Compare commits

..

31 commits

Author SHA1 Message Date
Simon Willison
56dd09702f Run no-default-groups smoke test from Justfile
Refs #842

I had to add --isolated because otherwise this test would pass if a .venv
folder already existed with the dev dependencies installed in it.
2026-08-13 17:01:47 -07:00
Simon Willison
28dc6278cc Release 4.2.1
Refs #842, #843
2026-08-13 16:52:30 -07:00
Simon Willison
f6d73112c8
Fix for sqlite-utils 4.2 crashing bug (#843)
- Remove from typing_extensions import Self
- Smoke test: uv run --no-default-groups sqlite-utils --help

Closes #842
2026-08-13 16:52:03 -07:00
Simon Willison
1d98613f28 Release 4.2
Refs #488, #602, #762, #790, #805, #808, #811, #816, #821, #822, #824, #825, #828, #829, #831, #833, #834, #836, #837
2026-08-13 13:09:42 -07:00
ikatyal2110
e4935e0644
transform: coerce empty strings to NULL when converting TEXT columns to numeric types (#805)
* transform: coerce empty strings to NULL when converting TEXT columns to numeric types

When a TEXT column is transformed to INTEGER, FLOAT, or REAL and a row
contains an empty string, the empty string is now converted to NULL during
the INSERT...SELECT copy, matching the expected behavior described in #488.

Fixes #488
2026-08-13 12:56:51 -07:00
Simon Willison
75ba588462 Preserve composite UNIQUE constraints in transforms 2026-08-12 18:46:18 -07:00
Simon Willison
2b52b5ed6f Preserve AUTOINCREMENT through transforms 2026-08-12 18:39:00 -07:00
Simon Willison
fcfccea813 Support ANY column types for strict tables
Closes #790, #820
2026-08-12 16:44:01 -07:00
Simon Willison
57192ef4e3 table.transform(rename=...) now preserves indexes, closes #822 2026-08-12 14:42:24 -07:00
Simon Willison
e4784ec120 Changelog updates
Refs #808, #811, #816, #821, #824, #825, #828, #829, #833, #836, #837
2026-08-12 14:38:30 -07:00
Simon Willison
88b48fa167 Fixed introspection of default values TRUE / FALSE / NULL
Closes #836
2026-08-12 14:19:38 -07:00
nyxst4ck
e6be6267a4
Use quote_identifier() in indexes/xindexes PRAGMA statements (#825)
Closes #824
2026-08-12 14:15:17 -07:00
Simon Willison
c5063f67b1 Use quoted SQL identifiers in convert --dry-run, closes #829 2026-08-12 14:14:27 -07:00
Rami Abdelrazzaq
25c632fbbc
Handle empty input in rows_from_file
Closes #808
2026-08-12 14:05:09 -07:00
Simon Willison
ebb04a97de Fixes for Pyright, closes #833 2026-08-12 14:03:04 -07:00
Simon Willison
38fe466700 Use db.table() and db.view() in tests, closes #838 2026-08-12 14:03:04 -07:00
ethanhawkes-gif
43d5d3331f
Emit LIMIT -1 when offset is used without limit (#821)
* Emit LIMIT -1 when offset is used without limit, closes #816

SQLite requires a LIMIT clause to appear before OFFSET, so passing offset
without limit generated invalid SQL such as:

    select * from "t" offset 2

which raised OperationalError: near "2": syntax error.

A negative limit means "no upper bound" in SQLite, so "limit -1 offset N"
returns all rows from position N onwards.

Fixed in three places that build LIMIT/OFFSET SQL:

- Queryable.rows_where() - also covers pks_and_rows_where()
- Table.search_sql() - also covers search()
- the "sqlite-utils rows" CLI command

* Remove duplicate comments

---------

Co-authored-by: ethanhawkes-gif <259455325+ethanhawkes-gif@users.noreply.github.com>
2026-08-11 22:52:43 -07:00
Bunlong Heng
2d3c6b9a1e
Escape tokenize argument in enable_fts (#828)
The tokenize value passed to Table.enable_fts() was interpolated directly
into the CREATE VIRTUAL TABLE statement inside a single-quoted string
literal. A value containing a single quote could break out of that literal
and inject arbitrary SQL, which executes via executescript(). This is
reachable from the CLI via 'enable-fts --tokenize'.

Route the value through the existing Database.quote() helper so SQLite
itself escapes it. Legitimate tokenizers such as 'porter' are unaffected.
Adds a regression test.
2026-08-11 22:48:06 -07:00
Simon Willison
b37b8cf8c8 Preserve column before/after comments through .transform()
Refs #762

The before comment comes before the column definition - the after
comment is anything after it but before its trailing comma.
2026-08-11 22:45:00 -07:00
Simon Willison
b432e686ca Use sqlite_master not sqlite_schema for older SQLite compatibility 2026-08-11 22:45:00 -07:00
Simon Willison
2303b80aef .transform() preserves check constraints, refs #762 2026-08-11 22:45:00 -07:00
Simon Willison
3db0c57a3b table.checks, table.column_checks, table.table_checks, closes #834
Refs #762
2026-08-11 22:45:00 -07:00
Simon Willison
f726ea4a65
transform() now works for tables referenced by views (#832)
Closes #831
2026-08-11 20:48:11 -07:00
ikatyal2110
6a456830ca
Fix _decode_default_value to unescape doubled single quotes in string defaults (#811)
* Fix _decode_default_value to unescape doubled single quotes in string defaults

SQLite stores string defaults with single quotes doubled (e.g. DEFAULT 'O''Brien'
is stored as the literal "'O''Brien'" in sqlite_master). The previous code
stripped the outer quotes with value[1:-1] but never converted '' back to ',
so default_values returned the raw escaped form instead of the true string value.

* Test for doubled single quotes in string defaults
2026-07-25 21:52:04 -07:00
Simon Willison
a7b734946f Changelog entry for 3.39.1
Refs #815

Copied from e1d55de8f8
2026-07-25 21:50:59 -07:00
Simon Willison
c621499ed1 codespell should check sqlite_utils as well
It did in CI but did not in the Justfile
2026-07-25 14:53:46 -07:00
Simon Willison
69a1c0d960
Fixes for Ruff>=0.16.0 (#814)
* Automated upgrades by Ruff

    uvx --with 'ruff>=0.16.0' ruff check . --fix --unsafe-fixes

* Fix remaining Ruff errors with GPT-5.6 Sol high

https://gist.github.com/simonw/6da7906a9fea6e90da131c21a9055199

* Fix flake E501 long lines
* New Protocol for migrations to make ty happy
2026-07-25 14:53:12 -07:00
Simon Willison
a947dc6739 Changelog now links to CLI and Python API in most recent entry 2026-07-12 17:14:01 -07:00
Simon Willison
458b3ab5b1
Release 4.1.1
Refs #791, #792, #794, #795
2026-07-12 13:52:14 -07:00
Simon Willison
f66ddcb215
Transform now refuses to run inside a transaction if destructive foreign keys exist (#795)
* Transform now refuses to run inside a transaction if destructive foreign keys exist

Closes #794

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014StVTWQJpFhfZJK2CYVBwv
2026-07-12 08:43:51 -07:00
Simon Willison
d714200659
Add test: transform does not cascade-delete referencing records (#792)
> Add a test that covers what happens if you run transform against a table with ON CASCADE DELETE for one of its foreign keys - those records should not be deleted during the transform even though the table is dropped as part of that procedure
2026-07-12 05:00:32 -07:00
71 changed files with 5329 additions and 2361 deletions

View file

@ -43,6 +43,9 @@ jobs:
run: pytest --sqlite-autocommit run: pytest --sqlite-autocommit
- name: run mypy - name: run mypy
run: mypy sqlite_utils tests run: mypy sqlite_utils tests
- name: run pyright regression checks
if: matrix.os == 'ubuntu-latest' && matrix.python-version == '3.14'
run: pyright sqlite_utils tests
- name: run flake8 - name: run flake8
run: flake8 run: flake8
- name: run ty - name: run ty
@ -50,6 +53,11 @@ jobs:
run: | run: |
pip install uv pip install uv
uv run ty check sqlite_utils uv run ty check sqlite_utils
- name: Check no accidental dev= dependencies needed
if: matrix.os == 'ubuntu-latest'
run: |
pip install uv
uv run --no-default-groups sqlite-utils --help
- name: Check formatting - name: Check formatting
run: black . --check run: black . --check
- name: Check if cog needs to be run - name: Check if cog needs to be run

1
.gitignore vendored
View file

@ -15,6 +15,7 @@ venv
.schema .schema
.vscode .vscode
.hypothesis .hypothesis
.claude/
Pipfile Pipfile
Pipfile.lock Pipfile.lock
uv.lock uv.lock

View file

@ -2,20 +2,25 @@
@default: test lint @default: test lint
# Run pytest with supplied options # Run pytest with supplied options
@test *options: @test *options: test-no-dev-dependencies
uv run pytest {{options}} uv run pytest {{options}}
@test-no-dev-dependencies:
uv run --isolated --no-default-groups sqlite-utils --help > /dev/null
@run *options: @run *options:
uv run -- {{options}} uv run -- {{options}}
# Run linters: black, flake8, mypy, ty, cog # Run linters: black, flake8, mypy, pyright, ty, cog
@lint: @lint:
just run black . --check just run black . --check
uv run flake8 uv run flake8
uv run mypy sqlite_utils tests uv run mypy sqlite_utils tests
uv run pyright sqlite_utils tests
uv run ty check sqlite_utils uv run ty check sqlite_utils
uv run cog --check README.md docs/*.rst uv run cog --check README.md docs/*.rst
uv run --group docs codespell docs/*.rst --ignore-words docs/codespell-ignore-words.txt uv run --group docs codespell docs/*.rst --ignore-words docs/codespell-ignore-words.txt
uv run --group docs codespell sqlite_utils --ignore-words docs/codespell-ignore-words.txt
# Rebuild docs with cog # Rebuild docs with cog
@cog: @cog:

View file

@ -4,6 +4,55 @@
Changelog Changelog
=========== ===========
.. _v4_2_1:
4.2.1 (2026-08-13)
------------------
- Fix for ``No module named 'typing_extensions'`` crashing bug accidentally shipped in version 4.2. (:issue:`842`)
.. _v4_2:
4.2 (2026-08-13)
----------------
- New ``table.checks``, ``table.column_checks`` and ``table.table_checks`` introspection properties expose column-level and table-level ``CHECK`` constraints. (:issue:`834`)
- New ``sqlite_utils.ANY`` marker type for creating and introspecting SQLite ``ANY`` columns. The Python API and CLI can create, add and transform these columns, and ``table.transform()`` and ``table.extract()`` now preserve ``ANY`` columns and their values in ``STRICT`` tables. (:issue:`790`)
- ``table.default_values`` now unescapes doubled single quotes in string defaults, so a default such as ``'O''Brien'`` is returned as ``"O'Brien"``. Thanks, `ikatyal2110 <https://github.com/ikatyal2110>`__. (`#811 <https://github.com/simonw/sqlite-utils/pull/811>`__)
- ``table.default_values`` now decodes unquoted ``TRUE``, ``FALSE`` and ``NULL`` default literals as ``True``, ``False`` and ``None`` respectively. (:issue:`836`)
- ``table.enable_fts(..., tokenize=...)`` and ``sqlite-utils enable-fts --tokenize`` now safely quote the tokenizer argument, preventing a crafted value from injecting additional SQL. Thanks, `Bunlong Heng <https://github.com/bunlongheng>`__. (`#828 <https://github.com/simonw/sqlite-utils/pull/828>`__)
- ``rows_where()``, ``pks_and_rows_where()``, ``search()`` and ``search_sql()`` now support ``offset=`` without requiring ``limit=``. The ``sqlite-utils rows --offset`` option now works without ``--limit`` too. Thanks, `ethanhawkes-gif <https://github.com/ethanhawkes-gif>`__. (:issue:`816`, `#821 <https://github.com/simonw/sqlite-utils/pull/821>`__)
- Empty or whitespace-only input passed to ``rows_from_file()`` is now handled as an empty CSV file instead of raising ``csv.Error``. Thanks, `Rami Abdelrazzaq <https://github.com/RamiNoodle733>`__. (:issue:`808`, `#837 <https://github.com/simonw/sqlite-utils/pull/837>`__)
- ``sqlite-utils convert --dry-run`` now works for table and column names containing closing square brackets. (:issue:`829`)
- ``table.indexes`` and ``table.xindexes`` now work for table, index and column names containing double quotes. This also fixes ``table.transform()`` for tables with those identifiers. Thanks, `nyxst4ck <https://github.com/nyxst4ck>`__. (:issue:`824`, `#825 <https://github.com/simonw/sqlite-utils/pull/825>`__)
- Improved type annotations throughout the package and added Pyright regression checks to CI. (:issue:`833`)
- Changing a ``TEXT`` column to ``INTEGER``, ``FLOAT`` or ``REAL`` using ``table.transform()`` or ``sqlite-utils transform`` now converts exact empty strings to ``NULL``. Previously they remained empty strings in the numeric column. Thanks, `ikatyal2110 <https://github.com/ikatyal2110>`__. (:issue:`488`, `#805 <https://github.com/simonw/sqlite-utils/pull/805>`__)
``table.transform()`` can handle many more edge-cases:
- ``table.transform()`` now preserves column-level and composite ``UNIQUE`` constraints, including constraint names, collations, sort order and ``ON CONFLICT`` behavior. Renaming columns updates those constraints, while dropping any constituent column removes the entire constraint. (:issue:`762`)
- ``table.transform()`` now preserves ``AUTOINCREMENT`` primary keys and their sequence high-water marks. Previously a transform removed ``AUTOINCREMENT`` and could reuse deleted row IDs. (:issue:`602`)
- ``table.transform()`` now preserves ``CHECK`` constraints, including comments within their expressions. Renaming a column rewrites identifier references in checks without changing string literals or function names. Dropping a column drops a check owned by that column, and raises ``TransformError`` if a remaining check depends on it. (:issue:`762`)
- ``table.transform()`` now preserves comments immediately before or after column definitions. These comments move with the column if it is renamed or reordered, and are removed if the column is dropped. (:issue:`762`)
- ``table.transform(rename=...)`` now preserves explicit indexes on renamed columns by dropping and recreating those indexes against the new column names. Previously this raised a ``TransformError``. (:issue:`822`)
- ``table.transform()`` now works for tables that are referenced by views. Previously the ``ALTER TABLE ... RENAME TO`` step raised ``no such table`` if a view referenced the table being transformed. View definitions are left unchanged - see :ref:`python_api_transform_views`. This also fixes a bug where ``transform(keep_table=...)`` silently rewrote dependent views to point at the frozen backup table instead of the live one. (:issue:`831`)
.. _v3_39_1:
3.39.1 (2026-07-25)
-------------------
- Fixed a bug where ``table.delete_where()`` left the connection in an open transaction, causing deleted rows to be silently restored when the connection was closed. (:issue:`815`)
.. _v4_1_1:
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: .. _v4_1:
4.1 (2026-07-11) 4.1 (2026-07-11)

View file

@ -494,7 +494,7 @@ See :ref:`cli_transform_table`.
Options: Options:
--type <TEXT CHOICE>... Change column type to INTEGER, TEXT, FLOAT, --type <TEXT CHOICE>... Change column type to INTEGER, TEXT, FLOAT,
REAL or BLOB REAL, BLOB or ANY
--drop TEXT Drop this column --drop TEXT Drop this column
--rename <TEXT TEXT>... Rename this column to X --rename <TEXT TEXT>... Rename this column to X
-o, --column-order TEXT Reorder columns -o, --column-order TEXT Reorder columns
@ -662,7 +662,7 @@ See :ref:`cli_convert`.
Convert a string like a,b,c into a JSON array ["a", "b", "c"] Convert a string like a,b,c into a JSON array ["a", "b", "c"]
r.parsedate(value: 'str', dayfirst: 'bool' = False, yearfirst: 'bool' = False, r.parsedate(value: 'str', dayfirst: 'bool' = False, yearfirst: 'bool' = False,
errors: 'Optional[object]' = None) -> 'Optional[str]' errors: 'object | None' = None) -> 'str | None'
Parse a date and convert it to ISO date format: yyyy-mm-dd Parse a date and convert it to ISO date format: yyyy-mm-dd
- dayfirst=True: treat xx as the day in xx/yy/zz - dayfirst=True: treat xx as the day in xx/yy/zz
@ -671,7 +671,7 @@ See :ref:`cli_convert`.
- errors=r.SET_NULL to set values that cannot be parsed to null - errors=r.SET_NULL to set values that cannot be parsed to null
r.parsedatetime(value: 'str', dayfirst: 'bool' = False, yearfirst: 'bool' = r.parsedatetime(value: 'str', dayfirst: 'bool' = False, yearfirst: 'bool' =
False, errors: 'Optional[object]' = None) -> 'Optional[str]' False, errors: 'object | None' = None) -> 'str | None'
Parse a datetime and convert it to ISO datetime format: yyyy-mm-ddTHH:MM:SS Parse a datetime and convert it to ISO datetime format: yyyy-mm-ddTHH:MM:SS
- dayfirst=True: treat xx as the day in xx/yy/zz - dayfirst=True: treat xx as the day in xx/yy/zz
@ -963,7 +963,7 @@ See :ref:`cli_create_table`.
height real \ height real \
photo blob --pk id photo blob --pk id
Valid column types are text, integer, real, float and blob. Valid column types are text, integer, real, float, blob and any.
Options: Options:
--pk TEXT Column to use as primary key --pk TEXT Column to use as primary key
@ -1257,7 +1257,7 @@ See :ref:`cli_add_column`.
:: ::
Usage: sqlite-utils add-column [OPTIONS] PATH TABLE COL_NAME Usage: sqlite-utils add-column [OPTIONS] PATH TABLE COL_NAME
[integer|int|float|real|text|str|blob|bytes] [integer|int|float|real|text|str|blob|bytes|any]
Add a column to the specified table Add a column to the specified table

View file

@ -1390,7 +1390,14 @@ Use ``--type column-name type`` to override the type automatically chosen when t
This is useful for values such as ZIP codes, which may look like integers but should be stored as ``TEXT`` to preserve leading zeros. This is useful for values such as ZIP codes, which may look like integers but should be stored as ``TEXT`` to preserve leading zeros.
The column type should be one of ``TEXT``, ``INTEGER``, ``FLOAT``, ``REAL`` or ``BLOB``. Column types are matched case-insensitively. The column type should be one of ``TEXT``, ``INTEGER``, ``FLOAT``, ``REAL``, ``BLOB`` or ``ANY``. Column types are matched case-insensitively.
``ANY`` is especially useful with ``--strict``. An ``ANY`` column in a strict table preserves values without coercion, so text such as ``000123`` remains text instead of being converted to an integer:
.. code-block:: bash
sqlite-utils insert events.db events events.csv --csv --strict \
--type payload any
As with detected column types, ``--type`` only affects tables created by the command. If the table already exists, its existing column types are left unchanged. As with detected column types, ``--type`` only affects tables created by the command. If the table already exists, its existing column types are left unchanged.
@ -2141,6 +2148,12 @@ You can create a table in `SQLite STRICT mode <https://www.sqlite.org/stricttabl
sqlite-utils create-table mydb.db mytable id integer name text --strict sqlite-utils create-table mydb.db mytable id integer name text --strict
Use the ``any`` type for a strict column that should accept integers, floating point values, text, binary data or null without coercion:
.. code-block:: bash
sqlite-utils create-table events.db events id integer payload any --strict
.. code-block:: bash .. code-block:: bash
sqlite-utils tables mydb.db --schema -t sqlite-utils tables mydb.db --schema -t
@ -2223,7 +2236,7 @@ The ``transform`` command allows you to apply complex transformations to a table
Every option for this table (with the exception of ``--pk-none``) can be specified multiple times. The options are as follows: Every option for this table (with the exception of ``--pk-none``) can be specified multiple times. The options are as follows:
``--type column-name new-type`` ``--type column-name new-type``
Change the type of the specified column. Valid types are ``integer``, ``text``, ``float``, ``blob``. Change the type of the specified column. Valid types are ``integer``, ``text``, ``float``, ``real``, ``blob`` and ``any``. Changing a ``TEXT`` column to ``INTEGER``, ``FLOAT`` or ``REAL`` converts exact empty-string values to ``NULL``.
``--drop column-name`` ``--drop column-name``
Drop the specified column. Drop the specified column.
@ -2288,7 +2301,11 @@ If you want to see the SQL that will be executed to make the change without actu
INSERT INTO "roadside_attractions_new_4033a60276b9" ("longitude", "latitude", "id", "name") INSERT INTO "roadside_attractions_new_4033a60276b9" ("longitude", "latitude", "id", "name")
SELECT "longitude", "latitude", "pk", "name" FROM "roadside_attractions"; SELECT "longitude", "latitude", "pk", "name" FROM "roadside_attractions";
DROP TABLE "roadside_attractions"; DROP TABLE "roadside_attractions";
PRAGMA legacy_alter_table=ON;
ALTER TABLE "roadside_attractions_new_4033a60276b9" RENAME TO "roadside_attractions"; ALTER TABLE "roadside_attractions_new_4033a60276b9" RENAME TO "roadside_attractions";
PRAGMA legacy_alter_table=OFF;
Tables that are referenced by views can be transformed - the view definitions are left unchanged, see :ref:`python_api_transform_views` for details.
.. note:: .. note::
In Python: :ref:`table.transform() <python_api_transform>` CLI reference: :ref:`sqlite-utils transform <cli_ref_transform>` In Python: :ref:`table.transform() <python_api_transform>` CLI reference: :ref:`sqlite-utils transform <cli_ref_transform>`

View file

@ -1,10 +1,7 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
import inspect import inspect
from pathlib import Path
from subprocess import Popen, PIPE, check_output
import sys import sys
from pathlib import Path
from subprocess import PIPE, CalledProcessError, Popen, check_output
# This file is execfile()d with the current directory set to its # This file is execfile()d with the current directory set to its
# containing dir. # containing dir.
@ -50,7 +47,7 @@ extlinks = {
def _linkcode_git_ref(): def _linkcode_git_ref():
try: try:
return check_output(["git", "rev-parse", "HEAD"]).decode("utf8").strip() return check_output(["git", "rev-parse", "HEAD"]).decode("utf8").strip()
except Exception: except (CalledProcessError, OSError):
return "main" return "main"
@ -79,7 +76,7 @@ def linkcode_resolve(domain, info):
obj = inspect.unwrap(obj) obj = inspect.unwrap(obj)
source_file = inspect.getsourcefile(obj) source_file = inspect.getsourcefile(obj)
_, line_number = inspect.getsourcelines(obj) _, line_number = inspect.getsourcelines(obj)
except Exception: except (OSError, TypeError, ValueError):
return None return None
if source_file is None: if source_file is None:

View file

@ -434,9 +434,10 @@ The library will never commit a transaction you opened. If you call write method
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. 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.
Two related safeguards to be aware of: Some 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. - ``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`. - 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: .. _python_api_transactions_modes:
@ -827,6 +828,19 @@ You can pass ``strict=True`` to create a table in ``STRICT`` mode:
"name": str, "name": str,
}, strict=True) }, strict=True)
SQLite ``STRICT`` tables can use the ``ANY`` column type for values that should retain their exact SQLite storage class without coercion. Use the ``sqlite_utils.ANY`` marker type:
.. code-block:: python
import sqlite_utils
db.table("events").create({
"id": int,
"payload": sqlite_utils.ANY,
}, pk="id", strict=True)
An ``ANY`` column can store integers, floating point values, text, binary data or ``None``. In a ``STRICT`` table a text value such as ``"000123"`` remains text with its leading zeroes intact. SQLite also accepts ``ANY`` columns in ordinary non-``STRICT`` tables, but those columns apply numeric affinity and would store that same value as the integer ``123``.
.. note:: .. note::
In the CLI: :ref:`sqlite-utils create-table <cli_create_table>` In the CLI: :ref:`sqlite-utils create-table <cli_create_table>`
@ -1568,7 +1582,7 @@ You can specify the ``col_type`` argument either using a SQLite type as a string
The ``col_type`` is optional - if you omit it the type of ``TEXT`` will be used. The ``col_type`` is optional - if you omit it the type of ``TEXT`` will be used.
SQLite types you can specify are ``"TEXT"``, ``"INTEGER"``, ``"FLOAT"``, ``"REAL"`` or ``"BLOB"``. SQLite types you can specify are ``"TEXT"``, ``"INTEGER"``, ``"FLOAT"``, ``"REAL"``, ``"BLOB"`` or ``"ANY"``. You can use the ``sqlite_utils.ANY`` marker instead of the ``"ANY"`` string.
If you pass a Python type, it will be mapped to SQLite types as shown here:: If you pass a Python type, it will be mapped to SQLite types as shown here::
@ -1581,6 +1595,7 @@ If you pass a Python type, it will be mapped to SQLite types as shown here::
datetime.date: "TEXT" datetime.date: "TEXT"
datetime.time: "TEXT" datetime.time: "TEXT"
datetime.timedelta: "TEXT" datetime.timedelta: "TEXT"
sqlite_utils.ANY: "ANY"
# If numpy is installed # If numpy is installed
np.int8: "INTEGER" np.int8: "INTEGER"
@ -1811,6 +1826,8 @@ To alter the type of a column, use the ``types=`` argument:
# Convert the 'age' column to an integer, and 'weight' to a float # Convert the 'age' column to an integer, and 'weight' to a float
table.transform(types={"age": int, "weight": float}) table.transform(types={"age": int, "weight": float})
When a ``TEXT`` column is changed to ``INTEGER``, ``FLOAT`` or ``REAL``, exact empty-string values are stored as ``NULL``. Other values, including whitespace-only strings, are copied normally.
See :ref:`python_api_add_column` for a list of available types. See :ref:`python_api_add_column` for a list of available types.
.. _python_api_transform_strict: .. _python_api_transform_strict:
@ -1830,6 +1847,8 @@ Pass ``strict=False`` to convert a strict table back to a regular non-strict tab
table.transform(strict=False) table.transform(strict=False)
If the table has ``ANY`` columns, converting it to non-strict mode can coerce text values that look numeric. For example, SQLite converts ``"000123"`` to the integer ``123`` when copying it into an ordinary ``ANY`` column. This is SQLite's documented distinction between `STRICT and ordinary ANY columns <https://www.sqlite.org/stricttables.html#the_any_datatype>`__.
The default is ``strict=None``, which preserves the table's existing strict mode. 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. Passing ``strict=True`` raises ``sqlite_utils.db.TransformError`` if the available SQLite version does not support strict tables.
@ -1985,6 +2004,28 @@ A bare column name drops any foreign key that column participates in, including
Renaming a column with ``rename=`` updates any foreign keys that use it, and dropping a column with ``drop=`` also drops any foreign keys it participates in - for a compound foreign key this removes the whole constraint. Renaming a column with ``rename=`` updates any foreign keys that use it, and dropping a column with ``drop=`` also drops any foreign keys it participates in - for a compound foreign key this removes the whole constraint.
.. _python_api_transform_check_constraints:
CHECK constraints
-----------------
``.transform()`` preserves both column-level and table-level ``CHECK`` constraints. If a column is renamed, references to that column in the check expression are renamed too.
A column-level check is removed if its owning column is dropped. Dropping a column referenced by any remaining check raises ``TransformError`` instead of creating an invalid or unexpectedly weakened schema.
Comments immediately before or after a column definition are preserved too. They move with that column if it is renamed or reordered, and are removed if the column is dropped. A comment between two column definitions is treated as belonging to the following column.
.. _python_api_transform_views:
Tables referenced by views
--------------------------
Tables that are referenced by views can be safely transformed - the view definitions are left byte-for-byte unchanged, and views continue to read from the live table even when ``keep_table=`` is used to keep a copy of the original around.
A view that references a column which the transform renamed or dropped will remain defined but will raise a ``no such column`` error when it is next queried. This is inherent to SQLite views, whose SQL is stored as text - if you rename or drop columns that a view depends on you should update that view definition yourself.
To achieve this, the SQL produced by ``transform_sql()`` turns on ``PRAGMA legacy_alter_table`` for its ``ALTER TABLE ... RENAME TO`` statements, then restores the pragma to the value it had when the SQL was generated - without this, SQLite would attempt to rewrite references to the renamed table in every view definition, which fails when a view references the table that was just dropped.
.. _python_api_transform_sql: .. _python_api_transform_sql:
Custom transformations with .transform_sql() Custom transformations with .transform_sql()
@ -1996,6 +2037,36 @@ 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. 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: .. _python_api_extract:
Extracting columns into a separate table Extracting columns into a separate table
@ -2405,6 +2476,11 @@ The ``.columns_dict`` property returns a dictionary version of the columns with
>>> db.table("PlantType").columns_dict >>> db.table("PlantType").columns_dict
{'id': <class 'int'>, 'value': <class 'str'>} {'id': <class 'int'>, 'value': <class 'str'>}
SQLite ``ANY`` columns are represented by the ``sqlite_utils.ANY`` marker type::
>>> db.table("events").columns_dict
{'id': <class 'int'>, 'payload': <class 'sqlite_utils.utils.ANY'>}
.. _python_api_introspection_default_values: .. _python_api_introspection_default_values:
.default_values .default_values
@ -2438,6 +2514,43 @@ Almost all SQLite tables have a ``rowid`` column, but a table with no explicitly
False False
.. _python_api_introspection_checks:
.checks
-------
The ``.checks`` property returns the column-level and table-level ``CHECK`` constraints defined on a table, as a list of ``Check`` objects. Each object has ``check`` (the expression inside ``CHECK (...)``), ``name``, ``column`` and ``options`` attributes. ``column`` is an empty string for a table-level check. ``options`` contains a list of values only when a column check consists entirely of ``column IN (literal, ...)``. The original constraint fragment is available as ``sql``; ``start`` and ``end`` are its offsets within ``table.schema``.
.. code-block:: python
>>> db["scores"].checks
[Check(check='score > 0', name='positive', column='score', options=None),
Check(check='score <= maximum', name='within_maximum', column='', options=None)]
.. _python_api_introspection_column_checks:
.column_checks
--------------
The ``.column_checks`` property returns the column-level checks grouped by column name:
.. code-block:: python
>>> db["scores"].column_checks
{'score': [Check(check='score > 0', name='positive', column='score', options=None)]}
.. _python_api_introspection_table_checks:
.table_checks
-------------
The ``.table_checks`` property returns only the table-level checks:
.. code-block:: python
>>> db["scores"].table_checks
[Check(check='score <= maximum', name='within_maximum', column='', options=None)]
.. _python_api_introspection_foreign_keys: .. _python_api_introspection_foreign_keys:
.foreign_keys .foreign_keys

View file

@ -1,6 +1,6 @@
[project] [project]
name = "sqlite-utils" name = "sqlite-utils"
version = "4.1" version = "4.2.1"
description = "CLI tool and Python library for manipulating SQLite databases" description = "CLI tool and Python library for manipulating SQLite databases"
readme = { file = "README.md", content-type = "text/markdown" } readme = { file = "README.md", content-type = "text/markdown" }
authors = [ authors = [
@ -48,6 +48,7 @@ dev = [
# flake8 # flake8
"flake8", "flake8",
"flake8-pyproject", "flake8-pyproject",
"pyright>=1.1.411",
"ty>=0.0.37", "ty>=0.0.37",
# For stable cog: # For stable cog:
"tabulate>=0.10.0", "tabulate>=0.10.0",
@ -79,7 +80,14 @@ build-backend = "setuptools.build_meta"
max-line-length = 160 max-line-length = 160
# Black compatibility, E203 whitespace before ':': # Black compatibility, E203 whitespace before ':':
extend-ignore = ["E203"] extend-ignore = ["E203"]
extend-exclude = [".venv", "build", "dist", "docs", "sqlite_utils.egg-info"] extend-exclude = [
".venv",
".claude",
"build",
"dist",
"docs",
"sqlite_utils.egg-info",
]
[tool.setuptools.package-data] [tool.setuptools.package-data]
sqlite_utils = ["py.typed"] sqlite_utils = ["py.typed"]

View file

@ -1,7 +1,13 @@
from .utils import suggest_column_types
from .hookspecs import hookimpl
from .hookspecs import hookspec
from .db import Database from .db import Database
from .hookspecs import hookimpl, hookspec
from .migrations import Migrations from .migrations import Migrations
from .utils import ANY, suggest_column_types
__all__ = ["Database", "Migrations", "suggest_column_types", "hookimpl", "hookspec"] __all__ = [
"ANY",
"Database",
"Migrations",
"hookimpl",
"hookspec",
"suggest_column_types",
]

View file

@ -1,17 +1,30 @@
import base64 import base64
import csv as csv_std
import difflib import difflib
from typing import Any
import click
from click_default_group import DefaultGroup
from datetime import datetime, timezone
import hashlib import hashlib
import inspect
import io
import itertools
import json
import os
import pathlib import pathlib
import pdb # noqa: T100
import sys
import textwrap
from datetime import datetime, timezone
from runpy import run_module from runpy import run_module
from typing import Any
import click
import tabulate
from click_default_group import DefaultGroup
import sqlite_utils import sqlite_utils
from sqlite_utils import recipes
from sqlite_utils.db import ( from sqlite_utils.db import (
DEFAULT,
AlterError, AlterError,
BadMultiValues, BadMultiValues,
DEFAULT,
DescIndex, DescIndex,
InvalidColumns, InvalidColumns,
NoTable, NoTable,
@ -19,36 +32,28 @@ from sqlite_utils.db import (
PrimaryKeyRequired, PrimaryKeyRequired,
quote_identifier, quote_identifier,
) )
from sqlite_utils.plugins import ensure_plugins_loaded, pm, get_plugins from sqlite_utils.plugins import ensure_plugins_loaded, get_plugins, pm
from sqlite_utils.utils import maximize_csv_field_size_limit from sqlite_utils.utils import maximize_csv_field_size_limit
from sqlite_utils import recipes
import textwrap
import inspect
import io
import itertools
import json
import os
import pdb
import sys
import csv as csv_std
import tabulate
from .utils import ( from .utils import (
Format,
OperationalError, OperationalError,
TypeTracker,
_compile_code, _compile_code,
chunks, chunks,
decode_base64_values,
dedupe_keys, dedupe_keys,
file_progress, file_progress,
find_spatialite, find_spatialite,
flatten as _flatten,
sqlite3,
decode_base64_values,
progressbar, progressbar,
rows_from_file, rows_from_file,
Format, sqlite3,
TypeTracker, )
from .utils import (
flatten as _flatten,
) )
CONTEXT_SETTINGS = dict(help_option_names=["-h", "--help"]) CONTEXT_SETTINGS = {"help_option_names": ["-h", "--help"]}
def _register_db_for_cleanup(db): def _register_db_for_cleanup(db):
@ -67,11 +72,11 @@ def _close_databases(ctx):
for db in ctx.meta.get("_databases_to_close", []): for db in ctx.meta.get("_databases_to_close", []):
try: try:
db.close() db.close()
except Exception: except sqlite3.Error:
pass pass
VALID_COLUMN_TYPES = ("INTEGER", "TEXT", "FLOAT", "REAL", "BLOB") VALID_COLUMN_TYPES = ("INTEGER", "TEXT", "FLOAT", "REAL", "BLOB", "ANY")
UNICODE_ERROR = """ UNICODE_ERROR = """
{} {}
@ -174,7 +179,6 @@ def functions_option(fn):
@click.version_option() @click.version_option()
def cli(): def cli():
"Commands for interacting with a SQLite database" "Commands for interacting with a SQLite database"
pass
@cli.command() @cli.command()
@ -485,7 +489,17 @@ def dump(path, load_extension):
@click.argument( @click.argument(
"col_type", "col_type",
type=click.Choice( type=click.Choice(
["integer", "int", "float", "real", "text", "str", "blob", "bytes"], [
"integer",
"int",
"float",
"real",
"text",
"str",
"blob",
"bytes",
"any",
],
case_sensitive=False, case_sensitive=False,
), ),
required=False, required=False,
@ -891,7 +905,7 @@ def enable_counts(path, tables, load_extension):
# Check all tables exist # Check all tables exist
bad_tables = [table for table in tables if not db[table].exists()] bad_tables = [table for table in tables if not db[table].exists()]
if bad_tables: if bad_tables:
raise click.ClickException("Invalid tables: {}".format(bad_tables)) raise click.ClickException(f"Invalid tables: {bad_tables}")
for table in tables: for table in tables:
db.table(table).enable_counts() db.table(table).enable_counts()
@ -1089,7 +1103,7 @@ def insert_upsert_implementation(
column_type_overrides = {column: ctype.upper() for column, ctype in (types or [])} column_type_overrides = {column: ctype.upper() for column, ctype in (types or [])}
def _insert_docs(docs, tracker=None): def _insert_docs(docs, tracker=None):
extra_kwargs = { extra_kwargs: dict[str, Any] = {
"ignore": ignore, "ignore": ignore,
"replace": replace, "replace": replace,
"truncate": truncate, "truncate": truncate,
@ -1140,9 +1154,7 @@ def insert_upsert_implementation(
) )
): ):
raise click.ClickException( raise click.ClickException(
"{}\n\nTry using --alter to add additional columns".format( f"{e.args[0]}\n\nTry using --alter to add additional columns"
e.args[0]
)
) )
# If we can find sql= and parameters= arguments, show those # If we can find sql= and parameters= arguments, show those
variables = _find_variables(e.__traceback__, ["sql", "parameters"]) variables = _find_variables(e.__traceback__, ["sql", "parameters"])
@ -1240,7 +1252,7 @@ def insert_upsert_implementation(
reader = csv_std.reader(decoded, **csv_reader_args) # type: ignore reader = csv_std.reader(decoded, **csv_reader_args) # type: ignore
first_row = next(reader) first_row = next(reader)
if no_headers: if no_headers:
headers = ["untitled_{}".format(i + 1) for i in range(len(first_row))] headers = [f"untitled_{i + 1}" for i in range(len(first_row))]
reader = itertools.chain([first_row], reader) reader = itertools.chain([first_row], reader)
else: else:
headers = first_row headers = first_row
@ -1269,9 +1281,7 @@ def insert_upsert_implementation(
docs = [docs] docs = [docs]
except json.decoder.JSONDecodeError as ex: except json.decoder.JSONDecodeError as ex:
raise click.ClickException( raise click.ClickException(
"Invalid JSON - use --csv for CSV or --tsv for TSV files\n\nJSON error: {}".format( f"Invalid JSON - use --csv for CSV or --tsv for TSV files\n\nJSON error: {ex}"
ex
)
) )
if flatten: if flatten:
docs = (_flatten(doc) for doc in docs) docs = (_flatten(doc) for doc in docs)
@ -1290,7 +1300,7 @@ def insert_upsert_implementation(
docs = (fn(doc["line"]) for doc in docs) docs = (fn(doc["line"]) for doc in docs)
elif text: elif text:
# Special case: this is allowed to be an iterable # Special case: this is allowed to be an iterable
text_value = list(docs)[0]["text"] text_value = next(iter(docs))["text"]
fn_return = fn(text_value) fn_return = fn(text_value)
if isinstance(fn_return, dict): if isinstance(fn_return, dict):
docs = [fn_return] docs = [fn_return]
@ -1758,7 +1768,7 @@ def create_table(
height real \\ height real \\
photo blob --pk id photo blob --pk id
Valid column types are text, integer, real, float and blob. Valid column types are text, integer, real, float, blob and any.
""" """
db = sqlite_utils.Database(path) db = sqlite_utils.Database(path)
_register_db_for_cleanup(db) _register_db_for_cleanup(db)
@ -1774,16 +1784,13 @@ def create_table(
ctype = columns.pop(0) ctype = columns.pop(0)
if ctype.upper() not in VALID_COLUMN_TYPES: if ctype.upper() not in VALID_COLUMN_TYPES:
raise click.ClickException( raise click.ClickException(
"column types must be one of {}".format(VALID_COLUMN_TYPES) f"column types must be one of {VALID_COLUMN_TYPES}"
) )
coltypes[name] = ctype.upper() coltypes[name] = ctype.upper()
# Does table already exist? # Does table already exist?
if table in db.table_names(): if table in db.table_names() and not ignore and not replace and not transform:
if not ignore and not replace and not transform:
raise click.ClickException( raise click.ClickException(
'Table "{}" already exists. Use --replace to delete and replace it.'.format( f'Table "{table}" already exists. Use --replace to delete and replace it.'
table
)
) )
db.table(table).create( db.table(table).create(
coltypes, coltypes,
@ -1819,7 +1826,7 @@ def duplicate(path, table, new_table, ignore, load_extension):
db.table(table).duplicate(new_table) db.table(table).duplicate(new_table)
except NoTable: except NoTable:
if not ignore: if not ignore:
raise click.ClickException('Table "{}" does not exist'.format(table)) raise click.ClickException(f'Table "{table}" does not exist')
@cli.command(name="rename-table") @cli.command(name="rename-table")
@ -1843,9 +1850,7 @@ def rename_table(path, table, new_name, ignore, load_extension):
db.rename_table(table, new_name) db.rename_table(table, new_name)
except sqlite3.OperationalError as ex: except sqlite3.OperationalError as ex:
if not ignore: if not ignore:
raise click.ClickException( raise click.ClickException(f'Table "{table}" could not be renamed. {ex!s}')
'Table "{}" could not be renamed. {}'.format(table, str(ex))
)
@cli.command(name="drop-table") @cli.command(name="drop-table")
@ -1874,10 +1879,10 @@ def drop_table(path, table, ignore, load_extension):
# A view exists with this name # A view exists with this name
if not ignore: if not ignore:
raise click.ClickException( raise click.ClickException(
'"{}" is a view, not a table - use drop-view to drop it'.format(table) f'"{table}" is a view, not a table - use drop-view to drop it'
) )
except OperationalError: except OperationalError:
raise click.ClickException('Table "{}" does not exist'.format(table)) raise click.ClickException(f'Table "{table}" does not exist')
@cli.command(name="create-view") @cli.command(name="create-view")
@ -1919,9 +1924,7 @@ def create_view(path, view, select, ignore, replace, load_extension):
db.view(view).drop() db.view(view).drop()
else: else:
raise click.ClickException( raise click.ClickException(
'View "{}" already exists. Use --replace to delete and replace it.'.format( f'View "{view}" already exists. Use --replace to delete and replace it.'
view
)
) )
db.create_view(view, select) db.create_view(view, select)
@ -1953,9 +1956,9 @@ def drop_view(path, view, ignore, load_extension):
return return
if view in db.table_names(): if view in db.table_names():
raise click.ClickException( raise click.ClickException(
'"{}" is a table, not a view - use drop-table to drop it'.format(view) f'"{view}" is a table, not a view - use drop-table to drop it'
) )
raise click.ClickException('View "{}" does not exist'.format(view)) raise click.ClickException(f'View "{view}" does not exist')
@cli.command() @cli.command()
@ -2177,7 +2180,7 @@ def memory(
file_path = pathlib.Path(path) file_path = pathlib.Path(path)
stem = file_path.stem stem = file_path.stem
if stem_counts.get(stem): if stem_counts.get(stem):
file_table = "{}_{}".format(stem, stem_counts[stem]) file_table = f"{stem}_{stem_counts[stem]}"
else: else:
file_table = stem file_table = stem
stem_counts[stem] = stem_counts.get(stem, 1) + 1 stem_counts[stem] = stem_counts.get(stem, 1) + 1
@ -2196,14 +2199,14 @@ def memory(
if tracker is not None and db.table(file_table).exists(): if tracker is not None and db.table(file_table).exists():
db.table(file_table).transform(types=tracker.types) db.table(file_table).transform(types=tracker.types)
# Add convenient t / t1 / t2 views # Add convenient t / t1 / t2 views
view_names = ["t{}".format(i + 1)] view_names = [f"t{i + 1}"]
if i == 0: if i == 0:
view_names.append("t") view_names.append("t")
for view_name in view_names: for view_name in view_names:
if not db[view_name].exists(): if not db[view_name].exists():
db.create_view( db.create_view(
view_name, view_name,
"select * from {}".format(quote_identifier(file_table)), f"select * from {quote_identifier(file_table)}",
) )
finally: finally:
if should_close_fp and fp: if should_close_fp and fp:
@ -2373,19 +2376,17 @@ def search(
# Check table exists # Check table exists
table_obj = db.table(dbtable) table_obj = db.table(dbtable)
if not table_obj.exists(): if not table_obj.exists():
raise click.ClickException("Table '{}' does not exist".format(dbtable)) raise click.ClickException(f"Table '{dbtable}' does not exist")
if not table_obj.detect_fts(): if not table_obj.detect_fts():
raise click.ClickException( raise click.ClickException(
"Table '{}' is not configured for full-text search".format(dbtable) f"Table '{dbtable}' is not configured for full-text search"
) )
if column: if column:
# Check they all exist # Check they all exist
table_columns = table_obj.columns_dict table_columns = table_obj.columns_dict
for c in column: for c in column:
if c not in table_columns: if c not in table_columns:
raise click.ClickException( raise click.ClickException(f"Table '{dbtable}' has no column '{c}")
"Table '{}' has no column '{}".format(dbtable, c)
)
sql = table_obj.search_sql(columns=column, order_by=order, limit=limit) sql = table_obj.search_sql(columns=column, order_by=order, limit=limit)
if show_sql: if show_sql:
click.echo(sql) click.echo(sql)
@ -2412,7 +2413,7 @@ def search(
except click.ClickException as e: except click.ClickException as e:
if "malformed MATCH expression" in str(e) or "unterminated string" in str(e): if "malformed MATCH expression" in str(e) or "unterminated string" in str(e):
raise click.ClickException( raise click.ClickException(
"{}\n\nTry running this again with the --quote option".format(str(e)) f"{e!s}\n\nTry running this again with the --quote option"
) )
else: else:
raise raise
@ -2479,15 +2480,17 @@ def rows(
columns = "*" columns = "*"
if column: if column:
columns = ", ".join(quote_identifier(c) for c in column) columns = ", ".join(quote_identifier(c) for c in column)
sql = "select {} from {}".format(columns, quote_identifier(dbtable)) sql = f"select {columns} from {quote_identifier(dbtable)}"
if where: if where:
sql += " where " + where sql += " where " + where
if order: if order:
sql += " order by " + order sql += " order by " + order
if limit: if limit:
sql += " limit {}".format(limit) sql += f" limit {limit}"
if offset: if offset:
sql += " offset {}".format(offset) if not limit:
sql += " limit -1"
sql += f" offset {offset}"
ctx.invoke( ctx.invoke(
query, query,
path=path, path=path,
@ -2675,12 +2678,10 @@ def schema(
"--type", "--type",
type=( type=(
str, str,
click.Choice( click.Choice(list(VALID_COLUMN_TYPES), case_sensitive=False),
["INTEGER", "TEXT", "FLOAT", "REAL", "BLOB"], case_sensitive=False
),
), ),
multiple=True, multiple=True,
help="Change column type to INTEGER, TEXT, FLOAT, REAL or BLOB", help="Change column type to INTEGER, TEXT, FLOAT, REAL, BLOB or ANY",
) )
@click.option("--drop", type=str, multiple=True, help="Drop this column") @click.option("--drop", type=str, multiple=True, help="Drop this column")
@click.option( @click.option(
@ -2760,7 +2761,7 @@ def transform(
for column, ctype in type: for column, ctype in type:
if ctype.upper() not in VALID_COLUMN_TYPES: if ctype.upper() not in VALID_COLUMN_TYPES:
raise click.ClickException( raise click.ClickException(
"column types must be one of {}".format(VALID_COLUMN_TYPES) f"column types must be one of {VALID_COLUMN_TYPES}"
) )
types[column] = ctype.upper() types[column] = ctype.upper()
@ -2858,12 +2859,12 @@ def extract(
db = sqlite_utils.Database(path) db = sqlite_utils.Database(path)
_register_db_for_cleanup(db) _register_db_for_cleanup(db)
_load_extensions(db, load_extension) _load_extensions(db, load_extension)
kwargs: dict[str, Any] = dict( kwargs: dict[str, Any] = {
columns=columns, "columns": columns,
table=other_table, "table": other_table,
fk_column=fk_column, "fk_column": fk_column,
rename=dict(rename), "rename": dict(rename),
) }
try: try:
db.table(table).extract(**kwargs) db.table(table).extract(**kwargs)
except (NoTable, InvalidColumns) as e: except (NoTable, InvalidColumns) as e:
@ -2958,7 +2959,7 @@ def insert_files(
with progressbar(paths_and_relative_paths, silent=silent) as bar: with progressbar(paths_and_relative_paths, silent=silent) as bar:
def to_insert(): def to_insert():
for path, relative_path in bar: for file_path, relative_path in bar:
row = {} row = {}
# content_text is special case as it considers 'encoding' # content_text is special case as it considers 'encoding'
@ -2970,19 +2971,21 @@ def insert_files(
raise UnicodeDecodeErrorForPath(e, resolved) raise UnicodeDecodeErrorForPath(e, resolved)
lookups = dict(FILE_COLUMNS, content_text=_content_text) lookups = dict(FILE_COLUMNS, content_text=_content_text)
if path == "-": if file_path == "-":
stdin_data = sys.stdin.buffer.read() stdin_data = sys.stdin.buffer.read()
# We only support a subset of columns for this case # We only support a subset of columns for this case
lookups = { lookups = {
"name": lambda p: name or "-", "name": lambda p: name or "-",
"path": lambda p: name or "-", "path": lambda p: name or "-",
"content": lambda p: stdin_data, "content": lambda p, data=stdin_data: data,
"content_text": lambda p: stdin_data.decode( "content_text": lambda p, data=stdin_data: data.decode(
encoding or "utf-8" encoding or "utf-8"
), ),
"sha256": lambda p: hashlib.sha256(stdin_data).hexdigest(), "sha256": lambda p, data=stdin_data: hashlib.sha256(
"md5": lambda p: hashlib.md5(stdin_data).hexdigest(), data
"size": lambda p: len(stdin_data), ).hexdigest(),
"md5": lambda p, data=stdin_data: hashlib.md5(data).hexdigest(),
"size": lambda p, data=stdin_data: len(data),
} }
for coldef in column: for coldef in column:
if ":" in coldef: if ":" in coldef:
@ -2990,7 +2993,7 @@ def insert_files(
else: else:
colname, coltype = coldef, coldef colname, coltype = coldef, coldef
try: try:
value = lookups[coltype](path) value = lookups[coltype](file_path)
row[colname] = value row[colname] = value
except KeyError: except KeyError:
raise click.ClickException( raise click.ClickException(
@ -3018,7 +3021,7 @@ def insert_files(
except UnicodeDecodeErrorForPath as e: except UnicodeDecodeErrorForPath as e:
raise click.ClickException( raise click.ClickException(
UNICODE_ERROR.format( UNICODE_ERROR.format(
"Could not read file '{}' as text\n\n{}".format(e.path, e.exception) f"Could not read file '{e.path}' as text\n\n{e.exception}"
) )
) )
@ -3196,7 +3199,7 @@ def _generate_convert_help():
for name in recipe_names: for name in recipe_names:
fn = getattr(recipes, name) fn = getattr(recipes, name)
doc = textwrap.dedent(fn.__doc__.rstrip()).replace("\b\n", "") doc = textwrap.dedent(fn.__doc__.rstrip()).replace("\b\n", "")
help += "\n\nr.{}{}\n\n\b{}".format(name, str(inspect.signature(fn)), doc) help += f"\n\nr.{name}{inspect.signature(fn)!s}\n\n\b{doc}"
help += "\n\n" help += "\n\n"
help += textwrap.dedent(""" help += textwrap.dedent("""
You can use these recipes like so: You can use these recipes like so:
@ -3280,26 +3283,21 @@ def convert(
raise click.ClickException(str(e)) raise click.ClickException(str(e))
if dry_run: if dry_run:
# Pull first 20 values for first column and preview them # Pull first 20 values for first column and preview them
def preview(v):
if multi: if multi:
def preview(v):
return json.dumps(fn(v), default=repr, ensure_ascii=False) if v else v return json.dumps(fn(v), default=repr, ensure_ascii=False) if v else v
else:
def preview(v):
return fn(v) if v else v return fn(v) if v else v
db.conn.create_function("preview_transform", 1, preview) db.conn.create_function("preview_transform", 1, preview)
sql = """ sql = """
select select
[{column}] as value, {column} as value,
preview_transform([{column}]) as preview preview_transform({column}) as preview
from [{table}]{where} limit 10 from {table}{where} limit 10
""".format( """.format(
column=columns[0], column=quote_identifier(columns[0]),
table=table, table=quote_identifier(table),
where=" where {}".format(where) if where is not None else "", where=f" where {where}" if where is not None else "",
) )
for row in db.conn.execute(sql, where_args).fetchall(): for row in db.conn.execute(sql, where_args).fetchall():
click.echo(str(row[0])) click.echo(str(row[0]))
@ -3319,7 +3317,7 @@ def convert(
def wrapped_fn(value): def wrapped_fn(value):
try: try:
return fn_(value) return fn_(value)
except Exception as ex: except Exception as ex: # noqa: BLE001
print("\nException raised, dropping into pdb...:", ex) print("\nException raised, dropping into pdb...:", ex)
pdb.post_mortem(ex.__traceback__) pdb.post_mortem(ex.__traceback__)
sys.exit(1) sys.exit(1)
@ -3339,9 +3337,7 @@ def convert(
) )
except BadMultiValues as e: except BadMultiValues as e:
raise click.ClickException( raise click.ClickException(
"When using --multi code must return a Python dictionary - returned: {}".format( f"When using --multi code must return a Python dictionary - returned: {e.values!r}"
repr(e.values)
)
) )
@ -3459,7 +3455,7 @@ def create_spatial_index(db_path, table, column_name, load_extension):
def _find_migration_files(migrations): def _find_migration_files(migrations):
if not migrations: if not migrations:
migrations = [pathlib.Path(".").resolve()] migrations = [pathlib.Path.cwd()]
files = set() files = set()
for path_str in migrations: for path_str in migrations:
path = pathlib.Path(path_str) path = pathlib.Path(path_str)
@ -3484,7 +3480,7 @@ def _load_migration_sets(files):
"__file__": str(filepath), "__file__": str(filepath),
"__name__": "__sqlite_utils_migration__", "__name__": "__sqlite_utils_migration__",
} }
exec(code, namespace) exec(code, namespace) # noqa: S102
migration_sets.extend( migration_sets.extend(
obj for obj in namespace.values() if _compatible_migration_set(obj) obj for obj in namespace.values() if _compatible_migration_set(obj)
) )
@ -3493,17 +3489,17 @@ def _load_migration_sets(files):
def _display_migration_list(db, migration_sets): def _display_migration_list(db, migration_sets):
for migration_set in migration_sets: for migration_set in migration_sets:
click.echo("Migrations for: {}".format(migration_set.name)) click.echo(f"Migrations for: {migration_set.name}")
click.echo() click.echo()
click.echo(" Applied:") click.echo(" Applied:")
for migration in migration_set.applied(db): for migration in migration_set.applied(db):
click.echo(" {} - {}".format(migration.name, migration.applied_at)) click.echo(f" {migration.name} - {migration.applied_at}")
click.echo() click.echo()
click.echo(" Pending:") click.echo(" Pending:")
output = False output = False
for migration in migration_set.pending(db): for migration in migration_set.pending(db):
output = True output = True
click.echo(" {}".format(migration.name)) click.echo(f" {migration.name}")
if not output: if not output:
click.echo(" (none)") click.echo(" (none)")
click.echo() click.echo()
@ -3583,7 +3579,7 @@ def migrate(db_path, migrations, stop_before, list_, verbose):
prev_schema = db.schema prev_schema = db.schema
if verbose: if verbose:
click.echo("Migrating {}".format(db_path)) click.echo(f"Migrating {db_path}")
click.echo("\nSchema before:\n") click.echo("\nSchema before:\n")
click.echo(textwrap.indent(prev_schema, " ") or " (empty)") click.echo(textwrap.indent(prev_schema, " ") or " (empty)")
click.echo() click.echo()
@ -3594,9 +3590,7 @@ def migrate(db_path, migrations, stop_before, list_, verbose):
names = {m.name for m in migration_set.pending(db)} names = {m.name for m in migration_set.pending(db)}
names.update(m.name for m in migration_set.applied(db)) names.update(m.name for m in migration_set.applied(db))
known_names.update(names) known_names.update(names)
known_names.update( known_names.update(f"{migration_set.name}:{name}" for name in names)
"{}:{}".format(migration_set.name, name) for name in names
)
unknown = [value for value in stop_before if value not in known_names] unknown = [value for value in stop_before if value not in known_names]
if unknown: if unknown:
raise click.ClickException( raise click.ClickException(
@ -3652,7 +3646,7 @@ def _render_common(title, values):
return "" return ""
lines = [title] lines = [title]
for value, count in values: for value, count in values:
lines.append(" {}: {}".format(count, value)) lines.append(f" {count}: {value}")
return "\n".join(lines) return "\n".join(lines)
@ -3722,7 +3716,7 @@ def maybe_json(value):
if not isinstance(value, str): if not isinstance(value, str):
return value return value
stripped = value.strip() stripped = value.strip()
if not (stripped.startswith("{") or stripped.startswith("[")): if not (stripped.startswith(("{", "["))):
return value return value
try: try:
return json.loads(stripped) return json.loads(stripped)
@ -3740,7 +3734,7 @@ def json_binary(value):
def verify_is_dict(doc): def verify_is_dict(doc):
if not isinstance(doc, dict): if not isinstance(doc, dict):
raise click.ClickException( raise click.ClickException(
"Rows must all be dictionaries, got: {}".format(repr(doc)[:1000]) f"Rows must all be dictionaries, got: {repr(doc)[:1000]}"
) )
return doc return doc
@ -3768,14 +3762,14 @@ def _register_functions(db, functions):
try: try:
functions = pathlib.Path(functions).read_text() functions = pathlib.Path(functions).read_text()
except FileNotFoundError: except FileNotFoundError:
raise click.ClickException("File not found: {}".format(functions)) raise click.ClickException(f"File not found: {functions}")
sqlite3.enable_callback_tracebacks(True) sqlite3.enable_callback_tracebacks(True)
globals = {} globals = {}
try: try:
exec(functions, globals) exec(functions, globals) # noqa: S102
except SyntaxError as ex: except SyntaxError as ex:
raise click.ClickException("Error in functions definition: {}".format(ex)) raise click.ClickException(f"Error in functions definition: {ex}")
# Register all callables in the locals dict: # Register all callables in the locals dict:
for name, value in globals.items(): for name, value in globals.items():
if callable(value) and not name.startswith("_"): if callable(value) and not name.startswith("_"):
@ -3796,13 +3790,13 @@ def _rows_from_code(code):
try: try:
code = pathlib.Path(code).read_text() code = pathlib.Path(code).read_text()
except FileNotFoundError: except FileNotFoundError:
raise click.ClickException("File not found: {}".format(code)) raise click.ClickException(f"File not found: {code}")
namespace = {} namespace: dict[str, Any] = {}
try: try:
exec(code, namespace) exec(code, namespace) # noqa: S102
except SyntaxError as ex: except SyntaxError as ex:
raise click.ClickException("Error in --code: {}".format(ex)) raise click.ClickException(f"Error in --code: {ex}")
rows = namespace.get("rows") rows: Any = namespace.get("rows")
if callable(rows): if callable(rows):
rows = rows() rows = rows()
if isinstance(rows, dict): if isinstance(rows, dict):

View file

@ -0,0 +1,897 @@
"""Helpers for parsing constraints from SQLite CREATE TABLE SQL.
SQLite does not expose CHECK constraints through a pragma, so preserving them
across a table rebuild requires reading ``sqlite_schema.sql``. This module is
deliberately small, but it uses a real lexer: strings, quoted identifiers and
comments are opaque, every token retains its source span and malformed input is
reported instead of being silently under-parsed.
"""
import re
from dataclasses import dataclass, field
from typing import Any
@dataclass
class Check:
check: str
name: str = ""
column: str = ""
options: list[Any] | None = None
# Source details are excluded from equality and repr so callers can compare
# semantic constraints while still having the original SQL available for
# diagnostics or future lossless edits.
sql: str = field(default="", compare=False, repr=False)
start: int = field(default=-1, compare=False, repr=False)
end: int = field(default=-1, compare=False, repr=False)
@dataclass(frozen=True)
class ColumnComments:
before: str = ""
after: str = ""
@dataclass(frozen=True)
class UniqueColumn:
name: str
collation: str = ""
order: str = ""
@dataclass
class Unique:
columns: tuple[UniqueColumn, ...]
name: str = ""
column: str = ""
conflict: str = ""
sql: str = field(default="", compare=False, repr=False)
start: int = field(default=-1, compare=False, repr=False)
end: int = field(default=-1, compare=False, repr=False)
class ParseError(ValueError):
pass
@dataclass(frozen=True)
class _Token:
kind: str
text: str
start: int
end: int
def is_keyword(self, keyword: str) -> bool:
return self.kind == "word" and self.text.upper() == keyword
_PUNCTUATION = frozenset("(),.;+-*/%<>=!~|&?:")
_TRIVIA = frozenset(("whitespace", "comment"))
_TABLE_CONSTRAINT_KEYWORDS = frozenset(("PRIMARY", "UNIQUE", "CHECK", "FOREIGN"))
_OTHER_COLUMN_CONSTRAINT_KEYWORDS = frozenset(
("PRIMARY", "UNIQUE", "REFERENCES", "DEFAULT", "NOT", "COLLATE", "GENERATED")
)
_SQLITE_KEYWORDS = frozenset(
(
"ABORT",
"ACTION",
"ADD",
"AFTER",
"ALL",
"ALTER",
"ANALYZE",
"AND",
"AS",
"ASC",
"ATTACH",
"AUTOINCREMENT",
"BEFORE",
"BEGIN",
"BETWEEN",
"BY",
"CASCADE",
"CASE",
"CAST",
"CHECK",
"COLLATE",
"COLUMN",
"COMMIT",
"CONFLICT",
"CONSTRAINT",
"CREATE",
"CROSS",
"CURRENT_DATE",
"CURRENT_TIME",
"CURRENT_TIMESTAMP",
"DATABASE",
"DEFAULT",
"DEFERRABLE",
"DEFERRED",
"DELETE",
"DESC",
"DETACH",
"DISTINCT",
"DO",
"DROP",
"EACH",
"ELSE",
"END",
"ESCAPE",
"EXCEPT",
"EXCLUDE",
"EXCLUSIVE",
"EXISTS",
"EXPLAIN",
"FAIL",
"FALSE",
"FILTER",
"FIRST",
"FOLLOWING",
"FOR",
"FOREIGN",
"FROM",
"FULL",
"GENERATED",
"GLOB",
"GROUP",
"GROUPS",
"HAVING",
"IF",
"IGNORE",
"IMMEDIATE",
"IN",
"INDEX",
"INDEXED",
"INITIALLY",
"INNER",
"INSERT",
"INSTEAD",
"INTERSECT",
"INTO",
"IS",
"ISNULL",
"JOIN",
"KEY",
"LAST",
"LEFT",
"LIKE",
"LIMIT",
"MATCH",
"MATERIALIZED",
"NATURAL",
"NO",
"NOT",
"NOTHING",
"NOTNULL",
"NULL",
"NULLS",
"OF",
"OFFSET",
"ON",
"OR",
"ORDER",
"OTHERS",
"OUTER",
"OVER",
"PARTITION",
"PLAN",
"PRAGMA",
"PRECEDING",
"PRIMARY",
"QUERY",
"RAISE",
"RANGE",
"RECURSIVE",
"REFERENCES",
"REGEXP",
"REINDEX",
"RELEASE",
"RENAME",
"REPLACE",
"RESTRICT",
"RETURNING",
"RIGHT",
"ROLLBACK",
"ROW",
"ROWS",
"SAVEPOINT",
"SELECT",
"SET",
"STRICT",
"TABLE",
"TEMP",
"TEMPORARY",
"THEN",
"TIES",
"TO",
"TRANSACTION",
"TRIGGER",
"TRUE",
"UNBOUNDED",
"UNION",
"UNIQUE",
"UPDATE",
"USING",
"VACUUM",
"VALUES",
"VIEW",
"VIRTUAL",
"WHEN",
"WHERE",
"WINDOW",
"WITH",
"WITHOUT",
)
)
_INTEGER_RE = re.compile(r"[+-]?(?:0[xX][0-9a-fA-F]+|[0-9]+)\Z")
_FLOAT_RE = re.compile(
r"[+-]?(?:(?:[0-9]+\.[0-9]*|\.[0-9]+)(?:[eE][+-]?[0-9]+)?|"
r"[0-9]+[eE][+-]?[0-9]+)\Z"
)
def _lex(sql: str) -> list[_Token]:
tokens: list[_Token] = []
i = 0
while i < len(sql):
start = i
char = sql[i]
if char.isspace():
i += 1
while i < len(sql) and sql[i].isspace():
i += 1
tokens.append(_Token("whitespace", sql[start:i], start, i))
continue
if sql.startswith("--", i):
newline = sql.find("\n", i + 2)
i = len(sql) if newline == -1 else newline + 1
tokens.append(_Token("comment", sql[start:i], start, i))
continue
if sql.startswith("/*", i):
end = sql.find("*/", i + 2)
if end == -1:
raise ParseError("Unterminated SQL comment")
i = end + 2
tokens.append(_Token("comment", sql[start:i], start, i))
continue
if char in ("'", '"', "`"):
quote = char
i += 1
while i < len(sql):
if sql[i] == quote:
if i + 1 < len(sql) and sql[i + 1] == quote:
i += 2
continue
i += 1
break
i += 1
else:
raise ParseError(f"Unterminated {quote} quoted token")
kind = "string" if quote == "'" else "identifier"
tokens.append(_Token(kind, sql[start:i], start, i))
continue
if char == "[":
end = sql.find("]", i + 1)
if end == -1:
raise ParseError("Unterminated [ quoted identifier")
i = end + 1
tokens.append(_Token("identifier", sql[start:i], start, i))
continue
if char in _PUNCTUATION:
i += 1
tokens.append(_Token("punct", char, start, i))
continue
# SQLite accepts any character >= U+0080 in a bare identifier. More
# generally, consume until a lexical delimiter rather than relying on
# Python's narrower definition of an alphanumeric character.
i += 1
while i < len(sql):
if sql[i].isspace() or sql[i] in _PUNCTUATION or sql[i] in "'\"`[":
break
i += 1
tokens.append(_Token("word", sql[start:i], start, i))
return tokens
def _meaningful(tokens: list[_Token]) -> list[_Token]:
return [token for token in tokens if token.kind not in _TRIVIA]
def _unquote(token: str) -> str:
if len(token) >= 2 and token[0] in ("'", '"', "`") and token[-1] == token[0]:
return token[1:-1].replace(token[0] * 2, token[0])
if len(token) >= 2 and token[0] == "[" and token[-1] == "]":
return token[1:-1]
return token
def _matching_paren(tokens: list[_Token], open_index: int) -> int:
if tokens[open_index].text != "(":
raise ParseError("Expected an opening parenthesis")
depth = 0
for index in range(open_index, len(tokens)):
if tokens[index].text == "(":
depth += 1
elif tokens[index].text == ")":
depth -= 1
if depth == 0:
return index
raise ParseError("Unbalanced parentheses")
def _split_spans(sql: str, tokens: list[_Token]) -> list[tuple[str, int, int]]:
if not tokens:
return []
items: list[tuple[str, int, int]] = []
depth = 0
start = tokens[0].start
for token in tokens:
if token.text == "(":
depth += 1
elif token.text == ")":
depth -= 1
if depth < 0:
raise ParseError("Unbalanced parentheses")
elif token.text == "," and depth == 0:
raw = sql[start : token.start]
item = raw.strip()
if item:
item_start = start + len(raw) - len(raw.lstrip())
items.append((item, item_start, item_start + len(item)))
start = token.end
if depth:
raise ParseError("Unbalanced parentheses")
raw = sql[start : tokens[-1].end]
item = raw.strip()
if item:
item_start = start + len(raw) - len(raw.lstrip())
items.append((item, item_start, item_start + len(item)))
return items
def _split_ranges(sql: str, tokens: list[_Token]) -> list[str]:
return [item for item, _, _ in _split_spans(sql, tokens)]
def _strip_outer_parens(tokens: list[_Token]) -> list[_Token]:
while tokens and tokens[0].text == "(":
close = _matching_paren(tokens, 0)
if close != len(tokens) - 1:
break
tokens = tokens[1:-1]
return tokens
_NO_LITERAL = object()
def _literal_value(text: str) -> Any:
tokens = _meaningful(_lex(text))
if len(tokens) == 1 and tokens[0].kind == "string":
return _unquote(tokens[0].text)
raw = "".join(token.text for token in tokens)
if raw.upper() == "NULL":
return None
if raw.upper() == "TRUE":
return True
if raw.upper() == "FALSE":
return False
if _INTEGER_RE.fullmatch(raw):
try:
return (
int(raw, 16) if raw.lower().lstrip("+-").startswith("0x") else int(raw)
)
except ValueError:
return _NO_LITERAL
if _FLOAT_RE.fullmatch(raw):
try:
return float(raw)
except ValueError:
return _NO_LITERAL
return _NO_LITERAL
def _ascii_fold(identifier: str) -> str:
return identifier.translate(
str.maketrans("ABCDEFGHIJKLMNOPQRSTUVWXYZ", "abcdefghijklmnopqrstuvwxyz")
)
def _parse_options(expression: str, column: str) -> list[Any] | None:
tokens = _strip_outer_parens(_meaningful(_lex(expression)))
if len(tokens) < 4:
return None
lhs = tokens[0]
if lhs.kind not in ("word", "identifier"):
return None
if column and _ascii_fold(_unquote(lhs.text)) != _ascii_fold(column):
return None
if not tokens[1].is_keyword("IN") or tokens[2].text != "(":
return None
close = _matching_paren(tokens, 2)
if close != len(tokens) - 1:
return None
inner = expression[tokens[2].end : tokens[close].start]
inner_tokens = _lex(inner)
if not _meaningful(inner_tokens):
return []
values = []
for item in _split_ranges(inner, inner_tokens):
value = _literal_value(item)
if value is _NO_LITERAL:
return None
values.append(value)
return values
def _check_after(
item: str,
tokens: list[_Token],
check_index: int,
name: str,
column: str,
constraint_start: int,
base_offset: int,
) -> tuple[Check, int]:
if check_index + 1 >= len(tokens) or tokens[check_index + 1].text != "(":
raise ParseError("CHECK must be followed by a parenthesized expression")
close = _matching_paren(tokens, check_index + 1)
expression = item[tokens[check_index + 1].end : tokens[close].start].strip()
source_start = tokens[constraint_start].start
source_end = tokens[close].end
return (
Check(
expression,
name=name,
column=column,
options=_parse_options(expression, column),
sql=item[source_start:source_end],
start=base_offset + source_start,
end=base_offset + source_end,
),
close + 1,
)
def _column_checks(
item: str, tokens: list[_Token], column: str, base_offset: int
) -> list[Check]:
checks: list[Check] = []
pending_name = ""
pending_start: int | None = None
index = 1
while index < len(tokens):
token = tokens[index]
if token.text == "(":
index = _matching_paren(tokens, index) + 1
continue
if token.is_keyword("CONSTRAINT"):
if index + 1 >= len(tokens):
raise ParseError("CONSTRAINT is missing its name")
pending_name = _unquote(tokens[index + 1].text)
pending_start = index
index += 2
continue
if token.is_keyword("CHECK"):
check, index = _check_after(
item,
tokens,
index,
pending_name,
column,
pending_start if pending_start is not None else index,
base_offset,
)
checks.append(check)
pending_name = ""
pending_start = None
continue
if (
token.kind == "word"
and token.text.upper() in _OTHER_COLUMN_CONSTRAINT_KEYWORDS
):
pending_name = ""
pending_start = None
index += 1
return checks
def _table_body(create_sql: str) -> tuple[str, int] | None:
all_tokens = _lex(create_sql)
tokens = _meaningful(all_tokens)
if not tokens or not tokens[0].is_keyword("CREATE"):
raise ParseError("Expected CREATE TABLE")
index = 1
if index < len(tokens) and (
tokens[index].is_keyword("TEMP") or tokens[index].is_keyword("TEMPORARY")
):
index += 1
if index < len(tokens) and tokens[index].is_keyword("VIRTUAL"):
return None
if index >= len(tokens) or not tokens[index].is_keyword("TABLE"):
raise ParseError("Expected CREATE TABLE")
index += 1
if (
index + 2 < len(tokens)
and tokens[index].is_keyword("IF")
and tokens[index + 1].is_keyword("NOT")
and tokens[index + 2].is_keyword("EXISTS")
):
index += 3
if index >= len(tokens):
raise ParseError("CREATE TABLE is missing its table name")
index += 1
if index + 1 < len(tokens) and tokens[index].text == ".":
index += 2
if index < len(tokens) and tokens[index].is_keyword("AS"):
return None
if index >= len(tokens) or tokens[index].text != "(":
raise ParseError("CREATE TABLE is missing its column list")
close = _matching_paren(tokens, index)
trailing = tokens[close + 1 :]
allowed_trailing = {"STRICT", "WITHOUT", "ROWID", ",", ";"}
if any(token.text.upper() not in allowed_trailing for token in trailing):
raise ParseError("Unexpected SQL after CREATE TABLE column list")
body_start = tokens[index].end
body_end = tokens[close].start
return create_sql[body_start:body_end], body_start
def parse_checks(create_sql: str) -> list[Check]:
"""Return CHECK constraints from a valid SQLite CREATE TABLE statement."""
body_info = _table_body(create_sql)
if body_info is None:
return []
body, body_start = body_info
body_tokens = _lex(body)
checks: list[Check] = []
for item, item_start, _ in _split_spans(body, body_tokens):
item_tokens = _meaningful(_lex(item))
if not item_tokens:
continue
item_index = 0
constraint_name = ""
if item_tokens[item_index].is_keyword("CONSTRAINT"):
if len(item_tokens) < 2:
raise ParseError("CONSTRAINT is missing its name")
constraint_name = _unquote(item_tokens[1].text)
item_index = 2
head = item_tokens[item_index] if item_index < len(item_tokens) else None
if (
head
and head.kind == "word"
and head.text.upper() in _TABLE_CONSTRAINT_KEYWORDS
):
if head.is_keyword("CHECK"):
check, _ = _check_after(
item,
item_tokens,
item_index,
constraint_name,
"",
0,
body_start + item_start,
)
checks.append(check)
continue
column = _unquote(item_tokens[0].text)
checks.extend(
_column_checks(item, item_tokens, column, body_start + item_start)
)
return checks
def parse_autoincrement(create_sql: str) -> str | None:
"""Return the AUTOINCREMENT column from a valid CREATE TABLE statement."""
body_info = _table_body(create_sql)
if body_info is None:
return None
body, _ = body_info
for item, _, _ in _split_spans(body, _lex(body)):
item_tokens = _meaningful(_lex(item))
if not item_tokens:
continue
head = item_tokens[0]
if (
head.kind == "word" and head.text.upper() in _TABLE_CONSTRAINT_KEYWORDS
) or head.is_keyword("CONSTRAINT"):
continue
column = _unquote(head.text)
index = 1
while index < len(item_tokens):
token = item_tokens[index]
if token.text == "(":
index = _matching_paren(item_tokens, index) + 1
continue
if token.is_keyword("AUTOINCREMENT"):
return column
index += 1
return None
_CONFLICT_ACTIONS = frozenset(("ROLLBACK", "ABORT", "FAIL", "IGNORE", "REPLACE"))
def _conflict_after(tokens: list[_Token], index: int) -> tuple[str, int]:
if index >= len(tokens) or not tokens[index].is_keyword("ON"):
return "", index
if index + 2 >= len(tokens) or not tokens[index + 1].is_keyword("CONFLICT"):
raise ParseError("ON after UNIQUE must be followed by CONFLICT and an action")
action = tokens[index + 2].text.upper()
if tokens[index + 2].kind != "word" or action not in _CONFLICT_ACTIONS:
raise ParseError("Invalid UNIQUE ON CONFLICT action")
return action, index + 3
def _unique_columns(
item: str, tokens: list[_Token], open_index: int
) -> tuple[tuple[UniqueColumn, ...], int]:
close = _matching_paren(tokens, open_index)
inner = item[tokens[open_index].end : tokens[close].start]
columns: list[UniqueColumn] = []
for raw_column in _split_ranges(inner, _lex(inner)):
column_tokens = _meaningful(_lex(raw_column))
if not column_tokens or column_tokens[0].kind not in (
"word",
"identifier",
"string",
):
raise ParseError("UNIQUE constraint has an invalid column")
name = _unquote(column_tokens[0].text)
collation = ""
order = ""
index = 1
if index < len(column_tokens) and column_tokens[index].is_keyword("COLLATE"):
if index + 1 >= len(column_tokens):
raise ParseError("COLLATE in UNIQUE constraint is missing its name")
collation = _unquote(column_tokens[index + 1].text)
index += 2
if index < len(column_tokens) and (
column_tokens[index].is_keyword("ASC")
or column_tokens[index].is_keyword("DESC")
):
order = column_tokens[index].text.upper()
index += 1
if index != len(column_tokens):
raise ParseError("UNIQUE constraint has an invalid indexed column")
columns.append(UniqueColumn(name, collation=collation, order=order))
if not columns:
raise ParseError("UNIQUE constraint must include at least one column")
return tuple(columns), close + 1
def _column_uniques(
item: str, tokens: list[_Token], column: str, base_offset: int
) -> list[Unique]:
uniques: list[Unique] = []
collation = ""
collation_index = 1
while collation_index < len(tokens):
token = tokens[collation_index]
if token.text == "(":
collation_index = _matching_paren(tokens, collation_index) + 1
continue
if token.is_keyword("COLLATE"):
if collation_index + 1 >= len(tokens):
raise ParseError("COLLATE is missing its name")
collation = _unquote(tokens[collation_index + 1].text)
collation_index += 2
continue
collation_index += 1
pending_name = ""
pending_start: int | None = None
index = 1
while index < len(tokens):
token = tokens[index]
if token.text == "(":
index = _matching_paren(tokens, index) + 1
continue
if token.is_keyword("CONSTRAINT"):
if index + 1 >= len(tokens):
raise ParseError("CONSTRAINT is missing its name")
pending_name = _unquote(tokens[index + 1].text)
pending_start = index
index += 2
continue
if token.is_keyword("UNIQUE"):
source_start = tokens[
pending_start if pending_start is not None else index
].start
conflict, next_index = _conflict_after(tokens, index + 1)
source_end = tokens[next_index - 1].end
uniques.append(
Unique(
(UniqueColumn(column, collation=collation),),
name=pending_name,
column=column,
conflict=conflict,
sql=item[source_start:source_end],
start=base_offset + source_start,
end=base_offset + source_end,
)
)
pending_name = ""
pending_start = None
index = next_index
continue
if (
token.kind == "word"
and token.text.upper() in _OTHER_COLUMN_CONSTRAINT_KEYWORDS
):
pending_name = ""
pending_start = None
index += 1
return uniques
def parse_uniques(create_sql: str) -> list[Unique]:
"""Return column-level and table-level UNIQUE constraints."""
body_info = _table_body(create_sql)
if body_info is None:
return []
body, body_start = body_info
uniques: list[Unique] = []
for item, item_start, _ in _split_spans(body, _lex(body)):
item_tokens = _meaningful(_lex(item))
if not item_tokens:
continue
item_index = 0
constraint_name = ""
if item_tokens[item_index].is_keyword("CONSTRAINT"):
if len(item_tokens) < 2:
raise ParseError("CONSTRAINT is missing its name")
constraint_name = _unquote(item_tokens[1].text)
item_index = 2
head = item_tokens[item_index] if item_index < len(item_tokens) else None
if head and head.is_keyword("UNIQUE"):
if (
item_index + 1 >= len(item_tokens)
or item_tokens[item_index + 1].text != "("
):
raise ParseError("Table UNIQUE must be followed by a column list")
columns, next_index = _unique_columns(item, item_tokens, item_index + 1)
conflict, next_index = _conflict_after(item_tokens, next_index)
if next_index != len(item_tokens):
raise ParseError("Unexpected SQL after UNIQUE constraint")
source_start = item_tokens[0].start
source_end = item_tokens[next_index - 1].end
uniques.append(
Unique(
columns,
name=constraint_name,
conflict=conflict,
sql=item[source_start:source_end],
start=body_start + item_start + source_start,
end=body_start + item_start + source_end,
)
)
continue
if (
head
and head.kind == "word"
and head.text.upper() in _TABLE_CONSTRAINT_KEYWORDS
):
continue
column = _unquote(item_tokens[0].text)
uniques.extend(
_column_uniques(
item,
item_tokens,
column,
body_start + item_start,
)
)
return uniques
def parse_column_comments(create_sql: str) -> dict[str, ColumnComments]:
"""Return comments immediately before and after each column definition."""
body_info = _table_body(create_sql)
if body_info is None:
return {}
body, _ = body_info
comments: dict[str, ColumnComments] = {}
for item, _, _ in _split_spans(body, _lex(body)):
item_tokens = _meaningful(_lex(item))
if not item_tokens:
continue
item_index = 0
if item_tokens[item_index].is_keyword("CONSTRAINT"):
item_index = 2
head = item_tokens[item_index] if item_index < len(item_tokens) else None
if (
head
and head.kind == "word"
and head.text.upper() in _TABLE_CONSTRAINT_KEYWORDS
):
continue
column = _unquote(item_tokens[0].text)
before = item[: item_tokens[0].start].strip()
after = item[item_tokens[-1].end :].strip()
if before or after:
comments[column] = ColumnComments(before=before, after=after)
return comments
def _is_identifier_token(tokens: list[_Token], index: int) -> bool:
token = tokens[index]
if index + 1 < len(tokens) and tokens[index + 1].text in ("(", "."):
return False
if index and (
tokens[index - 1].is_keyword("COLLATE") or tokens[index - 1].is_keyword("AS")
):
return False
if token.kind == "identifier":
return True
if token.kind != "word" or token.text.upper() in _SQLITE_KEYWORDS:
return False
return True
def check_references_identifier(expression: str, identifier: str) -> bool:
tokens = _meaningful(_lex(expression))
folded = _ascii_fold(identifier)
return any(
_is_identifier_token(tokens, index)
and _ascii_fold(_unquote(token.text)) == folded
for index, token in enumerate(tokens)
)
def sql_ends_in_line_comment(sql: str) -> bool:
"""Return True if appended SQL would be swallowed by a ``--`` comment."""
tokens = _lex(sql)
if not tokens:
return False
final = tokens[-1]
return (
final.kind == "comment"
and final.text.startswith("--")
and not final.text.endswith(("\n", "\r"))
)
def _valid_bare_identifier(identifier: str) -> bool:
if not identifier or identifier.upper() in _SQLITE_KEYWORDS:
return False
first = identifier[0]
if not (first == "_" or first.isalpha() or ord(first) >= 0x80):
return False
return all(
char == "_" or char == "$" or char.isalnum() or ord(char) >= 0x80
for char in identifier[1:]
)
def _quote_replacement(original: str, replacement: str) -> str:
if original.startswith('"'):
return '"{}"'.format(replacement.replace('"', '""'))
if original.startswith("`"):
return "`{}`".format(replacement.replace("`", "``"))
if original.startswith("[") and "]" not in replacement:
return f"[{replacement}]"
if _valid_bare_identifier(replacement):
return replacement
return '"{}"'.format(replacement.replace('"', '""'))
def rewrite_check_expression(expression: str, rename: dict[str, str]) -> str:
"""Rewrite column identifiers in a CHECK expression, preserving trivia."""
if not rename:
return expression
tokens = _lex(expression)
meaningful = _meaningful(tokens)
replacements = {_ascii_fold(key): value for key, value in rename.items()}
edits: list[tuple[int, int, str]] = []
for index, token in enumerate(meaningful):
if not _is_identifier_token(meaningful, index):
continue
replacement = replacements.get(_ascii_fold(_unquote(token.text)))
if replacement is not None:
edits.append(
(token.start, token.end, _quote_replacement(token.text, replacement))
)
for start, end, replacement in reversed(edits):
expression = expression[:start] + replacement + expression[end:]
return expression

File diff suppressed because it is too large Load diff

View file

@ -1,8 +1,7 @@
import sqlite3 import sqlite3
import click import click
from pluggy import HookimplMarker from pluggy import HookimplMarker, HookspecMarker
from pluggy import HookspecMarker
hookspec = HookspecMarker("sqlite_utils") hookspec = HookspecMarker("sqlite_utils")
hookimpl = HookimplMarker("sqlite_utils") hookimpl = HookimplMarker("sqlite_utils")

View file

@ -1,19 +1,28 @@
from collections.abc import Iterable
from dataclasses import dataclass
import datetime import datetime
from typing import Callable, cast, TYPE_CHECKING from collections.abc import Callable, Iterable
from dataclasses import dataclass
from typing import TYPE_CHECKING, Protocol, TypeVar, cast
if TYPE_CHECKING: if TYPE_CHECKING:
from sqlite_utils.db import Database, Table from sqlite_utils.db import Database, Table
class _MigrationFunction(Protocol):
__name__: str
def __call__(self, db: "Database", /) -> None: ...
_MigrationFunctionT = TypeVar("_MigrationFunctionT", bound=_MigrationFunction)
class Migrations: class Migrations:
migrations_table = "_sqlite_migrations" migrations_table = "_sqlite_migrations"
@dataclass @dataclass
class _Migration: class _Migration:
name: str name: str
fn: Callable fn: _MigrationFunction
transactional: bool = True transactional: bool = True
@dataclass @dataclass
@ -32,7 +41,7 @@ class Migrations:
def __call__( def __call__(
self, *, name: str | None = None, transactional: bool = True self, *, name: str | None = None, transactional: bool = True
) -> Callable: ) -> Callable[[_MigrationFunctionT], _MigrationFunctionT]:
""" """
:param name: The name to use for this migration - if not provided, :param name: The name to use for this migration - if not provided,
the name of the function will be used. the name of the function will be used.
@ -43,13 +52,11 @@ class Migrations:
example those that execute ``VACUUM``. example those that execute ``VACUUM``.
""" """
def inner(func: Callable) -> Callable: def inner(func: _MigrationFunctionT) -> _MigrationFunctionT:
migration_name = name or getattr(func, "__name__") migration_name = name or func.__name__
if any(m.name == migration_name for m in self._migrations): if any(m.name == migration_name for m in self._migrations):
raise ValueError( raise ValueError(
"Migration '{}' is already registered in set '{}'".format( f"Migration '{migration_name}' is already registered in set '{self.name}'"
migration_name, self.name
)
) )
self._migrations.append( self._migrations.append(
self._Migration(migration_name, func, transactional) self._Migration(migration_name, func, transactional)

View file

@ -1,7 +1,7 @@
from typing import Dict, List, Union import sys
import pluggy import pluggy
import sys
from . import hookspecs from . import hookspecs
pm: pluggy.PluginManager = pluggy.PluginManager("sqlite_utils") pm: pluggy.PluginManager = pluggy.PluginManager("sqlite_utils")
@ -17,13 +17,13 @@ def ensure_plugins_loaded() -> None:
_plugins_loaded = True _plugins_loaded = True
def get_plugins() -> List[Dict[str, Union[str, List[str]]]]: def get_plugins() -> list[dict[str, str | list[str]]]:
ensure_plugins_loaded() ensure_plugins_loaded()
plugins: List[Dict[str, Union[str, List[str]]]] = [] plugins: list[dict[str, str | list[str]]] = []
plugin_to_distinfo = dict(pm.list_plugin_distinfo()) plugin_to_distinfo = dict(pm.list_plugin_distinfo())
for plugin in pm.get_plugins(): for plugin in pm.get_plugins():
hookcallers = pm.get_hookcallers(plugin) or [] hookcallers = pm.get_hookcallers(plugin) or []
plugin_info: Dict[str, Union[str, List[str]]] = { plugin_info: dict[str, str | list[str]] = {
"name": plugin.__name__, "name": plugin.__name__,
"hooks": [h.name for h in hookcallers], "hooks": [h.name for h in hookcallers],
} }

View file

@ -1,9 +1,9 @@
from __future__ import annotations from __future__ import annotations
from typing import Callable, Optional import json
from collections.abc import Callable
from dateutil import parser from dateutil import parser
import json
IGNORE: object = object() IGNORE: object = object()
SET_NULL: object = object() SET_NULL: object = object()
@ -13,8 +13,8 @@ def parsedate(
value: str, value: str,
dayfirst: bool = False, dayfirst: bool = False,
yearfirst: bool = False, yearfirst: bool = False,
errors: Optional[object] = None, errors: object | None = None,
) -> Optional[str]: ) -> str | None:
""" """
Parse a date and convert it to ISO date format: yyyy-mm-dd Parse a date and convert it to ISO date format: yyyy-mm-dd
\b \b
@ -44,8 +44,8 @@ def parsedatetime(
value: str, value: str,
dayfirst: bool = False, dayfirst: bool = False,
yearfirst: bool = False, yearfirst: bool = False,
errors: Optional[object] = None, errors: object | None = None,
) -> Optional[str]: ) -> str | None:
""" """
Parse a datetime and convert it to ISO datetime format: yyyy-mm-ddTHH:MM:SS Parse a datetime and convert it to ISO datetime format: yyyy-mm-ddTHH:MM:SS
\b \b

View file

@ -9,20 +9,12 @@ import itertools
import json import json
import os import os
import sys import sys
from collections.abc import Callable, Generator, Iterable, Iterator
from typing import ( from typing import (
TYPE_CHECKING,
Any, Any,
BinaryIO, BinaryIO,
Callable, Generic,
Dict,
Generator,
Iterable,
Iterator,
List,
Optional,
Set,
Tuple,
Type,
TYPE_CHECKING,
TypeVar, TypeVar,
Union, Union,
cast, cast,
@ -33,8 +25,8 @@ import click
from . import recipes from . import recipes
if TYPE_CHECKING: if TYPE_CHECKING:
import sqlite3 # noqa: F401 import sqlite3
from sqlite3 import dbapi2 # noqa: F401 from sqlite3 import dbapi2
OperationalError = dbapi2.OperationalError OperationalError = dbapi2.OperationalError
else: else:
@ -44,7 +36,7 @@ else:
OperationalError = dbapi2.OperationalError OperationalError = dbapi2.OperationalError
except ImportError: except ImportError:
import sqlite3 # noqa: F401 import sqlite3 # noqa: F401
from sqlite3 import dbapi2 # noqa: F401 from sqlite3 import dbapi2
OperationalError = dbapi2.OperationalError OperationalError = dbapi2.OperationalError
@ -61,12 +53,16 @@ SPATIALITE_PATHS = (
ORIGINAL_CSV_FIELD_SIZE_LIMIT = csv.field_size_limit() ORIGINAL_CSV_FIELD_SIZE_LIMIT = csv.field_size_limit()
# Type alias for row dictionaries - values can be various SQLite-compatible types # Type alias for row dictionaries - values can be various SQLite-compatible types
RowValue = Union[None, int, float, str, bytes, bool, List[str]] RowValue = None | int | float | str | bytes | bool | list[str]
Row = Dict[str, RowValue] Row = dict[str, RowValue]
T = TypeVar("T") T = TypeVar("T")
class ANY:
"""Marker type for an SQLite ``ANY`` column."""
class _CloseableIterator(Iterator[Row]): class _CloseableIterator(Iterator[Row]):
"""Iterator wrapper that closes a file when iteration is complete.""" """Iterator wrapper that closes a file when iteration is complete."""
@ -103,7 +99,7 @@ def maximize_csv_field_size_limit() -> None:
field_size_limit = int(field_size_limit / 10) field_size_limit = int(field_size_limit / 10)
def find_spatialite() -> Optional[str]: def find_spatialite() -> str | None:
""" """
The ``find_spatialite()`` function searches for the `SpatiaLite <https://www.gaia-gis.it/fossil/libspatialite/index>`__ The ``find_spatialite()`` function searches for the `SpatiaLite <https://www.gaia-gis.it/fossil/libspatialite/index>`__
SQLite extension in some common places. It returns a string path to the location, or ``None`` if SpatiaLite was not found. SQLite extension in some common places. It returns a string path to the location, or ``None`` if SpatiaLite was not found.
@ -132,9 +128,9 @@ def find_spatialite() -> Optional[str]:
def suggest_column_types( def suggest_column_types(
records: Iterable[Dict[str, Any]], records: Iterable[dict[str, Any]],
) -> Dict[str, type]: ) -> dict[str, type]:
all_column_types: Dict[str, Set[type]] = {} all_column_types: dict[str, set[type]] = {}
for record in records: for record in records:
for key, value in record.items(): for key, value in record.items():
all_column_types.setdefault(key, set()).add(type(value)) all_column_types.setdefault(key, set()).add(type(value))
@ -142,9 +138,9 @@ def suggest_column_types(
def types_for_column_types( def types_for_column_types(
all_column_types: Dict[str, Set[type]], all_column_types: dict[str, set[type]],
) -> Dict[str, type]: ) -> dict[str, type]:
column_types: Dict[str, type] = {} column_types: dict[str, type] = {}
for key, types in all_column_types.items(): for key, types in all_column_types.items():
# Ignore null values if at least one other type present: # Ignore null values if at least one other type present:
if len(types) > 1: if len(types) > 1:
@ -153,7 +149,7 @@ def types_for_column_types(
if {None.__class__} == types: if {None.__class__} == types:
t = str t = str
elif len(types) == 1: elif len(types) == 1:
t = list(types)[0] t = next(iter(types))
# But if it's a subclass of list / tuple / dict, use str # But if it's a subclass of list / tuple / dict, use str
# instead as we will be storing it as JSON in the table # instead as we will be storing it as JSON in the table
for superclass in (list, tuple, dict): for superclass in (list, tuple, dict):
@ -186,11 +182,13 @@ def column_affinity(column_type: str) -> type:
return bytes return bytes
if "REAL" in column_type or "FLOA" in column_type or "DOUB" in column_type: if "REAL" in column_type or "FLOA" in column_type or "DOUB" in column_type:
return float return float
if column_type == "ANY":
return ANY
# Default is 'NUMERIC', which we currently also treat as float # Default is 'NUMERIC', which we currently also treat as float
return float return float
def decode_base64_values(doc: Dict[str, Any]) -> Dict[str, Any]: def decode_base64_values(doc: dict[str, Any]) -> dict[str, Any]:
# Looks for '{"$base64": true..., "encoded": ...}' values and decodes them # Looks for '{"$base64": true..., "encoded": ...}' values and decodes them
to_fix = [ to_fix = [
k k
@ -263,9 +261,9 @@ class RowError(Exception):
def _extra_key_strategy( def _extra_key_strategy(
reader: Iterable[Dict[Optional[str], object]], reader: Iterable[dict[str | None, object]],
ignore_extras: Optional[bool] = False, ignore_extras: bool | None = False,
extras_key: Optional[str] = None, extras_key: str | None = None,
) -> Iterable[Row]: ) -> Iterable[Row]:
# Logic for handling CSV rows with more values than there are headings # Logic for handling CSV rows with more values than there are headings
for row in reader: for row in reader:
@ -279,9 +277,7 @@ def _extra_key_strategy(
yield cast(Row, row) yield cast(Row, row)
elif not extras_key: elif not extras_key:
extras = row.pop(None) extras = row.pop(None)
raise RowError( raise RowError(f"Row {row} contained these extra values: {extras}")
"Row {} contained these extra values: {}".format(row, extras)
)
else: else:
extras_value = row.pop(None) extras_value = row.pop(None)
row_out = cast(Row, row) row_out = cast(Row, row)
@ -291,12 +287,12 @@ def _extra_key_strategy(
def rows_from_file( def rows_from_file(
fp: BinaryIO, fp: BinaryIO,
format: Optional[Format] = None, format: Format | None = None,
dialect: Optional[Type[csv.Dialect]] = None, dialect: type[csv.Dialect] | None = None,
encoding: Optional[str] = None, encoding: str | None = None,
ignore_extras: Optional[bool] = False, ignore_extras: bool | None = False,
extras_key: Optional[str] = None, extras_key: str | None = None,
) -> Tuple[Iterable[Row], Format]: ) -> tuple[Iterable[Row], Format]:
""" """
Load a sequence of dictionaries from a file-like object containing one of four different formats. Load a sequence of dictionaries from a file-like object containing one of four different formats.
@ -355,7 +351,11 @@ def rows_from_file(
reader = csv.DictReader(decoded_fp, dialect=dialect) reader = csv.DictReader(decoded_fp, dialect=dialect)
else: else:
reader = csv.DictReader(decoded_fp) reader = csv.DictReader(decoded_fp)
rows = _extra_key_strategy(reader, ignore_extras, extras_key) rows = _extra_key_strategy(
cast(Iterable[dict[str | None, object]], reader),
ignore_extras,
extras_key,
)
return _CloseableIterator(iter(rows), decoded_fp), Format.CSV return _CloseableIterator(iter(rows), decoded_fp), Format.CSV
elif format == Format.TSV: elif format == Format.TSV:
rows, _ = rows_from_file( rows, _ = rows_from_file(
@ -363,7 +363,7 @@ def rows_from_file(
) )
return ( return (
_extra_key_strategy( _extra_key_strategy(
cast(Iterable[Dict[Optional[str], object]], rows), cast(Iterable[dict[str | None, object]], rows),
ignore_extras, ignore_extras,
extras_key, extras_key,
), ),
@ -379,7 +379,9 @@ def rows_from_file(
raise TypeError( raise TypeError(
"rows_from_file() requires a file-like object that supports peek(), such as io.BytesIO" "rows_from_file() requires a file-like object that supports peek(), such as io.BytesIO"
) )
if first_bytes.startswith(b"[") or first_bytes.startswith(b"{"): if not first_bytes:
return (), Format.CSV
if first_bytes.startswith((b"[", b"{")):
# TODO: Detect newline-JSON # TODO: Detect newline-JSON
return rows_from_file(buffered, format=Format.JSON) return rows_from_file(buffered, format=Format.JSON)
else: else:
@ -393,7 +395,7 @@ def rows_from_file(
detected_format = Format.TSV if dialect.delimiter == "\t" else Format.CSV detected_format = Format.TSV if dialect.delimiter == "\t" else Format.CSV
return ( return (
_extra_key_strategy( _extra_key_strategy(
cast(Iterable[Dict[Optional[str], object]], rows), cast(Iterable[dict[str | None, object]], rows),
ignore_extras, ignore_extras,
extras_key, extras_key,
), ),
@ -425,9 +427,9 @@ class TypeTracker:
""" """
def __init__(self) -> None: def __init__(self) -> None:
self.trackers: Dict[str, "ValueTracker"] = {} self.trackers: dict[str, ValueTracker] = {}
def wrap(self, iterator: Iterable[Dict[str, Any]]) -> Iterable[Dict[str, Any]]: def wrap(self, iterator: Iterable[dict[str, Any]]) -> Iterable[dict[str, Any]]:
""" """
Use this to loop through an existing iterator, tracking the column types Use this to loop through an existing iterator, tracking the column types
as part of the iteration. as part of the iteration.
@ -441,7 +443,7 @@ class TypeTracker:
yield row yield row
@property @property
def types(self) -> Dict[str, str]: def types(self) -> dict[str, str]:
""" """
A dictionary mapping column names to their detected types. This can be passed A dictionary mapping column names to their detected types. This can be passed
to the ``db[table_name].transform(types=tracker.types)`` method. to the ``db[table_name].transform(types=tracker.types)`` method.
@ -450,17 +452,15 @@ class TypeTracker:
class ValueTracker: class ValueTracker:
couldbe: Dict[str, Callable[[object], bool]] couldbe: dict[str, Callable[[object], bool]]
def __init__(self) -> None: def __init__(self) -> None:
self.couldbe = {key: getattr(self, "test_" + key) for key in self.get_tests()} self.couldbe = {key: getattr(self, "test_" + key) for key in self.get_tests()}
@classmethod @classmethod
def get_tests(cls) -> List[str]: def get_tests(cls) -> list[str]:
return [ return [
key.split("test_")[-1] key.split("test_")[-1] for key in cls.__dict__ if key.startswith("test_")
for key in cls.__dict__.keys()
if key.startswith("test_")
] ]
def test_integer(self, value: object) -> bool: def test_integer(self, value: object) -> bool:
@ -492,7 +492,7 @@ class ValueTracker:
def evaluate(self, value: object) -> None: def evaluate(self, value: object) -> None:
if not value or not self.couldbe: if not value or not self.couldbe:
return return
not_these: List[str] = [] not_these: list[str] = []
for name, test in self.couldbe.items(): for name, test in self.couldbe.items():
if not test(value): if not test(value):
not_these.append(name) not_these.append(name)
@ -500,12 +500,12 @@ class ValueTracker:
del self.couldbe[key] del self.couldbe[key]
class NullProgressBar: class NullProgressBar(Generic[T]):
def __init__(self, *args: Iterable[T]) -> None: def __init__(self, *args: Iterable[T]) -> None:
self.args = args self.args = args
def __iter__(self) -> Iterator[T]: def __iter__(self) -> Iterator[T]:
yield from self.args[0] # type: ignore yield from self.args[0]
def update(self, value: int) -> None: def update(self, value: int) -> None:
pass pass
@ -524,14 +524,14 @@ def progressbar(*args: Iterable[T], **kwargs: Any) -> Generator[Any, None, None]
def _compile_code( def _compile_code(
code: str, imports: Iterable[str], variable: str = "value" code: str, imports: Iterable[str], variable: str = "value"
) -> Callable[..., Any]: ) -> Callable[..., Any]:
globals_dict: Dict[str, Any] = {"r": recipes, "recipes": recipes} globals_dict: dict[str, Any] = {"r": recipes, "recipes": recipes}
# Handle imports first so they're available for all approaches # Handle imports first so they're available for all approaches
for import_ in imports: for import_ in imports:
globals_dict[import_.split(".")[0]] = __import__(import_) globals_dict[import_.split(".")[0]] = __import__(import_)
# If user defined a convert() function, return that # If user defined a convert() function, return that
try: try:
exec(code, globals_dict) exec(code, globals_dict) # noqa: S102
return cast(Callable[..., object], globals_dict["convert"]) return cast(Callable[..., object], globals_dict["convert"])
except (AttributeError, SyntaxError, NameError, KeyError, TypeError): except (AttributeError, SyntaxError, NameError, KeyError, TypeError):
pass pass
@ -542,20 +542,20 @@ def _compile_code(
fn = eval(code, globals_dict) fn = eval(code, globals_dict)
if callable(fn): if callable(fn):
return cast(Callable[..., object], fn) return cast(Callable[..., object], fn)
except Exception: except Exception: # noqa: BLE001, S110
pass pass
# Try compiling their code as a function instead # Try compiling their code as a function instead
body_variants = [code] body_variants = [code]
# If single line and no 'return', try adding the return # If single line and no 'return', try adding the return
if "\n" not in code and not code.strip().startswith("return "): if "\n" not in code and not code.strip().startswith("return "):
body_variants.insert(0, "return {}".format(code)) body_variants.insert(0, f"return {code}")
code_o = None code_o = None
for variant in body_variants: for variant in body_variants:
new_code = ["def fn({}):".format(variable)] new_code = [f"def fn({variable}):"]
for line in variant.split("\n"): for line in variant.split("\n"):
new_code.append(" {}".format(line)) new_code.append(f" {line}")
try: try:
code_o = compile("\n".join(new_code), "<string>", "exec") code_o = compile("\n".join(new_code), "<string>", "exec")
break break
@ -566,7 +566,7 @@ def _compile_code(
if code_o is None: if code_o is None:
raise SyntaxError("Could not compile code") raise SyntaxError("Could not compile code")
exec(code_o, globals_dict) exec(code_o, globals_dict) # noqa: S102
return cast(Callable[..., object], globals_dict["fn"]) return cast(Callable[..., object], globals_dict["fn"])
@ -582,7 +582,7 @@ def chunks(sequence: Iterable[T], size: int) -> Iterable[Iterable[T]]:
yield itertools.chain([item], itertools.islice(iterator, size - 1)) yield itertools.chain([item], itertools.islice(iterator, size - 1))
def hash_record(record: Dict[str, Any], keys: Optional[Iterable[str]] = None) -> str: def hash_record(record: dict[str, Any], keys: Iterable[str] | None = None) -> str:
""" """
``record`` should be a Python dictionary. Returns a sha1 hash of the ``record`` should be a Python dictionary. Returns a sha1 hash of the
keys and values in that record. keys and values in that record.
@ -603,7 +603,7 @@ def hash_record(record: Dict[str, Any], keys: Optional[Iterable[str]] = None) ->
:param record: Record to generate a hash for :param record: Record to generate a hash for
:param keys: Subset of keys to use for that hash :param keys: Subset of keys to use for that hash
""" """
to_hash: Dict[str, Any] = record to_hash: dict[str, Any] = record
if keys is not None: if keys is not None:
to_hash = {key: record[key] for key in keys} to_hash = {key: record[key] for key in keys}
return hashlib.sha1( return hashlib.sha1(
@ -613,7 +613,7 @@ def hash_record(record: Dict[str, Any], keys: Optional[Iterable[str]] = None) ->
).hexdigest() ).hexdigest()
def dedupe_keys(keys: Iterable[str]) -> List[str]: def dedupe_keys(keys: Iterable[str]) -> list[str]:
""" """
Rename duplicates in a list of column names so every name is unique, Rename duplicates in a list of column names so every name is unique,
by appending ``_2``, ``_3``... to later occurrences - skipping any by appending ``_2``, ``_3``... to later occurrences - skipping any
@ -636,7 +636,7 @@ def dedupe_keys(keys: Iterable[str]) -> List[str]:
new_key = key new_key = key
suffix = 2 suffix = 2
while new_key in seen or new_key in taken: while new_key in seen or new_key in taken:
new_key = "{}_{}".format(key, suffix) new_key = f"{key}_{suffix}"
suffix += 1 suffix += 1
key = new_key key = new_key
seen.add(key) seen.add(key)
@ -644,7 +644,7 @@ def dedupe_keys(keys: Iterable[str]) -> List[str]:
return result return result
def _flatten(d: Dict[str, Any]) -> Generator[Tuple[str, Any], None, None]: def _flatten(d: dict[str, Any]) -> Generator[tuple[str, Any], None, None]:
for key, value in d.items(): for key, value in d.items():
if isinstance(value, dict): if isinstance(value, dict):
for key2, value2 in _flatten(value): for key2, value2 in _flatten(value):
@ -653,7 +653,7 @@ def _flatten(d: Dict[str, Any]) -> Generator[Tuple[str, Any], None, None]:
yield key, value yield key, value
def flatten(row: Dict[str, Any]) -> Dict[str, Any]: def flatten(row: dict[str, Any]) -> dict[str, Any]:
""" """
Turn a nested dict e.g. ``{"a": {"b": 1}}`` into a flat dict: ``{"a_b": 1}`` Turn a nested dict e.g. ``{"a": {"b": 1}}`` into a flat dict: ``{"a_b": 1}``

View file

@ -1,6 +1,7 @@
import pytest
from sqlite_utils import Database from sqlite_utils import Database
from sqlite_utils.utils import sqlite3 from sqlite_utils.utils import sqlite3
import pytest
CREATE_TABLES = """ CREATE_TABLES = """
create table Gosh (c1 text, c2 text, c3 text); create table Gosh (c1 text, c2 text, c3 text);
@ -55,7 +56,7 @@ def close_all_databases():
for db in databases: for db in databases:
try: try:
db.close() db.close()
except Exception: except sqlite3.Error:
pass pass

View file

@ -3,11 +3,13 @@ import pytest
@pytest.fixture @pytest.fixture
def db(fresh_db): def db(fresh_db):
fresh_db["one_index"].insert({"id": 1, "name": "Cleo"}, pk="id") fresh_db.table("one_index").insert({"id": 1, "name": "Cleo"}, pk="id")
fresh_db["one_index"].create_index(["name"]) fresh_db.table("one_index").create_index(["name"])
fresh_db["two_indexes"].insert({"id": 1, "name": "Cleo", "species": "dog"}, pk="id") fresh_db.table("two_indexes").insert(
fresh_db["two_indexes"].create_index(["name"]) {"id": 1, "name": "Cleo", "species": "dog"}, pk="id"
fresh_db["two_indexes"].create_index(["species"]) )
fresh_db.table("two_indexes").create_index(["name"])
fresh_db.table("two_indexes").create_index(["species"])
return fresh_db return fresh_db
@ -17,7 +19,7 @@ def test_analyze_whole_database(db):
assert set(db.table_names()).issuperset( assert set(db.table_names()).issuperset(
{"one_index", "two_indexes", "sqlite_stat1"} {"one_index", "two_indexes", "sqlite_stat1"}
) )
assert list(db["sqlite_stat1"].rows) == [ assert list(db.table("sqlite_stat1").rows) == [
{"tbl": "two_indexes", "idx": "idx_two_indexes_species", "stat": "1 1"}, {"tbl": "two_indexes", "idx": "idx_two_indexes_species", "stat": "1 1"},
{"tbl": "two_indexes", "idx": "idx_two_indexes_name", "stat": "1 1"}, {"tbl": "two_indexes", "idx": "idx_two_indexes_name", "stat": "1 1"},
{"tbl": "one_index", "idx": "idx_one_index_name", "stat": "1 1"}, {"tbl": "one_index", "idx": "idx_one_index_name", "stat": "1 1"},
@ -30,12 +32,12 @@ def test_analyze_one_table(db, method):
if method == "db_method_with_name": if method == "db_method_with_name":
db.analyze("one_index") db.analyze("one_index")
elif method == "table_method": elif method == "table_method":
db["one_index"].analyze() db.table("one_index").analyze()
assert set(db.table_names()).issuperset( assert set(db.table_names()).issuperset(
{"one_index", "two_indexes", "sqlite_stat1"} {"one_index", "two_indexes", "sqlite_stat1"}
) )
assert list(db["sqlite_stat1"].rows) == [ assert list(db.table("sqlite_stat1").rows) == [
{"tbl": "one_index", "idx": "idx_one_index_name", "stat": "1 1"} {"tbl": "one_index", "idx": "idx_one_index_name", "stat": "1 1"}
] ]
@ -46,6 +48,6 @@ def test_analyze_index_by_name(db):
assert set(db.table_names()).issuperset( assert set(db.table_names()).issuperset(
{"one_index", "two_indexes", "sqlite_stat1"} {"one_index", "two_indexes", "sqlite_stat1"}
) )
assert list(db["sqlite_stat1"].rows) == [ assert list(db.table("sqlite_stat1").rows) == [
{"tbl": "two_indexes", "idx": "idx_two_indexes_species", "stat": "1 1"}, {"tbl": "two_indexes", "idx": "idx_two_indexes_species", "stat": "1 1"},
] ]

View file

@ -1,13 +1,15 @@
from sqlite_utils.db import Database, ColumnDetails
from sqlite_utils import cli
from click.testing import CliRunner
import pytest
import sqlite3 import sqlite3
import pytest
from click.testing import CliRunner
from sqlite_utils import cli
from sqlite_utils.db import ColumnDetails, Database
@pytest.fixture @pytest.fixture
def db_to_analyze(fresh_db): def db_to_analyze(fresh_db):
stuff = fresh_db["stuff"] stuff = fresh_db.table("stuff")
stuff.insert_all( stuff.insert_all(
[ [
{"id": 1, "owner": "Terryterryterry", "size": 5}, {"id": 1, "owner": "Terryterryterry", "size": 5},
@ -43,7 +45,7 @@ def big_db_to_analyze_path(tmpdir):
"all_null": None, "all_null": None,
} }
) )
db["stuff"].insert_all(to_insert) db.table("stuff").insert_all(to_insert)
return path return path
@ -124,7 +126,7 @@ def big_db_to_analyze_path(tmpdir):
) )
def test_analyze_column(db_to_analyze, column, extra_kwargs, expected): def test_analyze_column(db_to_analyze, column, extra_kwargs, expected):
assert ( assert (
db_to_analyze["stuff"].analyze_column( db_to_analyze.table("stuff").analyze_column(
column, common_limit=2, value_truncate=5, **extra_kwargs column, common_limit=2, value_truncate=5, **extra_kwargs
) )
== expected == expected
@ -184,7 +186,7 @@ def test_analyze_table_save(db_to_analyze_path):
cli.cli, ["analyze-tables", db_to_analyze_path, "--save"] cli.cli, ["analyze-tables", db_to_analyze_path, "--save"]
) )
assert result.exit_code == 0 assert result.exit_code == 0
rows = list(Database(db_to_analyze_path)["_analyze_tables_"].rows) rows = list(Database(db_to_analyze_path).table("_analyze_tables_").rows)
assert rows == [ assert rows == [
{ {
"table": "stuff", "table": "stuff",
@ -246,7 +248,7 @@ def test_analyze_table_save_no_most_no_least_options(
args.append("--no-least") args.append("--no-least")
result = CliRunner().invoke(cli.cli, args) result = CliRunner().invoke(cli.cli, args)
assert result.exit_code == 0 assert result.exit_code == 0
rows = list(Database(big_db_to_analyze_path)["_analyze_tables_"].rows) rows = list(Database(big_db_to_analyze_path).table("_analyze_tables_").rows)
expected = { expected = {
"table": "stuff", "table": "stuff",
"column": "category", "column": "category",
@ -295,13 +297,13 @@ def test_analyze_table_column_all_nulls(big_db_to_analyze_path):
def test_analyze_table_validate_columns(tmpdir, args, expected_error): def test_analyze_table_validate_columns(tmpdir, args, expected_error):
path = str(tmpdir / "test_validate_columns.db") path = str(tmpdir / "test_validate_columns.db")
db = Database(path) db = Database(path)
db["one"].insert( db.table("one").insert(
{ {
"id": 1, "id": 1,
"name": "one", "name": "one",
} }
) )
db["two"].insert( db.table("two").insert(
{ {
"id": 1, "id": 1,
"age": 5, "age": 5,

View file

@ -28,11 +28,13 @@ from sqlite_utils.utils import sqlite3
END; END;
""", """,
[ [
(
"CREATE TRIGGER t_ai AFTER INSERT ON t\n" "CREATE TRIGGER t_ai AFTER INSERT ON t\n"
" BEGIN\n" " BEGIN\n"
" UPDATE t SET value = 'a;b' WHERE id = new.id;\n" " UPDATE t SET value = 'a;b' WHERE id = new.id;\n"
" INSERT INTO log VALUES ('x;y');\n" " INSERT INTO log VALUES ('x;y');\n"
" END;" " END;"
)
], ],
), ),
), ),
@ -43,51 +45,47 @@ def test_iter_complete_sql_statements(sql, expected):
def test_atomic_commits(fresh_db): def test_atomic_commits(fresh_db):
with fresh_db.atomic(): with fresh_db.atomic():
fresh_db["dogs"].insert({"id": 1, "name": "Cleo"}, pk="id") fresh_db.table("dogs").insert({"id": 1, "name": "Cleo"}, pk="id")
assert list(fresh_db["dogs"].rows) == [{"id": 1, "name": "Cleo"}] assert list(fresh_db.table("dogs").rows) == [{"id": 1, "name": "Cleo"}]
def test_atomic_rolls_back(fresh_db): def test_atomic_rolls_back(fresh_db):
with pytest.raises(RuntimeError): with pytest.raises(RuntimeError), fresh_db.atomic():
with fresh_db.atomic(): fresh_db.table("dogs").insert({"id": 1, "name": "Cleo"}, pk="id")
fresh_db["dogs"].insert({"id": 1, "name": "Cleo"}, pk="id")
raise RuntimeError("boom") raise RuntimeError("boom")
assert not fresh_db["dogs"].exists() assert not fresh_db.table("dogs").exists()
def test_nested_atomic_rolls_back_to_savepoint(fresh_db): def test_nested_atomic_rolls_back_to_savepoint(fresh_db):
fresh_db["dogs"].create({"id": int, "name": str}, pk="id") fresh_db.table("dogs").create({"id": int, "name": str}, pk="id")
with fresh_db.atomic(): with fresh_db.atomic():
fresh_db["dogs"].insert({"id": 1, "name": "Cleo"}) fresh_db.table("dogs").insert({"id": 1, "name": "Cleo"})
with pytest.raises(RuntimeError): with pytest.raises(RuntimeError), fresh_db.atomic():
with fresh_db.atomic(): fresh_db.table("dogs").insert({"id": 2, "name": "Pancakes"})
fresh_db["dogs"].insert({"id": 2, "name": "Pancakes"})
raise RuntimeError("boom") raise RuntimeError("boom")
fresh_db["dogs"].insert({"id": 3, "name": "Marnie"}) fresh_db.table("dogs").insert({"id": 3, "name": "Marnie"})
assert list(fresh_db["dogs"].rows) == [ assert list(fresh_db.table("dogs").rows) == [
{"id": 1, "name": "Cleo"}, {"id": 1, "name": "Cleo"},
{"id": 3, "name": "Marnie"}, {"id": 3, "name": "Marnie"},
] ]
def test_outer_atomic_rolls_back_released_savepoint(fresh_db): def test_outer_atomic_rolls_back_released_savepoint(fresh_db):
with pytest.raises(RuntimeError): with pytest.raises(RuntimeError), fresh_db.atomic():
fresh_db.table("dogs").insert({"id": 1, "name": "Cleo"}, pk="id")
with fresh_db.atomic(): with fresh_db.atomic():
fresh_db["dogs"].insert({"id": 1, "name": "Cleo"}, pk="id") fresh_db.table("dogs").insert({"id": 2, "name": "Pancakes"})
with fresh_db.atomic():
fresh_db["dogs"].insert({"id": 2, "name": "Pancakes"})
raise RuntimeError("boom") raise RuntimeError("boom")
assert not fresh_db["dogs"].exists() assert not fresh_db.table("dogs").exists()
def test_executescript_does_not_commit_open_atomic_block(fresh_db): def test_executescript_does_not_commit_open_atomic_block(fresh_db):
with pytest.raises(RuntimeError): with pytest.raises(RuntimeError), fresh_db.atomic():
with fresh_db.atomic():
fresh_db.executescript(""" fresh_db.executescript("""
CREATE TABLE dogs(id INTEGER PRIMARY KEY, name TEXT); CREATE TABLE dogs(id INTEGER PRIMARY KEY, name TEXT);
CREATE TRIGGER dogs_ai AFTER INSERT ON dogs CREATE TRIGGER dogs_ai AFTER INSERT ON dogs
@ -99,42 +97,41 @@ def test_executescript_does_not_commit_open_atomic_block(fresh_db):
""") """)
raise RuntimeError("boom") raise RuntimeError("boom")
assert not fresh_db["dogs"].exists() assert not fresh_db.table("dogs").exists()
def test_transform_does_not_commit_open_atomic_block(fresh_db): def test_transform_does_not_commit_open_atomic_block(fresh_db):
fresh_db["dogs"].insert({"id": 1, "name": "Cleo", "age": "5"}, pk="id") fresh_db.table("dogs").insert({"id": 1, "name": "Cleo", "age": "5"}, pk="id")
with pytest.raises(RuntimeError): with pytest.raises(RuntimeError), fresh_db.atomic():
with fresh_db.atomic(): fresh_db.table("dogs").insert({"id": 2, "name": "Pancakes", "age": "6"})
fresh_db["dogs"].insert({"id": 2, "name": "Pancakes", "age": "6"}) fresh_db.table("dogs").transform(rename={"age": "dog_age"})
fresh_db["dogs"].transform(rename={"age": "dog_age"})
raise RuntimeError("boom") raise RuntimeError("boom")
assert ( assert (
fresh_db["dogs"].schema fresh_db.table("dogs").schema
== 'CREATE TABLE "dogs" (\n "id" INTEGER PRIMARY KEY,\n "name" TEXT,\n "age" TEXT\n)' == 'CREATE TABLE "dogs" (\n "id" INTEGER PRIMARY KEY,\n "name" TEXT,\n "age" TEXT\n)'
) )
assert list(fresh_db["dogs"].rows) == [ assert list(fresh_db.table("dogs").rows) == [
{"id": 1, "name": "Cleo", "age": "5"}, {"id": 1, "name": "Cleo", "age": "5"},
] ]
def test_transform_parent_table_with_foreign_keys_in_atomic(fresh_db): def test_transform_parent_table_with_foreign_keys_in_atomic(fresh_db):
fresh_db.conn.execute("PRAGMA foreign_keys=ON") fresh_db.conn.execute("PRAGMA foreign_keys=ON")
fresh_db["authors"].insert({"id": 1, "name": "Tina"}, pk="id") fresh_db.table("authors").insert({"id": 1, "name": "Tina"}, pk="id")
fresh_db["books"].insert( fresh_db.table("books").insert(
{"id": 1, "title": "Book", "author_id": 1}, {"id": 1, "title": "Book", "author_id": 1},
pk="id", pk="id",
foreign_keys={"author_id"}, foreign_keys={"author_id"},
) )
with fresh_db.atomic(): with fresh_db.atomic():
fresh_db["authors"].transform(rename={"name": "full_name"}) fresh_db.table("authors").transform(rename={"name": "full_name"})
assert fresh_db.conn.execute("PRAGMA foreign_keys").fetchone()[0] assert fresh_db.conn.execute("PRAGMA foreign_keys").fetchone()[0]
assert ( assert (
fresh_db["authors"].schema fresh_db.table("authors").schema
== 'CREATE TABLE "authors" (\n "id" INTEGER PRIMARY KEY,\n "full_name" TEXT\n)' == 'CREATE TABLE "authors" (\n "id" INTEGER PRIMARY KEY,\n "full_name" TEXT\n)'
) )
assert fresh_db.execute("PRAGMA foreign_key_check").fetchall() == [] assert fresh_db.execute("PRAGMA foreign_key_check").fetchall() == []
@ -142,20 +139,19 @@ def test_transform_parent_table_with_foreign_keys_in_atomic(fresh_db):
def test_transform_parent_table_with_foreign_keys_rolls_back(fresh_db): def test_transform_parent_table_with_foreign_keys_rolls_back(fresh_db):
fresh_db.conn.execute("PRAGMA foreign_keys=ON") fresh_db.conn.execute("PRAGMA foreign_keys=ON")
fresh_db["authors"].insert({"id": 1, "name": "Tina"}, pk="id") fresh_db.table("authors").insert({"id": 1, "name": "Tina"}, pk="id")
fresh_db["books"].insert( fresh_db.table("books").insert(
{"id": 1, "title": "Book", "author_id": 1}, {"id": 1, "title": "Book", "author_id": 1},
pk="id", pk="id",
foreign_keys={"author_id"}, foreign_keys={"author_id"},
) )
with pytest.raises(RuntimeError): with pytest.raises(RuntimeError), fresh_db.atomic():
with fresh_db.atomic(): fresh_db.table("authors").transform(rename={"name": "full_name"})
fresh_db["authors"].transform(rename={"name": "full_name"})
raise RuntimeError("boom") raise RuntimeError("boom")
assert ( assert (
fresh_db["authors"].schema fresh_db.table("authors").schema
== 'CREATE TABLE "authors" (\n "id" INTEGER PRIMARY KEY,\n "name" TEXT\n)' == 'CREATE TABLE "authors" (\n "id" INTEGER PRIMARY KEY,\n "name" TEXT\n)'
) )
assert fresh_db.conn.execute("PRAGMA foreign_keys").fetchone()[0] assert fresh_db.conn.execute("PRAGMA foreign_keys").fetchone()[0]
@ -164,49 +160,51 @@ def test_transform_parent_table_with_foreign_keys_rolls_back(fresh_db):
def test_transform_detects_foreign_key_check_violations(fresh_db): def test_transform_detects_foreign_key_check_violations(fresh_db):
fresh_db.conn.execute("PRAGMA foreign_keys=ON") fresh_db.conn.execute("PRAGMA foreign_keys=ON")
fresh_db["authors"].insert({"id": 1, "name": "Tina"}, pk="id") fresh_db.table("authors").insert({"id": 1, "name": "Tina"}, pk="id")
fresh_db["books"].insert({"id": 1, "author_id": 2}, pk="id") fresh_db.table("books").insert({"id": 1, "author_id": 2}, pk="id")
with pytest.raises(sqlite3.IntegrityError): with pytest.raises(sqlite3.IntegrityError):
fresh_db["books"].transform(add_foreign_keys=(("author_id", "authors", "id"),)) fresh_db.table("books").transform(
add_foreign_keys=(("author_id", "authors", "id"),)
)
assert fresh_db["books"].foreign_keys == [] assert fresh_db.table("books").foreign_keys == []
assert fresh_db.conn.execute("PRAGMA foreign_keys").fetchone()[0] assert fresh_db.conn.execute("PRAGMA foreign_keys").fetchone()[0]
def test_atomic_inside_manual_transaction_uses_savepoint(fresh_db): def test_atomic_inside_manual_transaction_uses_savepoint(fresh_db):
fresh_db["t"].insert({"id": 1}, pk="id") fresh_db.table("t").insert({"id": 1}, pk="id")
fresh_db.execute("begin") fresh_db.execute("begin")
with fresh_db.atomic(): with fresh_db.atomic():
fresh_db["t"].insert({"id": 2}, pk="id") fresh_db.table("t").insert({"id": 2}, pk="id")
# Nothing is committed until the user's own transaction commits # Nothing is committed until the user's own transaction commits
assert fresh_db.conn.in_transaction assert fresh_db.conn.in_transaction
fresh_db.rollback() fresh_db.rollback()
assert [r["id"] for r in fresh_db["t"].rows] == [1] assert [r["id"] for r in fresh_db.table("t").rows] == [1]
# And with a commit instead, the atomic block's writes persist # And with a commit instead, the atomic block's writes persist
fresh_db.execute("begin") fresh_db.execute("begin")
with fresh_db.atomic(): with fresh_db.atomic():
fresh_db["t"].insert({"id": 3}, pk="id") fresh_db.table("t").insert({"id": 3}, pk="id")
fresh_db.commit() fresh_db.commit()
assert [r["id"] for r in fresh_db["t"].rows] == [1, 3] assert [r["id"] for r in fresh_db.table("t").rows] == [1, 3]
def test_begin_commit_rollback(tmpdir): def test_begin_commit_rollback(tmpdir):
path = str(tmpdir / "test.db") path = str(tmpdir / "test.db")
db = Database(path) db = Database(path)
db["t"].insert({"id": 1}, pk="id") db.table("t").insert({"id": 1}, pk="id")
db.begin() db.begin()
db["t"].insert({"id": 2}, pk="id") db.table("t").insert({"id": 2}, pk="id")
assert db.conn.in_transaction assert db.conn.in_transaction
db.rollback() db.rollback()
assert not db.conn.in_transaction assert not db.conn.in_transaction
assert [r["id"] for r in db["t"].rows] == [1] assert [r["id"] for r in db.table("t").rows] == [1]
db.begin() db.begin()
db["t"].insert({"id": 3}, pk="id") db.table("t").insert({"id": 3}, pk="id")
db.commit() db.commit()
db.close() db.close()
db2 = Database(path) db2 = Database(path)
assert [r["id"] for r in db2["t"].rows] == [1, 3] assert [r["id"] for r in db2.table("t").rows] == [1, 3]
db2.close() db2.close()
@ -226,7 +224,7 @@ def test_commit_and_rollback_without_transaction_are_noops(fresh_db):
def test_execute_write_commits_immediately(tmpdir): def test_execute_write_commits_immediately(tmpdir):
path = str(tmpdir / "test.db") path = str(tmpdir / "test.db")
db = Database(path) db = Database(path)
db["t"].insert({"id": 1}, pk="id") db.table("t").insert({"id": 1}, pk="id")
db.execute("insert into t (id) values (2)") db.execute("insert into t (id) values (2)")
# No implicit transaction is left open # No implicit transaction is left open
assert not db.conn.in_transaction assert not db.conn.in_transaction
@ -238,24 +236,24 @@ def test_execute_write_commits_immediately(tmpdir):
def test_execute_write_respects_explicit_transaction(fresh_db): def test_execute_write_respects_explicit_transaction(fresh_db):
fresh_db["t"].insert({"id": 1}, pk="id") fresh_db.table("t").insert({"id": 1}, pk="id")
fresh_db.begin() fresh_db.begin()
fresh_db.execute("insert into t (id) values (2)") fresh_db.execute("insert into t (id) values (2)")
# Still inside the explicit transaction - not committed # Still inside the explicit transaction - not committed
assert fresh_db.conn.in_transaction assert fresh_db.conn.in_transaction
fresh_db.rollback() fresh_db.rollback()
assert [r["id"] for r in fresh_db["t"].rows] == [1] assert [r["id"] for r in fresh_db.table("t").rows] == [1]
def test_execute_comment_prefixed_begin_leaves_transaction_open(fresh_db): def test_execute_comment_prefixed_begin_leaves_transaction_open(fresh_db):
# A BEGIN hidden behind a leading comment must not be auto-committed # A BEGIN hidden behind a leading comment must not be auto-committed
# out from under the caller # out from under the caller
fresh_db["t"].insert({"id": 1}, pk="id") fresh_db.table("t").insert({"id": 1}, pk="id")
fresh_db.execute("-- start a transaction\nbegin") fresh_db.execute("-- start a transaction\nbegin")
assert fresh_db.conn.in_transaction assert fresh_db.conn.in_transaction
fresh_db.execute("insert into t (id) values (2)") fresh_db.execute("insert into t (id) values (2)")
fresh_db.rollback() fresh_db.rollback()
assert [r["id"] for r in fresh_db["t"].rows] == [1] assert [r["id"] for r in fresh_db.table("t").rows] == [1]
def _sqlite_accepts_bom(): def _sqlite_accepts_bom():
@ -273,12 +271,12 @@ def test_execute_prefixed_begin_leaves_transaction_open(fresh_db, begin_sql):
# out from under the caller # out from under the caller
if begin_sql.startswith("\ufeff") and not _sqlite_accepts_bom(): if begin_sql.startswith("\ufeff") and not _sqlite_accepts_bom():
pytest.skip("This SQLite version rejects a leading byte order mark") pytest.skip("This SQLite version rejects a leading byte order mark")
fresh_db["t"].insert({"id": 1}, pk="id") fresh_db.table("t").insert({"id": 1}, pk="id")
fresh_db.execute(begin_sql) fresh_db.execute(begin_sql)
assert fresh_db.conn.in_transaction assert fresh_db.conn.in_transaction
fresh_db.execute("insert into t (id) values (2)") fresh_db.execute("insert into t (id) values (2)")
fresh_db.rollback() fresh_db.rollback()
assert [r["id"] for r in fresh_db["t"].rows] == [1] assert [r["id"] for r in fresh_db.table("t").rows] == [1]
def test_execute_failed_write_rolls_back_implicit_transaction(tmpdir): def test_execute_failed_write_rolls_back_implicit_transaction(tmpdir):
@ -286,40 +284,40 @@ def test_execute_failed_write_rolls_back_implicit_transaction(tmpdir):
# that would silently disable auto-commit for every subsequent write # that would silently disable auto-commit for every subsequent write
path = str(tmpdir / "test.db") path = str(tmpdir / "test.db")
db = Database(path) db = Database(path)
db["t"].insert({"id": 1}, pk="id") db.table("t").insert({"id": 1}, pk="id")
with pytest.raises(sqlite3.IntegrityError): with pytest.raises(sqlite3.IntegrityError):
db.execute("insert into t (id) values (1)") db.execute("insert into t (id) values (1)")
assert not db.conn.in_transaction assert not db.conn.in_transaction
# Subsequent writes commit as normal and survive closing the connection # Subsequent writes commit as normal and survive closing the connection
db["other"].insert({"id": 2}) db.table("other").insert({"id": 2})
db.close() db.close()
db2 = Database(path) db2 = Database(path)
assert db2["other"].exists() assert db2.table("other").exists()
db2.close() db2.close()
def test_execute_failed_write_preserves_explicit_transaction(fresh_db): def test_execute_failed_write_preserves_explicit_transaction(fresh_db):
# A failed write inside an explicit transaction must not roll back # A failed write inside an explicit transaction must not roll back
# the caller's earlier work - only the caller decides that # the caller's earlier work - only the caller decides that
fresh_db["t"].insert({"id": 1}, pk="id") fresh_db.table("t").insert({"id": 1}, pk="id")
fresh_db.begin() fresh_db.begin()
fresh_db.execute("insert into t (id) values (2)") fresh_db.execute("insert into t (id) values (2)")
with pytest.raises(sqlite3.IntegrityError): with pytest.raises(sqlite3.IntegrityError):
fresh_db.execute("insert into t (id) values (1)") fresh_db.execute("insert into t (id) values (1)")
assert fresh_db.conn.in_transaction assert fresh_db.conn.in_transaction
fresh_db.commit() fresh_db.commit()
assert [r["id"] for r in fresh_db["t"].rows] == [1, 2] assert [r["id"] for r in fresh_db.table("t").rows] == [1, 2]
def test_execute_failed_write_inside_atomic_preserves_block(fresh_db): def test_execute_failed_write_inside_atomic_preserves_block(fresh_db):
# A caught failure inside an atomic() block must leave the block's # A caught failure inside an atomic() block must leave the block's
# transaction open so its other work still commits # transaction open so its other work still commits
fresh_db["t"].insert({"id": 1}, pk="id") fresh_db.table("t").insert({"id": 1}, pk="id")
with fresh_db.atomic(): with fresh_db.atomic():
fresh_db.execute("insert into t (id) values (2)") fresh_db.execute("insert into t (id) values (2)")
with pytest.raises(sqlite3.IntegrityError): with pytest.raises(sqlite3.IntegrityError):
fresh_db.execute("insert into t (id) values (1)") fresh_db.execute("insert into t (id) values (1)")
assert [r["id"] for r in fresh_db["t"].rows] == [1, 2] assert [r["id"] for r in fresh_db.table("t").rows] == [1, 2]
def test_query_returning_commits_after_iteration(tmpdir): def test_query_returning_commits_after_iteration(tmpdir):
@ -329,7 +327,7 @@ def test_query_returning_commits_after_iteration(tmpdir):
_pytest.skip("RETURNING requires SQLite 3.35.0 or higher") _pytest.skip("RETURNING requires SQLite 3.35.0 or higher")
path = str(tmpdir / "test.db") path = str(tmpdir / "test.db")
db = Database(path) db = Database(path)
db["t"].insert({"id": 1}, pk="id") db.table("t").insert({"id": 1}, pk="id")
rows = list(db.query("insert into t (id) values (2) returning id")) rows = list(db.query("insert into t (id) values (2) returning id"))
assert rows == [{"id": 2}] assert rows == [{"id": 2}]
assert not db.conn.in_transaction assert not db.conn.in_transaction
@ -354,8 +352,10 @@ def test_atomic_preserves_error_from_transaction_destroying_trigger(fresh_db):
# with "cannot rollback - no transaction is active" # with "cannot rollback - no transaction is active"
fresh_db.execute("create table t (id integer primary key, v text)") fresh_db.execute("create table t (id integer primary key, v text)")
fresh_db.execute(TRIGGER_SQL) fresh_db.execute(TRIGGER_SQL)
with pytest.raises(sqlite3.IntegrityError, match="trigger says no"): with (
with fresh_db.atomic(): pytest.raises(sqlite3.IntegrityError, match="trigger says no"),
fresh_db.atomic(),
):
fresh_db.execute("insert into t (v) values ('bad')") fresh_db.execute("insert into t (v) values ('bad')")
assert not fresh_db.conn.in_transaction assert not fresh_db.conn.in_transaction
@ -367,16 +367,17 @@ def test_nested_atomic_preserves_error_from_transaction_destroying_trigger(
# "no such savepoint" from ROLLBACK TO SAVEPOINT # "no such savepoint" from ROLLBACK TO SAVEPOINT
fresh_db.execute("create table t (id integer primary key, v text)") fresh_db.execute("create table t (id integer primary key, v text)")
fresh_db.execute(TRIGGER_SQL) fresh_db.execute(TRIGGER_SQL)
with pytest.raises(sqlite3.IntegrityError, match="trigger says no"): with (
with fresh_db.atomic(): pytest.raises(sqlite3.IntegrityError, match="trigger says no"),
with fresh_db.atomic(): fresh_db.atomic(),
fresh_db.atomic(),
):
fresh_db.execute("insert into t (v) values ('bad')") fresh_db.execute("insert into t (v) values ('bad')")
assert not fresh_db.conn.in_transaction assert not fresh_db.conn.in_transaction
def test_atomic_preserves_error_from_insert_or_rollback(fresh_db): def test_atomic_preserves_error_from_insert_or_rollback(fresh_db):
fresh_db["t"].insert({"id": 1}, pk="id") fresh_db.table("t").insert({"id": 1}, pk="id")
with pytest.raises(sqlite3.IntegrityError): with pytest.raises(sqlite3.IntegrityError), fresh_db.atomic():
with fresh_db.atomic():
fresh_db.execute("insert or rollback into t (id) values (1)") fresh_db.execute("insert or rollback into t (id) values (1)")
assert not fresh_db.conn.in_transaction assert not fresh_db.conn.in_transaction

View file

@ -6,10 +6,10 @@ def test_attach(tmpdir):
bar_path = str(tmpdir / "bar.db") bar_path = str(tmpdir / "bar.db")
db = Database(foo_path) db = Database(foo_path)
with db.conn: with db.conn:
db["foo"].insert({"id": 1, "text": "foo"}) db.table("foo").insert({"id": 1, "text": "foo"})
db2 = Database(bar_path) db2 = Database(bar_path)
with db2.conn: with db2.conn:
db2["bar"].insert({"id": 1, "text": "bar"}) db2.table("bar").insert({"id": 1, "text": "bar"})
db.attach("bar", bar_path) db.attach("bar", bar_path)
assert db.execute( assert db.execute(
"select * from foo union all select * from bar.bar" "select * from foo union all select * from bar.bar"

File diff suppressed because it is too large Load diff

View file

@ -1,17 +1,19 @@
from click.testing import CliRunner
from sqlite_utils import cli, Database
import pathlib import pathlib
import pytest
import subprocess import subprocess
import sys import sys
import time import time
import pytest
from click.testing import CliRunner
from sqlite_utils import Database, cli
@pytest.fixture @pytest.fixture
def test_db_and_path(tmpdir): def test_db_and_path(tmpdir):
db_path = str(pathlib.Path(tmpdir) / "data.db") db_path = str(pathlib.Path(tmpdir) / "data.db")
db = Database(db_path) db = Database(db_path)
db["example"].insert_all( db.table("example").insert_all(
[ [
{"id": 1, "name": "One"}, {"id": 1, "name": "One"},
{"id": 2, "name": "Two"}, {"id": 2, "name": "Two"},
@ -42,7 +44,7 @@ def test_cli_bulk(test_db_and_path):
{"id": 2, "name": "Two"}, {"id": 2, "name": "Two"},
{"id": 3, "name": "THREE"}, {"id": 3, "name": "THREE"},
{"id": 4, "name": "FOUR"}, {"id": 4, "name": "FOUR"},
] == list(db["example"].rows) ] == list(db.table("example").rows)
def test_cli_bulk_multiple_functions(test_db_and_path): def test_cli_bulk_multiple_functions(test_db_and_path):
@ -68,7 +70,7 @@ def test_cli_bulk_multiple_functions(test_db_and_path):
{"id": 2, "name": "Two"}, {"id": 2, "name": "Two"},
{"id": 3, "name": "THREE"}, {"id": 3, "name": "THREE"},
{"id": 4, "name": "FOUR"}, {"id": 4, "name": "FOUR"},
] == list(db["example"].rows) ] == list(db.table("example").rows)
def test_cli_bulk_batch_size(test_db_and_path): def test_cli_bulk_batch_size(test_db_and_path):
@ -89,17 +91,18 @@ def test_cli_bulk_batch_size(test_db_and_path):
stdin=subprocess.PIPE, stdin=subprocess.PIPE,
stdout=sys.stdout, stdout=sys.stdout,
) )
assert proc.stdin is not None
# Writing one record should not commit # Writing one record should not commit
proc.stdin.write(b'{"id": 3, "name": "Three"}\n\n') proc.stdin.write(b'{"id": 3, "name": "Three"}\n\n')
proc.stdin.flush() proc.stdin.flush()
time.sleep(1) time.sleep(1)
assert db["example"].count == 2 assert db.table("example").count == 2
# Writing another should trigger a commit: # Writing another should trigger a commit:
proc.stdin.write(b'{"id": 4, "name": "Four"}\n\n') proc.stdin.write(b'{"id": 4, "name": "Four"}\n\n')
proc.stdin.flush() proc.stdin.flush()
time.sleep(1) time.sleep(1)
assert db["example"].count == 4 assert db.table("example").count == 4
proc.stdin.close() proc.stdin.close()
proc.wait() proc.wait()

View file

@ -1,16 +1,18 @@
from click.testing import CliRunner
from sqlite_utils import cli
import sqlite_utils
import json import json
import textwrap
import pathlib import pathlib
import textwrap
import pytest import pytest
from click.testing import CliRunner
import sqlite_utils
from sqlite_utils import cli
@pytest.fixture @pytest.fixture
def test_db_and_path(fresh_db_and_path): def test_db_and_path(fresh_db_and_path):
db, db_path = fresh_db_and_path db, db_path = fresh_db_and_path
db["example"].insert_all( db.table("example").insert_all(
[ [
{"id": 1, "dt": "5th October 2019 12:04"}, {"id": 1, "dt": "5th October 2019 12:04"},
{"id": 2, "dt": "6th October 2019 00:05:06"}, {"id": 2, "dt": "6th October 2019 00:05:06"},
@ -45,12 +47,12 @@ def fresh_db_and_path(tmpdir):
) )
def test_convert_code(fresh_db_and_path, code): def test_convert_code(fresh_db_and_path, code):
db, db_path = fresh_db_and_path db, db_path = fresh_db_and_path
db["t"].insert({"text": "October"}) db.table("t").insert({"text": "October"})
result = CliRunner().invoke( result = CliRunner().invoke(
cli.cli, ["convert", db_path, "t", "text", code], catch_exceptions=False cli.cli, ["convert", db_path, "t", "text", code], catch_exceptions=False
) )
assert result.exit_code == 0, result.output assert result.exit_code == 0, result.output
value = list(db["t"].rows)[0]["text"] value = next(iter(db.table("t").rows))["text"]
assert value == "Spooktober" assert value == "Spooktober"
@ -63,7 +65,7 @@ def test_convert_code(fresh_db_and_path, code):
) )
def test_convert_code_errors(fresh_db_and_path, bad_code): def test_convert_code_errors(fresh_db_and_path, bad_code):
db, db_path = fresh_db_and_path db, db_path = fresh_db_and_path
db["t"].insert({"text": "October"}) db.table("t").insert({"text": "October"})
result = CliRunner().invoke( result = CliRunner().invoke(
cli.cli, ["convert", db_path, "t", "text", bad_code], catch_exceptions=False cli.cli, ["convert", db_path, "t", "text", bad_code], catch_exceptions=False
) )
@ -91,12 +93,12 @@ def test_convert_import(test_db_and_path):
{"id": 2, "dt": "6th OXXober 2019 00:05:06"}, {"id": 2, "dt": "6th OXXober 2019 00:05:06"},
{"id": 3, "dt": ""}, {"id": 3, "dt": ""},
{"id": 4, "dt": None}, {"id": 4, "dt": None},
] == list(db["example"].rows) ] == list(db.table("example").rows)
def test_convert_import_nested(fresh_db_and_path): def test_convert_import_nested(fresh_db_and_path):
db, db_path = fresh_db_and_path db, db_path = fresh_db_and_path
db["example"].insert({"xml": '<item name="Cleo" />'}) db.table("example").insert({"xml": '<item name="Cleo" />'})
result = CliRunner().invoke( result = CliRunner().invoke(
cli.cli, cli.cli,
[ [
@ -112,7 +114,7 @@ def test_convert_import_nested(fresh_db_and_path):
assert result.exit_code == 0, result.output assert result.exit_code == 0, result.output
assert [ assert [
{"xml": "Cleo"}, {"xml": "Cleo"},
] == list(db["example"].rows) ] == list(db.table("example").rows)
def test_convert_dryrun(test_db_and_path): def test_convert_dryrun(test_db_and_path):
@ -150,7 +152,7 @@ def test_convert_dryrun(test_db_and_path):
"Would affect 4 rows" "Would affect 4 rows"
) )
# But it should not have actually modified the table data # But it should not have actually modified the table data
assert list(db["example"].rows) == [ assert list(db.table("example").rows) == [
{"id": 1, "dt": "5th October 2019 12:04"}, {"id": 1, "dt": "5th October 2019 12:04"},
{"id": 2, "dt": "6th October 2019 00:05:06"}, {"id": 2, "dt": "6th October 2019 00:05:06"},
{"id": 3, "dt": ""}, {"id": 3, "dt": ""},
@ -179,6 +181,34 @@ def test_convert_dryrun(test_db_and_path):
assert result.output.strip().split("\n")[-1] == "Would affect 1 row" assert result.output.strip().split("\n")[-1] == "Would affect 1 row"
def test_convert_dryrun_table_and_column_names_containing_closing_bracket(
fresh_db_and_path,
):
db, db_path = fresh_db_and_path
table_name = "table]name"
column_name = "column]name"
db[table_name].insert({column_name: "hello"})
result = CliRunner().invoke(
cli.cli,
[
"convert",
db_path,
table_name,
column_name,
"value.upper()",
"--dry-run",
],
catch_exceptions=False,
)
assert result.exit_code == 0
assert result.output.strip() == (
"hello\n --- becomes:\nHELLO\n\nWould affect 1 row"
)
assert list(db[table_name].rows) == [{column_name: "hello"}]
def test_convert_multi_dryrun(test_db_and_path): def test_convert_multi_dryrun(test_db_and_path):
db_path = test_db_and_path[1] db_path = test_db_and_path[1]
result = CliRunner().invoke( result = CliRunner().invoke(
@ -267,7 +297,7 @@ def test_convert_output_column(test_db_and_path, drop):
if drop: if drop:
for row in expected: for row in expected:
del row["dt"] del row["dt"]
assert list(db["example"].rows) == expected assert list(db.table("example").rows) == expected
@pytest.mark.parametrize( @pytest.mark.parametrize(
@ -350,7 +380,7 @@ def test_convert_output_error(test_db_and_path, options, expected_error):
@pytest.mark.parametrize("drop", (True, False)) @pytest.mark.parametrize("drop", (True, False))
def test_convert_multi(fresh_db_and_path, drop): def test_convert_multi(fresh_db_and_path, drop):
db, db_path = fresh_db_and_path db, db_path = fresh_db_and_path
db["creatures"].insert_all( db.table("creatures").insert_all(
[ [
{"id": 1, "name": "Simon"}, {"id": 1, "name": "Simon"},
{"id": 2, "name": "Cleo"}, {"id": 2, "name": "Cleo"},
@ -376,12 +406,12 @@ def test_convert_multi(fresh_db_and_path, drop):
if drop: if drop:
for row in expected: for row in expected:
del row["name"] del row["name"]
assert list(db["creatures"].rows) == expected assert list(db.table("creatures").rows) == expected
def test_convert_multi_complex_column_types(fresh_db_and_path): def test_convert_multi_complex_column_types(fresh_db_and_path):
db, db_path = fresh_db_and_path db, db_path = fresh_db_and_path
db["rows"].insert_all( db.table("rows").insert_all(
[ [
{"id": 1}, {"id": 1},
{"id": 2}, {"id": 2},
@ -410,7 +440,7 @@ def test_convert_multi_complex_column_types(fresh_db_and_path):
], ],
) )
assert result.exit_code == 0, result.output assert result.exit_code == 0, result.output
assert list(db["rows"].rows) == [ assert list(db.table("rows").rows) == [
{"id": 1, "is_str": "", "is_float": 1.2, "is_int": None, "is_bytes": None}, {"id": 1, "is_str": "", "is_float": 1.2, "is_int": None, "is_bytes": None},
{"id": 2, "is_str": None, "is_float": 1.0, "is_int": 12, "is_bytes": None}, {"id": 2, "is_str": None, "is_float": 1.0, "is_int": 12, "is_bytes": None},
{ {
@ -422,7 +452,7 @@ def test_convert_multi_complex_column_types(fresh_db_and_path):
}, },
{"id": 4, "is_str": None, "is_float": None, "is_int": None, "is_bytes": None}, {"id": 4, "is_str": None, "is_float": None, "is_int": None, "is_bytes": None},
] ]
assert db["rows"].schema == ( assert db.table("rows").schema == (
'CREATE TABLE "rows" (\n' 'CREATE TABLE "rows" (\n'
' "id" INTEGER PRIMARY KEY\n' ' "id" INTEGER PRIMARY KEY\n'
', "is_str" TEXT, "is_float" REAL, "is_int" INTEGER, "is_bytes" BLOB)' ', "is_str" TEXT, "is_float" REAL, "is_int" INTEGER, "is_bytes" BLOB)'
@ -433,7 +463,7 @@ def test_convert_multi_complex_column_types(fresh_db_and_path):
def test_recipe_jsonsplit(tmpdir, delimiter): def test_recipe_jsonsplit(tmpdir, delimiter):
db_path = str(pathlib.Path(tmpdir) / "data.db") db_path = str(pathlib.Path(tmpdir) / "data.db")
db = sqlite_utils.Database(db_path) db = sqlite_utils.Database(db_path)
db["example"].insert_all( db.table("example").insert_all(
[ [
{"id": 1, "tags": (delimiter or ",").join(["foo", "bar"])}, {"id": 1, "tags": (delimiter or ",").join(["foo", "bar"])},
{"id": 2, "tags": (delimiter or ",").join(["bar", "baz"])}, {"id": 2, "tags": (delimiter or ",").join(["bar", "baz"])},
@ -442,11 +472,11 @@ def test_recipe_jsonsplit(tmpdir, delimiter):
) )
code = "r.jsonsplit(value)" code = "r.jsonsplit(value)"
if delimiter: if delimiter:
code = 'recipes.jsonsplit(value, delimiter="{}")'.format(delimiter) code = f'recipes.jsonsplit(value, delimiter="{delimiter}")'
args = ["convert", db_path, "example", "tags", code] args = ["convert", db_path, "example", "tags", code]
result = CliRunner().invoke(cli.cli, args) result = CliRunner().invoke(cli.cli, args)
assert result.exit_code == 0, result.output assert result.exit_code == 0, result.output
assert list(db["example"].rows) == [ assert list(db.table("example").rows) == [
{"id": 1, "tags": '["foo", "bar"]'}, {"id": 1, "tags": '["foo", "bar"]'},
{"id": 2, "tags": '["bar", "baz"]'}, {"id": 2, "tags": '["bar", "baz"]'},
] ]
@ -462,7 +492,7 @@ def test_recipe_jsonsplit(tmpdir, delimiter):
) )
def test_recipe_jsonsplit_type(fresh_db_and_path, type, expected_array): def test_recipe_jsonsplit_type(fresh_db_and_path, type, expected_array):
db, db_path = fresh_db_and_path db, db_path = fresh_db_and_path
db["example"].insert_all( db.table("example").insert_all(
[ [
{"id": 1, "records": "1,2,3"}, {"id": 1, "records": "1,2,3"},
], ],
@ -470,17 +500,17 @@ def test_recipe_jsonsplit_type(fresh_db_and_path, type, expected_array):
) )
code = "r.jsonsplit(value)" code = "r.jsonsplit(value)"
if type: if type:
code = "recipes.jsonsplit(value, type={})".format(type) code = f"recipes.jsonsplit(value, type={type})"
args = ["convert", db_path, "example", "records", code] args = ["convert", db_path, "example", "records", code]
result = CliRunner().invoke(cli.cli, args) result = CliRunner().invoke(cli.cli, args)
assert result.exit_code == 0, result.output assert result.exit_code == 0, result.output
assert json.loads(db["example"].get(1)["records"]) == expected_array assert json.loads(db.table("example").get(1)["records"]) == expected_array
@pytest.mark.parametrize("drop", (True, False)) @pytest.mark.parametrize("drop", (True, False))
def test_recipe_jsonsplit_output(fresh_db_and_path, drop): def test_recipe_jsonsplit_output(fresh_db_and_path, drop):
db, db_path = fresh_db_and_path db, db_path = fresh_db_and_path
db["example"].insert_all( db.table("example").insert_all(
[ [
{"id": 1, "records": "1,2,3"}, {"id": 1, "records": "1,2,3"},
], ],
@ -499,7 +529,7 @@ def test_recipe_jsonsplit_output(fresh_db_and_path, drop):
} }
if drop: if drop:
del expected["records"] del expected["records"]
assert db["example"].get(1) == expected assert db.table("example").get(1) == expected
def test_cannot_use_drop_without_multi_or_output(fresh_db_and_path): def test_cannot_use_drop_without_multi_or_output(fresh_db_and_path):
@ -556,7 +586,7 @@ def test_convert_where(test_db_and_path):
], ],
) )
assert result.exit_code == 0, result.output assert result.exit_code == 0, result.output
assert list(db["example"].rows) == [ assert list(db.table("example").rows) == [
{"id": 1, "dt": "5th October 2019 12:04"}, {"id": 1, "dt": "5th October 2019 12:04"},
{"id": 2, "dt": "6TH OCTOBER 2019 00:05:06"}, {"id": 2, "dt": "6TH OCTOBER 2019 00:05:06"},
{"id": 3, "dt": ""}, {"id": 3, "dt": ""},
@ -566,7 +596,7 @@ def test_convert_where(test_db_and_path):
def test_convert_where_multi(fresh_db_and_path): def test_convert_where_multi(fresh_db_and_path):
db, db_path = fresh_db_and_path db, db_path = fresh_db_and_path
db["names"].insert_all( db.table("names").insert_all(
[{"id": 1, "name": "Cleo"}, {"id": 2, "name": "Bants"}], pk="id" [{"id": 1, "name": "Cleo"}, {"id": 2, "name": "Bants"}], pk="id"
) )
result = CliRunner().invoke( result = CliRunner().invoke(
@ -586,7 +616,7 @@ def test_convert_where_multi(fresh_db_and_path):
], ],
) )
assert result.exit_code == 0, result.output assert result.exit_code == 0, result.output
assert list(db["names"].rows) == [ assert list(db.table("names").rows) == [
{"id": 1, "name": "Cleo", "upper": None}, {"id": 1, "name": "Cleo", "upper": None},
{"id": 2, "name": "Bants", "upper": "BANTS"}, {"id": 2, "name": "Bants", "upper": "BANTS"},
] ]
@ -594,7 +624,7 @@ def test_convert_where_multi(fresh_db_and_path):
def test_convert_code_standard_input(fresh_db_and_path): def test_convert_code_standard_input(fresh_db_and_path):
db, db_path = fresh_db_and_path db, db_path = fresh_db_and_path
db["names"].insert_all([{"id": 1, "name": "Cleo"}], pk="id") db.table("names").insert_all([{"id": 1, "name": "Cleo"}], pk="id")
result = CliRunner().invoke( result = CliRunner().invoke(
cli.cli, cli.cli,
[ [
@ -607,27 +637,27 @@ def test_convert_code_standard_input(fresh_db_and_path):
input="value.upper()", input="value.upper()",
) )
assert result.exit_code == 0, result.output assert result.exit_code == 0, result.output
assert list(db["names"].rows) == [ assert list(db.table("names").rows) == [
{"id": 1, "name": "CLEO"}, {"id": 1, "name": "CLEO"},
] ]
def test_convert_hyphen_workaround(fresh_db_and_path): def test_convert_hyphen_workaround(fresh_db_and_path):
db, db_path = fresh_db_and_path db, db_path = fresh_db_and_path
db["names"].insert_all([{"id": 1, "name": "Cleo"}], pk="id") db.table("names").insert_all([{"id": 1, "name": "Cleo"}], pk="id")
result = CliRunner().invoke( result = CliRunner().invoke(
cli.cli, cli.cli,
["convert", db_path, "names", "name", '"-"'], ["convert", db_path, "names", "name", '"-"'],
) )
assert result.exit_code == 0, result.output assert result.exit_code == 0, result.output
assert list(db["names"].rows) == [ assert list(db.table("names").rows) == [
{"id": 1, "name": "-"}, {"id": 1, "name": "-"},
] ]
def test_convert_initialization_pattern(fresh_db_and_path): def test_convert_initialization_pattern(fresh_db_and_path):
db, db_path = fresh_db_and_path db, db_path = fresh_db_and_path
db["names"].insert_all([{"id": 1, "name": "Cleo"}], pk="id") db.table("names").insert_all([{"id": 1, "name": "Cleo"}], pk="id")
result = CliRunner().invoke( result = CliRunner().invoke(
cli.cli, cli.cli,
[ [
@ -640,7 +670,7 @@ def test_convert_initialization_pattern(fresh_db_and_path):
input="import random\nrandom.seed(1)\ndef convert(value): return random.randint(0, 100)", input="import random\nrandom.seed(1)\ndef convert(value): return random.randint(0, 100)",
) )
assert result.exit_code == 0, result.output assert result.exit_code == 0, result.output
assert list(db["names"].rows) == [ assert list(db.table("names").rows) == [
{"id": 1, "name": "17"}, {"id": 1, "name": "17"},
] ]
@ -655,13 +685,13 @@ def test_convert_handles_falsey_values(fresh_db_and_path):
"x", "x",
"-", "-",
] ]
db["t"].insert_all([{"x": 0}, {"x": 1}]) db.table("t").insert_all([{"x": 0}, {"x": 1}])
assert db["t"].get(1)["x"] == 0 assert db.table("t").get(1)["x"] == 0
assert db["t"].get(2)["x"] == 1 assert db.table("t").get(2)["x"] == 1
result = CliRunner().invoke(cli.cli, args, input="value + 1") result = CliRunner().invoke(cli.cli, args, input="value + 1")
assert result.exit_code == 0, result.output assert result.exit_code == 0, result.output
assert db["t"].get(1)["x"] == 1 assert db.table("t").get(1)["x"] == 1
assert db["t"].get(2)["x"] == 2 assert db.table("t").get(2)["x"] == 2
@pytest.mark.parametrize( @pytest.mark.parametrize(
@ -682,7 +712,7 @@ def test_convert_callable_reference(test_db_and_path, code):
cli.cli, ["convert", db_path, "example", "dt", code], catch_exceptions=False cli.cli, ["convert", db_path, "example", "dt", code], catch_exceptions=False
) )
assert result.exit_code == 0, result.output assert result.exit_code == 0, result.output
rows = list(db["example"].rows) rows = list(db.table("example").rows)
assert rows[0]["dt"] == "2019-10-05" assert rows[0]["dt"] == "2019-10-05"
assert rows[1]["dt"] == "2019-10-06" assert rows[1]["dt"] == "2019-10-06"
assert rows[2]["dt"] == "" assert rows[2]["dt"] == ""
@ -692,7 +722,7 @@ def test_convert_callable_reference(test_db_and_path, code):
def test_convert_callable_reference_with_import(fresh_db_and_path): def test_convert_callable_reference_with_import(fresh_db_and_path):
"""Test callable reference from an imported module""" """Test callable reference from an imported module"""
db, db_path = fresh_db_and_path db, db_path = fresh_db_and_path
db["example"].insert({"id": 1, "data": '{"name": "test"}'}) db.table("example").insert({"id": 1, "data": '{"name": "test"}'})
result = CliRunner().invoke( result = CliRunner().invoke(
cli.cli, cli.cli,
[ [
@ -708,5 +738,5 @@ def test_convert_callable_reference_with_import(fresh_db_and_path):
) )
assert result.exit_code == 0, result.output assert result.exit_code == 0, result.output
# json.loads returns a dict, which sqlite stores as JSON string # json.loads returns a dict, which sqlite stores as JSON string
row = db["example"].get(1) row = db.table("example").get(1)
assert row["data"] == '{"name": "test"}' assert row["data"] == '{"name": "test"}'

View file

@ -1,11 +1,13 @@
from sqlite_utils import cli, Database
from click.testing import CliRunner
import json import json
import pytest
import subprocess import subprocess
import sys import sys
import time import time
import pytest
from click.testing import CliRunner
from sqlite_utils import Database, cli
def test_insert_simple(tmpdir): def test_insert_simple(tmpdir):
json_path = str(tmpdir / "dog.json") json_path = str(tmpdir / "dog.json")
@ -19,7 +21,7 @@ def test_insert_simple(tmpdir):
) )
db = Database(db_path) db = Database(db_path)
assert ["dogs"] == db.table_names() assert ["dogs"] == db.table_names()
assert [] == db["dogs"].indexes assert [] == db.table("dogs").indexes
def test_insert_from_stdin(tmpdir): def test_insert_from_stdin(tmpdir):
@ -94,12 +96,12 @@ def test_insert_with_primary_keys(db_path, tmpdir, args, expected_pks):
Database(db_path).query("select * from dogs") Database(db_path).query("select * from dogs")
) )
db = Database(db_path) db = Database(db_path)
assert db["dogs"].pks == expected_pks assert db.table("dogs").pks == expected_pks
def test_insert_multiple_with_primary_key(db_path, tmpdir): def test_insert_multiple_with_primary_key(db_path, tmpdir):
json_path = str(tmpdir / "dogs.json") json_path = str(tmpdir / "dogs.json")
dogs = [{"id": i, "name": "Cleo {}".format(i), "age": i + 3} for i in range(1, 21)] dogs = [{"id": i, "name": f"Cleo {i}", "age": i + 3} for i in range(1, 21)]
with open(json_path, "w") as fp: with open(json_path, "w") as fp:
fp.write(json.dumps(dogs)) fp.write(json.dumps(dogs))
result = CliRunner().invoke( result = CliRunner().invoke(
@ -108,13 +110,13 @@ def test_insert_multiple_with_primary_key(db_path, tmpdir):
assert result.exit_code == 0 assert result.exit_code == 0
db = Database(db_path) db = Database(db_path)
assert dogs == list(db.query("select * from dogs order by id")) assert dogs == list(db.query("select * from dogs order by id"))
assert ["id"] == db["dogs"].pks assert ["id"] == db.table("dogs").pks
def test_insert_multiple_with_compound_primary_key(db_path, tmpdir): def test_insert_multiple_with_compound_primary_key(db_path, tmpdir):
json_path = str(tmpdir / "dogs.json") json_path = str(tmpdir / "dogs.json")
dogs = [ dogs = [
{"breed": "mixed", "id": i, "name": "Cleo {}".format(i), "age": i + 3} {"breed": "mixed", "id": i, "name": f"Cleo {i}", "age": i + 3}
for i in range(1, 21) for i in range(1, 21)
] ]
with open(json_path, "w") as fp: with open(json_path, "w") as fp:
@ -125,7 +127,7 @@ def test_insert_multiple_with_compound_primary_key(db_path, tmpdir):
assert result.exit_code == 0 assert result.exit_code == 0
db = Database(db_path) db = Database(db_path)
assert dogs == list(db.query("select * from dogs order by breed, id")) assert dogs == list(db.query("select * from dogs order by breed, id"))
assert {"breed", "id"} == set(db["dogs"].pks) assert {"breed", "id"} == set(db.table("dogs").pks)
assert ( assert (
'CREATE TABLE "dogs" (\n' 'CREATE TABLE "dogs" (\n'
' "breed" TEXT,\n' ' "breed" TEXT,\n'
@ -134,14 +136,13 @@ def test_insert_multiple_with_compound_primary_key(db_path, tmpdir):
' "age" INTEGER,\n' ' "age" INTEGER,\n'
' PRIMARY KEY ("id", "breed")\n' ' PRIMARY KEY ("id", "breed")\n'
")" ")"
) == db["dogs"].schema ) == db.table("dogs").schema
def test_insert_not_null_default(db_path, tmpdir): def test_insert_not_null_default(db_path, tmpdir):
json_path = str(tmpdir / "dogs.json") json_path = str(tmpdir / "dogs.json")
dogs = [ dogs = [
{"id": i, "name": "Cleo {}".format(i), "age": i + 3, "score": 10} {"id": i, "name": f"Cleo {i}", "age": i + 3, "score": 10} for i in range(1, 21)
for i in range(1, 21)
] ]
with open(json_path, "w") as fp: with open(json_path, "w") as fp:
fp.write(json.dumps(dogs)) fp.write(json.dumps(dogs))
@ -159,7 +160,7 @@ def test_insert_not_null_default(db_path, tmpdir):
' "name" TEXT NOT NULL,\n' ' "name" TEXT NOT NULL,\n'
" \"age\" INTEGER NOT NULL DEFAULT '1',\n" " \"age\" INTEGER NOT NULL DEFAULT '1',\n"
" \"score\" INTEGER DEFAULT '5'\n)" " \"score\" INTEGER DEFAULT '5'\n)"
) == db["dogs"].schema ) == db.table("dogs").schema
def test_insert_binary_base64(db_path): def test_insert_binary_base64(db_path):
@ -190,7 +191,7 @@ def test_insert_newline_delimited(db_path):
def test_insert_ignore(db_path, tmpdir): def test_insert_ignore(db_path, tmpdir):
db = Database(db_path) db = Database(db_path)
db["dogs"].insert({"id": 1, "name": "Cleo"}, pk="id") db.table("dogs").insert({"id": 1, "name": "Cleo"}, pk="id")
json_path = str(tmpdir / "dogs.json") json_path = str(tmpdir / "dogs.json")
with open(json_path, "w") as fp: with open(json_path, "w") as fp:
fp.write(json.dumps([{"id": 1, "name": "Bailey"}])) fp.write(json.dumps([{"id": 1, "name": "Bailey"}]))
@ -231,7 +232,7 @@ def test_insert_csv_tsv(content, options, db_path, tmpdir):
catch_exceptions=False, catch_exceptions=False,
) )
assert result.exit_code == 0 assert result.exit_code == 0
assert [{"foo": "1", "bar": "2", "baz": "cat,dog"}] == list(db["data"].rows) assert [{"foo": "1", "bar": "2", "baz": "cat,dog"}] == list(db.table("data").rows)
@pytest.mark.parametrize("empty_null", (True, False)) @pytest.mark.parametrize("empty_null", (True, False))
@ -247,7 +248,7 @@ def test_insert_csv_empty_null(db_path, empty_null):
) )
assert result.exit_code == 0 assert result.exit_code == 0
db = Database(db_path) db = Database(db_path)
assert [r for r in db["data"].rows] == [ assert [r for r in db.table("data").rows] == [
{"foo": "1", "bar": None if empty_null else "", "baz": "cat"} {"foo": "1", "bar": None if empty_null else "", "baz": "cat"}
] ]
@ -301,7 +302,7 @@ def test_insert_replace(db_path, tmpdir):
test_insert_multiple_with_primary_key(db_path, tmpdir) test_insert_multiple_with_primary_key(db_path, tmpdir)
json_path = str(tmpdir / "insert-replace.json") json_path = str(tmpdir / "insert-replace.json")
db = Database(db_path) db = Database(db_path)
assert db["dogs"].count == 20 assert db.table("dogs").count == 20
insert_replace_dogs = [ insert_replace_dogs = [
{"id": 1, "name": "Insert replaced 1", "age": 4}, {"id": 1, "name": "Insert replaced 1", "age": 4},
{"id": 2, "name": "Insert replaced 2", "age": 4}, {"id": 2, "name": "Insert replaced 2", "age": 4},
@ -313,7 +314,7 @@ def test_insert_replace(db_path, tmpdir):
cli.cli, ["insert", db_path, "dogs", json_path, "--pk", "id", "--replace"] cli.cli, ["insert", db_path, "dogs", json_path, "--pk", "id", "--replace"]
) )
assert result.exit_code == 0, result.output assert result.exit_code == 0, result.output
assert db["dogs"].count == 21 assert db.table("dogs").count == 21
assert ( assert (
list(db.query("select * from dogs where id in (1, 2, 21) order by id")) list(db.query("select * from dogs where id in (1, 2, 21) order by id"))
== insert_replace_dogs == insert_replace_dogs
@ -376,7 +377,7 @@ def test_insert_alter(db_path, tmpdir):
assert result.exit_code == 0, result.output assert result.exit_code == 0, result.output
# Soundness check the database itself # Soundness check the database itself
db = Database(db_path) db = Database(db_path)
assert {"foo": str, "n": int, "baz": int} == db["from_json_nl"].columns_dict assert {"foo": str, "n": int, "baz": int} == db.table("from_json_nl").columns_dict
assert [ assert [
{"foo": "bar", "n": 1, "baz": None}, {"foo": "bar", "n": 1, "baz": None},
{"foo": "baz", "n": 2, "baz": None}, {"foo": "baz", "n": 2, "baz": None},
@ -386,8 +387,8 @@ def test_insert_alter(db_path, tmpdir):
def test_insert_analyze(db_path): def test_insert_analyze(db_path):
db = Database(db_path) db = Database(db_path)
db["rows"].insert({"foo": "x", "n": 3}) db.table("rows").insert({"foo": "x", "n": 3})
db["rows"].create_index(["n"]) db.table("rows").create_index(["n"])
assert "sqlite_stat1" not in db.table_names() assert "sqlite_stat1" not in db.table_names()
result = CliRunner().invoke( result = CliRunner().invoke(
cli.cli, cli.cli,
@ -576,18 +577,19 @@ def test_insert_streaming_batch_size_1(db_path):
stdin=subprocess.PIPE, stdin=subprocess.PIPE,
stdout=sys.stdout, stdout=sys.stdout,
) )
assert proc.stdin is not None
proc.stdin.write(b'{"name": "Azi"}\n') proc.stdin.write(b'{"name": "Azi"}\n')
proc.stdin.flush() proc.stdin.flush()
def try_until(expected): def try_until(expected):
tries = 0 tries = 0
while True: while True:
rows = list(Database(db_path)["rows"].rows) rows = list(Database(db_path).table("rows").rows)
if rows == expected: if rows == expected:
return return
tries += 1 tries += 1
if tries > 10: if tries > 10:
assert False, "Expected {}, got {}".format(expected, rows) assert False, f"Expected {expected}, got {rows}"
time.sleep(tries * 0.1) time.sleep(tries * 0.1)
try_until([{"name": "Azi"}]) try_until([{"name": "Azi"}])
@ -614,13 +616,13 @@ def test_insert_csv_headers_only(tmpdir):
assert result.exit_code == 0 assert result.exit_code == 0
# Table should not exist since there were no data rows # Table should not exist since there were no data rows
db = Database(db_path) db = Database(db_path)
assert not db["data"].exists() assert not db.table("data").exists()
def test_insert_into_view_errors(tmpdir): def test_insert_into_view_errors(tmpdir):
db_path = str(tmpdir / "test.db") db_path = str(tmpdir / "test.db")
db = Database(db_path) db = Database(db_path)
db["t"].insert({"id": 1}) db.table("t").insert({"id": 1})
db.create_view("v", "select * from t") db.create_view("v", "select * from t")
db.close() db.close()
result = CliRunner().invoke( result = CliRunner().invoke(
@ -636,7 +638,7 @@ def test_insert_csv_detect_types_leaves_existing_table_alone(db_path):
# table would rewrite its column types and corrupt data such as # table would rewrite its column types and corrupt data such as
# TEXT zip codes with leading zeros # TEXT zip codes with leading zeros
db = Database(db_path) db = Database(db_path)
db["places"].insert({"name": "Boston", "zip": "01234"}) db.table("places").insert({"name": "Boston", "zip": "01234"})
result = CliRunner().invoke( result = CliRunner().invoke(
cli.cli, cli.cli,
["insert", db_path, "places", "-", "--csv"], ["insert", db_path, "places", "-", "--csv"],
@ -644,8 +646,8 @@ def test_insert_csv_detect_types_leaves_existing_table_alone(db_path):
input="name,zip\nSF,94107", input="name,zip\nSF,94107",
) )
assert result.exit_code == 0, result.output assert result.exit_code == 0, result.output
assert db["places"].columns_dict["zip"] is str assert db.table("places").columns_dict["zip"] is str
assert list(db["places"].rows) == [ assert list(db.table("places").rows) == [
{"name": "Boston", "zip": "01234"}, {"name": "Boston", "zip": "01234"},
{"name": "SF", "zip": "94107"}, {"name": "SF", "zip": "94107"},
] ]
@ -661,7 +663,7 @@ def test_insert_csv_detect_types_new_table(db_path):
) )
assert result.exit_code == 0, result.output assert result.exit_code == 0, result.output
db = Database(db_path) db = Database(db_path)
assert db["data"].columns_dict == {"name": str, "age": int, "weight": float} assert db.table("data").columns_dict == {"name": str, "age": int, "weight": float}
@pytest.mark.parametrize( @pytest.mark.parametrize(
@ -707,13 +709,13 @@ def test_insert_upsert_csv_type_overrides_detected_types(
expected_columns = {"zipcode": str, "score": float} expected_columns = {"zipcode": str, "score": float}
if command == "upsert": if command == "upsert":
expected_columns = {"id": int, **expected_columns} expected_columns = {"id": int, **expected_columns}
assert db["places"].columns_dict == expected_columns assert db.table("places").columns_dict == expected_columns
assert list(db["places"].rows) == [expected_row] assert list(db.table("places").rows) == [expected_row]
def test_upsert_csv_detect_types_leaves_existing_table_alone(db_path): def test_upsert_csv_detect_types_leaves_existing_table_alone(db_path):
db = Database(db_path) db = Database(db_path)
db["places"].insert({"id": 1, "name": "Boston", "zip": "01234"}, pk="id") db.table("places").insert({"id": 1, "name": "Boston", "zip": "01234"}, pk="id")
result = CliRunner().invoke( result = CliRunner().invoke(
cli.cli, cli.cli,
["upsert", db_path, "places", "-", "--csv", "--pk", "id"], ["upsert", db_path, "places", "-", "--csv", "--pk", "id"],
@ -721,15 +723,15 @@ def test_upsert_csv_detect_types_leaves_existing_table_alone(db_path):
input="id,name,zip\n2,SF,94107", input="id,name,zip\n2,SF,94107",
) )
assert result.exit_code == 0, result.output assert result.exit_code == 0, result.output
assert db["places"].columns_dict["zip"] is str assert db.table("places").columns_dict["zip"] is str
assert db["places"].get(1)["zip"] == "01234" assert db.table("places").get(1)["zip"] == "01234"
def test_insert_invalid_pk_clean_error(db_path): def test_insert_invalid_pk_clean_error(db_path):
# An invalid --pk against an existing table should be a clean CLI # An invalid --pk against an existing table should be a clean CLI
# error, not a raw InvalidColumns traceback # error, not a raw InvalidColumns traceback
db = Database(db_path) db = Database(db_path)
db["t"].insert({"a": 1}) db.table("t").insert({"a": 1})
result = CliRunner().invoke( result = CliRunner().invoke(
cli.cli, cli.cli,
["insert", db_path, "t", "-", "--pk", "badcol"], ["insert", db_path, "t", "-", "--pk", "badcol"],
@ -764,8 +766,8 @@ def test_insert_code(tmpdir, code):
) )
assert result.exit_code == 0, result.output assert result.exit_code == 0, result.output
db = Database(db_path) db = Database(db_path)
assert db["creatures"].pks == ["id"] assert db.table("creatures").pks == ["id"]
assert list(db["creatures"].rows) == [ assert list(db.table("creatures").rows) == [
{"id": 1, "name": "Cleo"}, {"id": 1, "name": "Cleo"},
{"id": 2, "name": "Suna"}, {"id": 2, "name": "Suna"},
] ]
@ -781,7 +783,7 @@ def test_insert_code_from_file(tmpdir):
["insert", db_path, "creatures", "--code", code_path], ["insert", db_path, "creatures", "--code", code_path],
) )
assert result.exit_code == 0, result.output assert result.exit_code == 0, result.output
assert list(Database(db_path)["creatures"].rows) == [ assert list(Database(db_path).table("creatures").rows) == [
{"id": 1, "name": "Cleo"}, {"id": 1, "name": "Cleo"},
{"id": 2, "name": "Suna"}, {"id": 2, "name": "Suna"},
] ]
@ -790,7 +792,7 @@ def test_insert_code_from_file(tmpdir):
def test_upsert_code(tmpdir): def test_upsert_code(tmpdir):
db_path = str(tmpdir / "dogs.db") db_path = str(tmpdir / "dogs.db")
db = Database(db_path) db = Database(db_path)
db["creatures"].insert_all( db.table("creatures").insert_all(
[{"id": 1, "name": "old"}, {"id": 2, "name": "Suna"}], pk="id" [{"id": 1, "name": "old"}, {"id": 2, "name": "Suna"}], pk="id"
) )
result = CliRunner().invoke( result = CliRunner().invoke(
@ -798,7 +800,7 @@ def test_upsert_code(tmpdir):
["upsert", db_path, "creatures", "--code", CODE_ROWS_FUNCTION, "--pk", "id"], ["upsert", db_path, "creatures", "--code", CODE_ROWS_FUNCTION, "--pk", "id"],
) )
assert result.exit_code == 0, result.output assert result.exit_code == 0, result.output
assert list(db["creatures"].rows) == [ assert list(db.table("creatures").rows) == [
{"id": 1, "name": "Cleo"}, {"id": 1, "name": "Cleo"},
{"id": 2, "name": "Suna"}, {"id": 2, "name": "Suna"},
] ]
@ -857,7 +859,9 @@ def test_insert_code_single_dict(tmpdir):
], ],
) )
assert result.exit_code == 0, result.output assert result.exit_code == 0, result.output
assert list(Database(db_path)["creatures"].rows) == [{"id": 1, "name": "Cleo"}] assert list(Database(db_path).table("creatures").rows) == [
{"id": 1, "name": "Cleo"}
]
def test_insert_code_not_iterable(tmpdir): def test_insert_code_not_iterable(tmpdir):

View file

@ -1,5 +1,6 @@
import click
import json import json
import click
import pytest import pytest
from click.testing import CliRunner from click.testing import CliRunner
@ -28,7 +29,7 @@ def test_memory_csv(tmpdir, sql_from, use_stdin):
fp.write(content) fp.write(content)
result = CliRunner().invoke( result = CliRunner().invoke(
cli.cli, cli.cli,
["memory", csv_path, "select * from {}".format(sql_from), "--nl"], ["memory", csv_path, f"select * from {sql_from}", "--nl"],
input=input, input=input,
) )
assert result.exit_code == 0 assert result.exit_code == 0
@ -53,7 +54,7 @@ def test_memory_tsv(tmpdir, use_stdin):
sql_from = "chickens" sql_from = "chickens"
result = CliRunner().invoke( result = CliRunner().invoke(
cli.cli, cli.cli,
["memory", path, "select * from {}".format(sql_from)], ["memory", path, f"select * from {sql_from}"],
input=input, input=input,
) )
assert result.exit_code == 0, result.output assert result.exit_code == 0, result.output
@ -79,7 +80,7 @@ def test_memory_json(tmpdir, use_stdin):
sql_from = "chickens" sql_from = "chickens"
result = CliRunner().invoke( result = CliRunner().invoke(
cli.cli, cli.cli,
["memory", path, "select * from {}".format(sql_from)], ["memory", path, f"select * from {sql_from}"],
input=input, input=input,
) )
assert result.exit_code == 0, result.output assert result.exit_code == 0, result.output
@ -105,7 +106,7 @@ def test_memory_json_nl(tmpdir, use_stdin):
sql_from = "chickens" sql_from = "chickens"
result = CliRunner().invoke( result = CliRunner().invoke(
cli.cli, cli.cli,
["memory", path, "select * from {}".format(sql_from)], ["memory", path, f"select * from {sql_from}"],
input=input, input=input,
) )
assert result.exit_code == 0, result.output assert result.exit_code == 0, result.output
@ -135,7 +136,7 @@ def test_memory_csv_encoding(tmpdir, use_stdin):
CliRunner() CliRunner()
.invoke( .invoke(
cli.cli, cli.cli,
["memory", csv_path, "select * from {}".format(sql_from), "--nl"], ["memory", csv_path, f"select * from {sql_from}", "--nl"],
input=input, input=input,
) )
.exit_code .exit_code
@ -227,7 +228,7 @@ def test_memory_save(tmpdir, extra_args):
) )
assert result.exit_code == 0 assert result.exit_code == 0
db = Database(save_to) db = Database(save_to)
assert list(db["stdin"].rows) == [ assert list(db.table("stdin").rows) == [
{"id": 1, "name": "Cleo"}, {"id": 1, "name": "Cleo"},
{"id": 2, "name": "Bants"}, {"id": 2, "name": "Bants"},
] ]

View file

@ -1,7 +1,8 @@
import pathlib import pathlib
from click.testing import CliRunner
import pytest import pytest
from click.testing import CliRunner
import sqlite_utils import sqlite_utils
import sqlite_utils.cli import sqlite_utils.cli
@ -12,11 +13,11 @@ m = Migrations("hello")
@m() @m()
def foo(db): def foo(db):
db["foo"].insert({"hello": "world"}) db.table("foo").insert({"hello": "world"})
@m() @m()
def bar(db): def bar(db):
db["bar"].insert({"hello": "world"}) db.table("bar").insert({"hello": "world"})
""" """
@ -41,21 +42,21 @@ creatures = Migrations("creatures")
@creatures() @creatures()
def create_table(db): def create_table(db):
db["creatures"].insert({"name": "Cleo"}) db.table("creatures").insert({"name": "Cleo"})
@creatures() @creatures()
def add_weight(db): def add_weight(db):
db["creature_weights"].insert({"weight": 4.2}) db.table("creature_weights").insert({"weight": 4.2})
sales = Migrations("sales") sales = Migrations("sales")
@sales() @sales()
def create_table(db): def create_table(db):
db["sales"].insert({"id": 1}) db.table("sales").insert({"id": 1})
@sales() @sales()
def add_weight(db): def add_weight(db):
db["sales_weights"].insert({"weight": 10}) db.table("sales_weights").insert({"weight": 10})
""", """,
"utf-8", "utf-8",
) )
@ -98,10 +99,10 @@ def test_basic(two_migrations, arg):
assert " Pending:\n (none)" in list_output assert " Pending:\n (none)" in list_output
db = sqlite_utils.Database(db_path) db = sqlite_utils.Database(db_path)
assert db["foo"].exists() assert db.table("foo").exists()
assert db["bar"].exists() assert db.table("bar").exists()
assert db["_sqlite_migrations"].exists() assert db.table("_sqlite_migrations").exists()
rows = list(db["_sqlite_migrations"].rows) rows = list(db.table("_sqlite_migrations").rows)
assert len(rows) == 2 assert len(rows) == 2
assert rows[0]["name"] == "foo" assert rows[0]["name"] == "foo"
assert rows[1]["name"] == "bar" assert rows[1]["name"] == "bar"
@ -112,13 +113,13 @@ def test_list_same_migration_names_in_different_sets(capsys):
@applied(name="foo") @applied(name="foo")
def applied_foo(db): def applied_foo(db):
db["applied"].insert({"hello": "world"}) db.table("applied").insert({"hello": "world"})
pending = sqlite_utils.Migrations("pending") pending = sqlite_utils.Migrations("pending")
@pending(name="foo") @pending(name="foo")
def pending_foo(db): def pending_foo(db):
db["pending"].insert({"hello": "world"}) db.table("pending").insert({"hello": "world"})
db = sqlite_utils.Database(memory=True) db = sqlite_utils.Database(memory=True)
applied.apply(db) applied.apply(db)
@ -143,7 +144,7 @@ m = Migrations("hello")
@m() @m()
def foo(db): def foo(db):
db["dogs"].insert({"id": 1, "name": "Cleo"}) db.table("dogs").insert({"id": 1, "name": "Cleo"})
""", """,
"utf-8", "utf-8",
) )
@ -183,9 +184,9 @@ Schema after:
new_migration = """ new_migration = """
@m() @m()
def bar(db): def bar(db):
db["dogs"].add_column("age", int) db.table("dogs").add_column("age", int)
db["dogs"].add_column("weight", float) db.table("dogs").add_column("weight", float)
db["dogs"].transform() db.table("dogs").transform()
""" """
migrations_py.write_text(migrations_py.read_text("utf-8") + new_migration) migrations_py.write_text(migrations_py.read_text("utf-8") + new_migration)
@ -223,8 +224,8 @@ def test_stop_before(two_migrations):
) )
assert result.exit_code == 0 assert result.exit_code == 0
db = sqlite_utils.Database(db_path) db = sqlite_utils.Database(db_path)
assert db["foo"].exists() assert db.table("foo").exists()
assert not db["bar"].exists() assert not db.table("bar").exists()
def test_stop_before_multiple_sets_unqualified(two_migrations): def test_stop_before_multiple_sets_unqualified(two_migrations):
@ -238,7 +239,7 @@ m = Migrations("hello2")
@m() @m()
def foo(db): def foo(db):
db["foo"].insert({"hello": "world"}) db.table("foo").insert({"hello": "world"})
""", """,
"utf-8", "utf-8",
) )
@ -256,7 +257,7 @@ def foo(db):
assert result.exit_code == 0, result.output assert result.exit_code == 0, result.output
db = sqlite_utils.Database(db_path) db = sqlite_utils.Database(db_path)
assert db.table_names() == ["_sqlite_migrations"] assert db.table_names() == ["_sqlite_migrations"]
assert list(db["_sqlite_migrations"].rows) == [] assert list(db.table("_sqlite_migrations").rows) == []
def test_stop_before_qualified_only_affects_named_set(two_sets_same_migration_name): def test_stop_before_qualified_only_affects_named_set(two_sets_same_migration_name):
@ -274,10 +275,10 @@ def test_stop_before_qualified_only_affects_named_set(two_sets_same_migration_na
) )
assert result.exit_code == 0, result.output assert result.exit_code == 0, result.output
db = sqlite_utils.Database(db_path) db = sqlite_utils.Database(db_path)
assert db["creatures"].exists() assert db.table("creatures").exists()
assert not db["creature_weights"].exists() assert not db.table("creature_weights").exists()
assert db["sales"].exists() assert db.table("sales").exists()
assert db["sales_weights"].exists() assert db.table("sales_weights").exists()
def test_stop_before_multiple_qualified(two_sets_same_migration_name): def test_stop_before_multiple_qualified(two_sets_same_migration_name):
@ -297,10 +298,10 @@ def test_stop_before_multiple_qualified(two_sets_same_migration_name):
) )
assert result.exit_code == 0, result.output assert result.exit_code == 0, result.output
db = sqlite_utils.Database(db_path) db = sqlite_utils.Database(db_path)
assert db["creatures"].exists() assert db.table("creatures").exists()
assert not db["creature_weights"].exists() assert not db.table("creature_weights").exists()
assert db["sales"].exists() assert db.table("sales").exists()
assert not db["sales_weights"].exists() assert not db.table("sales_weights").exists()
LEGACY_MIGRATIONS = """ LEGACY_MIGRATIONS = """
@ -330,7 +331,7 @@ class LegacyMigrations:
return fn return fn
def ensure_migrations_table(self, db): def ensure_migrations_table(self, db):
db[self.migrations_table].create( db.table(self.migrations_table).create(
{"migration_set": str, "name": str, "applied_at": str}, {"migration_set": str, "name": str, "applied_at": str},
pk=("migration_set", "name"), pk=("migration_set", "name"),
if_not_exists=True, if_not_exists=True,
@ -340,7 +341,7 @@ class LegacyMigrations:
self.ensure_migrations_table(db) self.ensure_migrations_table(db)
return [ return [
_Applied(row["name"], row["applied_at"]) _Applied(row["name"], row["applied_at"])
for row in db[self.migrations_table].rows_where( for row in db.table(self.migrations_table).rows_where(
"migration_set = ?", [self.name] "migration_set = ?", [self.name]
) )
] ]
@ -354,7 +355,7 @@ class LegacyMigrations:
if migration.name == stop_before: if migration.name == stop_before:
return return
migration.fn(db) migration.fn(db)
db[self.migrations_table].insert( db.table(self.migrations_table).insert(
{ {
"migration_set": self.name, "migration_set": self.name,
"name": migration.name, "name": migration.name,
@ -368,11 +369,11 @@ legacy = LegacyMigrations("legacy_set")
@legacy @legacy
def first(db): def first(db):
db["first"].insert({"hello": "world"}) db.table("first").insert({"hello": "world"})
@legacy @legacy
def second(db): def second(db):
db["second"].insert({"hello": "world"}) db.table("second").insert({"hello": "world"})
""" """
@ -445,11 +446,11 @@ def test_list_does_not_upgrade_legacy_migrations_table(two_migrations):
path, _ = two_migrations path, _ = two_migrations
db_path = str(path / "test.db") db_path = str(path / "test.db")
db = sqlite_utils.Database(db_path) db = sqlite_utils.Database(db_path)
db["_sqlite_migrations"].create( db.table("_sqlite_migrations").create(
{"migration_set": str, "name": str, "applied_at": str}, {"migration_set": str, "name": str, "applied_at": str},
pk=("migration_set", "name"), pk=("migration_set", "name"),
) )
db["_sqlite_migrations"].insert( db.table("_sqlite_migrations").insert(
{"migration_set": "hello", "name": "foo", "applied_at": "x"} {"migration_set": "hello", "name": "foo", "applied_at": "x"}
) )
db.close() db.close()
@ -461,7 +462,7 @@ def test_list_does_not_upgrade_legacy_migrations_table(two_migrations):
assert "foo - x" in result.output assert "foo - x" in result.output
# --list must not perform the one-way legacy schema upgrade # --list must not perform the one-way legacy schema upgrade
db2 = sqlite_utils.Database(db_path) db2 = sqlite_utils.Database(db_path)
assert db2["_sqlite_migrations"].pks == ["migration_set", "name"] assert db2.table("_sqlite_migrations").pks == ["migration_set", "name"]
db2.close() db2.close()
@ -484,7 +485,7 @@ def test_stop_before_applied_migration_errors(two_migrations):
assert result.exit_code != 0 assert result.exit_code != 0
assert "already been applied" in result.output assert "already been applied" in result.output
db = sqlite_utils.Database(db_path) db = sqlite_utils.Database(db_path)
assert not db["bar"].exists() assert not db.table("bar").exists()
def test_list_with_legacy_class_is_read_only(tmpdir): def test_list_with_legacy_class_is_read_only(tmpdir):
@ -495,7 +496,7 @@ def test_list_with_legacy_class_is_read_only(tmpdir):
(path / "migrations.py").write_text(LEGACY_MIGRATIONS, "utf-8") (path / "migrations.py").write_text(LEGACY_MIGRATIONS, "utf-8")
db_path = str(path / "test.db") db_path = str(path / "test.db")
db = sqlite_utils.Database(db_path) db = sqlite_utils.Database(db_path)
db["existing"].insert({"id": 1}) db.table("existing").insert({"id": 1})
db.close() db.close()
result = CliRunner().invoke( result = CliRunner().invoke(
sqlite_utils.cli.cli, ["migrate", db_path, str(path), "--list"] sqlite_utils.cli.cli, ["migrate", db_path, str(path), "--list"]

View file

@ -1,4 +1,6 @@
import pytest import pytest
from sqlite_utils import ANY
from sqlite_utils.utils import column_affinity from sqlite_utils.utils import column_affinity
EXAMPLES = [ EXAMPLES = [
@ -25,6 +27,8 @@ EXAMPLES = [
("DOUBLE", float), ("DOUBLE", float),
("DOUBLE PRECISION", float), ("DOUBLE PRECISION", float),
("FLOAT", float), ("FLOAT", float),
("ANY", ANY),
("any", ANY),
# Numeric, treated as float: # Numeric, treated as float:
("NUMERIC", float), ("NUMERIC", float),
("DECIMAL(10,5)", float), ("DECIMAL(10,5)", float),
@ -41,5 +45,5 @@ def test_column_affinity(column_def, expected_type):
@pytest.mark.parametrize("column_def,expected_type", EXAMPLES) @pytest.mark.parametrize("column_def,expected_type", EXAMPLES)
def test_columns_dict(fresh_db, column_def, expected_type): def test_columns_dict(fresh_db, column_def, expected_type):
fresh_db.execute("create table foo (col {})".format(column_def)) fresh_db.execute(f"create table foo (col {column_def})")
assert {"col": expected_type} == fresh_db["foo"].columns_dict assert {"col": expected_type} == fresh_db.table("foo").columns_dict

View file

@ -13,14 +13,14 @@ from sqlite_utils.db import ForeignKey
def test_insert_populates_last_pk_case_insensitively(fresh_db): def test_insert_populates_last_pk_case_insensitively(fresh_db):
books = fresh_db["books"] books = fresh_db.table("books")
books.create({"Id": int, "Title": str}, pk="Id") books.create({"Id": int, "Title": str}, pk="Id")
books.insert({"Id": 1, "Title": "One"}, pk="id") books.insert({"Id": 1, "Title": "One"}, pk="id")
assert books.last_pk == 1 assert books.last_pk == 1
def test_insert_populates_last_pk_compound_pk_case_insensitively(fresh_db): def test_insert_populates_last_pk_compound_pk_case_insensitively(fresh_db):
books = fresh_db["books"] books = fresh_db.table("books")
books.create({"Author": str, "Position": int, "Title": str}) books.create({"Author": str, "Position": int, "Title": str})
books.insert( books.insert(
{"Author": "Sue", "Position": 1, "Title": "One"}, pk=("author", "position") {"Author": "Sue", "Position": 1, "Title": "One"}, pk=("author", "position")
@ -31,7 +31,7 @@ def test_insert_populates_last_pk_compound_pk_case_insensitively(fresh_db):
@pytest.mark.parametrize("use_old_upsert", (False, True)) @pytest.mark.parametrize("use_old_upsert", (False, True))
def test_upsert_pk_case_differs_from_schema(use_old_upsert): def test_upsert_pk_case_differs_from_schema(use_old_upsert):
db = Database(memory=True, use_old_upsert=use_old_upsert) db = Database(memory=True, use_old_upsert=use_old_upsert)
books = db["books"] books = db.table("books")
books.create({"Id": int, "Title": str}, pk="Id") books.create({"Id": int, "Title": str}, pk="Id")
books.insert({"Id": 1, "Title": "One"}) books.insert({"Id": 1, "Title": "One"})
books.upsert({"id": 1, "title": "Won"}, pk="id") books.upsert({"id": 1, "title": "Won"}, pk="id")
@ -43,7 +43,7 @@ def test_upsert_pk_case_differs_from_schema(use_old_upsert):
def test_upsert_record_key_case_differs_from_pk(use_old_upsert): def test_upsert_record_key_case_differs_from_pk(use_old_upsert):
# all_columns comes from the record keys, pk= from the caller # all_columns comes from the record keys, pk= from the caller
db = Database(memory=True, use_old_upsert=use_old_upsert) db = Database(memory=True, use_old_upsert=use_old_upsert)
books = db["books"] books = db.table("books")
books.create({"Id": int, "Title": str}, pk="Id") books.create({"Id": int, "Title": str}, pk="Id")
books.upsert({"ID": 1, "Title": "One"}, pk="id") books.upsert({"ID": 1, "Title": "One"}, pk="id")
assert list(books.rows) == [{"Id": 1, "Title": "One"}] assert list(books.rows) == [{"Id": 1, "Title": "One"}]
@ -52,7 +52,7 @@ def test_upsert_record_key_case_differs_from_pk(use_old_upsert):
def test_upsert_inferred_pk_case_differs_from_record_keys(fresh_db): def test_upsert_inferred_pk_case_differs_from_record_keys(fresh_db):
# pk is inferred from the existing schema as "Id", records use "id" # pk is inferred from the existing schema as "Id", records use "id"
books = fresh_db["books"] books = fresh_db.table("books")
books.create({"Id": int, "Title": str}, pk="Id") books.create({"Id": int, "Title": str}, pk="Id")
books.upsert({"id": 1, "title": "One"}) books.upsert({"id": 1, "title": "One"})
assert list(books.rows) == [{"Id": 1, "Title": "One"}] assert list(books.rows) == [{"Id": 1, "Title": "One"}]
@ -60,7 +60,7 @@ def test_upsert_inferred_pk_case_differs_from_record_keys(fresh_db):
def test_upsert_list_mode_pk_case_insensitive(fresh_db): def test_upsert_list_mode_pk_case_insensitive(fresh_db):
books = fresh_db["books"] books = fresh_db.table("books")
books.create({"Id": int, "Title": str}, pk="Id") books.create({"Id": int, "Title": str}, pk="Id")
books.upsert_all([["id", "title"], [1, "One"]], pk="Id") books.upsert_all([["id", "title"], [1, "One"]], pk="Id")
assert list(books.rows) == [{"Id": 1, "Title": "One"}] assert list(books.rows) == [{"Id": 1, "Title": "One"}]
@ -68,84 +68,84 @@ def test_upsert_list_mode_pk_case_insensitive(fresh_db):
def test_lookup_pk_case_insensitive(fresh_db): def test_lookup_pk_case_insensitive(fresh_db):
fresh_db["species"].create({"ID": int, "Name": str}, pk="ID") fresh_db.table("species").create({"ID": int, "Name": str}, pk="ID")
fresh_db["species"].insert({"ID": 5, "Name": "Palm"}) fresh_db.table("species").insert({"ID": 5, "Name": "Palm"})
fresh_db["species"].create_index(["Name"], unique=True) fresh_db.table("species").create_index(["Name"], unique=True)
assert fresh_db["species"].lookup({"Name": "Palm"}, pk="id") == 5 assert fresh_db.table("species").lookup({"Name": "Palm"}, pk="id") == 5
def test_lookup_does_not_create_redundant_index(fresh_db): def test_lookup_does_not_create_redundant_index(fresh_db):
fresh_db["species"].create({"id": int, "Name": str}, pk="id") fresh_db.table("species").create({"id": int, "Name": str}, pk="id")
fresh_db["species"].create_index(["Name"], unique=True) fresh_db.table("species").create_index(["Name"], unique=True)
fresh_db["species"].lookup({"name": "Palm"}) fresh_db.table("species").lookup({"name": "Palm"})
assert len(fresh_db["species"].indexes) == 1 assert len(fresh_db.table("species").indexes) == 1
def test_create_table_transform_same_columns_different_case(fresh_db): def test_create_table_transform_same_columns_different_case(fresh_db):
fresh_db["t"].create({"Name": str, "Age": int}) fresh_db.table("t").create({"Name": str, "Age": int})
fresh_db["t"].insert({"Name": "Cleo", "Age": 5}) fresh_db.table("t").insert({"Name": "Cleo", "Age": 5})
fresh_db.create_table("t", {"name": str, "age": int}, transform=True) fresh_db.create_table("t", {"name": str, "age": int}, transform=True)
# Schema casing is preserved - SQLite considers these the same columns # Schema casing is preserved - SQLite considers these the same columns
assert fresh_db["t"].columns_dict == {"Name": str, "Age": int} assert fresh_db.table("t").columns_dict == {"Name": str, "Age": int}
assert list(fresh_db["t"].rows) == [{"Name": "Cleo", "Age": 5}] assert list(fresh_db.table("t").rows) == [{"Name": "Cleo", "Age": 5}]
def test_create_table_transform_case_insensitive_with_changes(fresh_db): def test_create_table_transform_case_insensitive_with_changes(fresh_db):
fresh_db["t"].create({"Name": str, "Age": int}) fresh_db.table("t").create({"Name": str, "Age": int})
fresh_db.create_table("t", {"name": str, "age": str, "size": int}, transform=True) fresh_db.create_table("t", {"name": str, "age": str, "size": int}, transform=True)
# age changed type, size added, Name untouched # age changed type, size added, Name untouched
assert fresh_db["t"].columns_dict == {"Name": str, "Age": str, "size": int} assert fresh_db.table("t").columns_dict == {"Name": str, "Age": str, "size": int}
def test_transform_types_case_insensitive(fresh_db): def test_transform_types_case_insensitive(fresh_db):
fresh_db["t"].create({"Name": str, "Age": str}) fresh_db.table("t").create({"Name": str, "Age": str})
fresh_db["t"].transform(types={"age": int}) fresh_db.table("t").transform(types={"age": int})
assert fresh_db["t"].columns_dict == {"Name": str, "Age": int} assert fresh_db.table("t").columns_dict == {"Name": str, "Age": int}
def test_transform_rename_case_insensitive(fresh_db): def test_transform_rename_case_insensitive(fresh_db):
fresh_db["t"].create({"Name": str}) fresh_db.table("t").create({"Name": str})
fresh_db["t"].transform(rename={"name": "title"}) fresh_db.table("t").transform(rename={"name": "title"})
assert fresh_db["t"].columns_dict == {"title": str} assert fresh_db.table("t").columns_dict == {"title": str}
def test_transform_drop_case_insensitive(fresh_db): def test_transform_drop_case_insensitive(fresh_db):
fresh_db["t"].create({"Name": str, "Age": int}) fresh_db.table("t").create({"Name": str, "Age": int})
fresh_db["t"].transform(drop=["name"]) fresh_db.table("t").transform(drop=["name"])
assert fresh_db["t"].columns_dict == {"Age": int} assert fresh_db.table("t").columns_dict == {"Age": int}
def test_transform_not_null_and_defaults_case_insensitive(fresh_db): def test_transform_not_null_and_defaults_case_insensitive(fresh_db):
fresh_db["t"].create({"Name": str, "Age": int}) fresh_db.table("t").create({"Name": str, "Age": int})
fresh_db["t"].transform(not_null={"name"}, defaults={"age": 3}) fresh_db.table("t").transform(not_null={"name"}, defaults={"age": 3})
columns = {c.name: c for c in fresh_db["t"].columns} columns = {c.name: c for c in fresh_db.table("t").columns}
assert columns["Name"].notnull assert columns["Name"].notnull
assert fresh_db["t"].default_values == {"Age": 3} assert fresh_db.table("t").default_values == {"Age": 3}
def test_transform_pk_case_insensitive(fresh_db): def test_transform_pk_case_insensitive(fresh_db):
fresh_db["t"].create({"Id": int, "Name": str}) fresh_db.table("t").create({"Id": int, "Name": str})
fresh_db["t"].transform(pk="id") fresh_db.table("t").transform(pk="id")
assert fresh_db["t"].pks == ["Id"] assert fresh_db.table("t").pks == ["Id"]
assert fresh_db["t"].columns_dict == {"Id": int, "Name": str} assert fresh_db.table("t").columns_dict == {"Id": int, "Name": str}
def test_transform_drop_foreign_keys_case_insensitive(fresh_db): def test_transform_drop_foreign_keys_case_insensitive(fresh_db):
fresh_db["parent"].create({"Id": int}, pk="Id") fresh_db.table("parent").create({"Id": int}, pk="Id")
fresh_db["child"].create( fresh_db.table("child").create(
{"id": int, "Parent_ID": int}, {"id": int, "Parent_ID": int},
pk="id", pk="id",
foreign_keys=[("Parent_ID", "parent", "Id")], foreign_keys=[("Parent_ID", "parent", "Id")],
) )
fresh_db["child"].transform(drop_foreign_keys=["parent_id"]) fresh_db.table("child").transform(drop_foreign_keys=["parent_id"])
assert fresh_db["child"].foreign_keys == [] assert fresh_db.table("child").foreign_keys == []
def test_add_foreign_key_case_insensitive(fresh_db): def test_add_foreign_key_case_insensitive(fresh_db):
fresh_db["parent"].create({"Id": int}, pk="Id") fresh_db.table("parent").create({"Id": int}, pk="Id")
fresh_db["child"].create({"id": int, "Parent_ID": int}, pk="id") fresh_db.table("child").create({"id": int, "Parent_ID": int}, pk="id")
fresh_db["child"].add_foreign_key("parent_id", "parent", "id") fresh_db.table("child").add_foreign_key("parent_id", "parent", "id")
fks = fresh_db["child"].foreign_keys fks = fresh_db.table("child").foreign_keys
assert len(fks) == 1 assert len(fks) == 1
# The foreign key should use the schema casing of the columns # The foreign key should use the schema casing of the columns
assert fks[0].column == "Parent_ID" assert fks[0].column == "Parent_ID"
@ -153,79 +153,83 @@ def test_add_foreign_key_case_insensitive(fresh_db):
def test_add_foreign_keys_case_insensitive(fresh_db): def test_add_foreign_keys_case_insensitive(fresh_db):
fresh_db["parent"].create({"Id": int}, pk="Id") fresh_db.table("parent").create({"Id": int}, pk="Id")
fresh_db["child"].create({"id": int, "Parent_ID": int}, pk="id") fresh_db.table("child").create({"id": int, "Parent_ID": int}, pk="id")
fresh_db.add_foreign_keys([("child", "parent_id", "parent", "id")]) fresh_db.add_foreign_keys([("child", "parent_id", "parent", "id")])
fks = fresh_db["child"].foreign_keys fks = fresh_db.table("child").foreign_keys
assert len(fks) == 1 assert len(fks) == 1
assert fks[0].column == "Parent_ID" assert fks[0].column == "Parent_ID"
assert fks[0].other_column == "Id" assert fks[0].other_column == "Id"
def test_add_foreign_key_detects_existing_case_insensitively(fresh_db): def test_add_foreign_key_detects_existing_case_insensitively(fresh_db):
fresh_db["parent"].create({"Id": int}, pk="Id") fresh_db.table("parent").create({"Id": int}, pk="Id")
fresh_db["child"].create( fresh_db.table("child").create(
{"id": int, "Parent_ID": int}, {"id": int, "Parent_ID": int},
pk="id", pk="id",
foreign_keys=[("Parent_ID", "parent", "Id")], foreign_keys=[("Parent_ID", "parent", "Id")],
) )
# ignore=True should treat this as already existing, not add a duplicate # ignore=True should treat this as already existing, not add a duplicate
fresh_db["child"].add_foreign_key("parent_id", "parent", "id", ignore=True) fresh_db.table("child").add_foreign_key("parent_id", "parent", "id", ignore=True)
assert len(fresh_db["child"].foreign_keys) == 1 assert len(fresh_db.table("child").foreign_keys) == 1
def test_add_column_fk_col_case_insensitive(fresh_db): def test_add_column_fk_col_case_insensitive(fresh_db):
fresh_db["parent"].create({"Id": int}, pk="Id") fresh_db.table("parent").create({"Id": int}, pk="Id")
fresh_db["child"].create({"id": int}, pk="id") fresh_db.table("child").create({"id": int}, pk="id")
fresh_db["child"].add_column("parent_id", int, fk="parent", fk_col="id") fresh_db.table("child").add_column("parent_id", int, fk="parent", fk_col="id")
fks = fresh_db["child"].foreign_keys fks = fresh_db.table("child").foreign_keys
assert len(fks) == 1 assert len(fks) == 1
assert fks[0].other_column == "Id" assert fks[0].other_column == "Id"
def test_extract_case_insensitive(fresh_db): def test_extract_case_insensitive(fresh_db):
fresh_db["trees"].insert({"id": 1, "Species": "Palm"}, pk="id") fresh_db.table("trees").insert({"id": 1, "Species": "Palm"}, pk="id")
fresh_db["trees"].extract("species") fresh_db.table("trees").extract("species")
assert fresh_db["trees"].columns_dict == {"id": int, "Species_id": int} assert fresh_db.table("trees").columns_dict == {"id": int, "Species_id": int}
assert list(fresh_db["Species"].rows) == [{"id": 1, "Species": "Palm"}] assert list(fresh_db.table("Species").rows) == [{"id": 1, "Species": "Palm"}]
def test_convert_multi_case_insensitive(fresh_db): def test_convert_multi_case_insensitive(fresh_db):
fresh_db["t"].insert({"id": 1, "Name": "Cleo"}, pk="id") fresh_db.table("t").insert({"id": 1, "Name": "Cleo"}, pk="id")
fresh_db["t"].convert("name", lambda v: {"upper": v.upper()}, multi=True) fresh_db.table("t").convert("name", lambda v: {"upper": v.upper()}, multi=True)
assert list(fresh_db["t"].rows) == [{"id": 1, "Name": "Cleo", "upper": "CLEO"}] assert list(fresh_db.table("t").rows) == [
{"id": 1, "Name": "Cleo", "upper": "CLEO"}
]
def test_convert_output_case_insensitive(fresh_db): def test_convert_output_case_insensitive(fresh_db):
fresh_db["t"].insert({"id": 1, "Name": "Cleo", "Upper": None}, pk="id") fresh_db.table("t").insert({"id": 1, "Name": "Cleo", "Upper": None}, pk="id")
fresh_db["t"].convert("name", lambda v: v.upper(), output="upper") fresh_db.table("t").convert("name", lambda v: v.upper(), output="upper")
assert list(fresh_db["t"].rows) == [{"id": 1, "Name": "Cleo", "Upper": "CLEO"}] assert list(fresh_db.table("t").rows) == [
{"id": 1, "Name": "Cleo", "Upper": "CLEO"}
]
def test_create_table_sql_pk_case_insensitive(fresh_db): def test_create_table_sql_pk_case_insensitive(fresh_db):
fresh_db["t"].create({"Id": int, "Name": str}, pk="id") fresh_db.table("t").create({"Id": int, "Name": str}, pk="id")
# Should not have created an extra lowercase "id" column # Should not have created an extra lowercase "id" column
assert fresh_db["t"].columns_dict == {"Id": int, "Name": str} assert fresh_db.table("t").columns_dict == {"Id": int, "Name": str}
assert fresh_db["t"].pks == ["Id"] assert fresh_db.table("t").pks == ["Id"]
def test_create_table_not_null_and_defaults_case_insensitive(fresh_db): def test_create_table_not_null_and_defaults_case_insensitive(fresh_db):
fresh_db["t"].create( fresh_db.table("t").create(
{"Name": str, "Age": int}, not_null={"name"}, defaults={"age": 1} {"Name": str, "Age": int}, not_null={"name"}, defaults={"age": 1}
) )
columns = {c.name: c for c in fresh_db["t"].columns} columns = {c.name: c for c in fresh_db.table("t").columns}
assert columns["Name"].notnull assert columns["Name"].notnull
assert fresh_db["t"].default_values == {"Age": 1} assert fresh_db.table("t").default_values == {"Age": 1}
def test_create_table_foreign_keys_case_insensitive(fresh_db): def test_create_table_foreign_keys_case_insensitive(fresh_db):
fresh_db["parent"].create({"Id": int}, pk="Id") fresh_db.table("parent").create({"Id": int}, pk="Id")
fresh_db["child"].create( fresh_db.table("child").create(
{"id": int, "Parent_ID": int}, {"id": int, "Parent_ID": int},
pk="id", pk="id",
foreign_keys=[("parent_id", "parent", "id")], foreign_keys=[("parent_id", "parent", "id")],
) )
fks = fresh_db["child"].foreign_keys fks = fresh_db.table("child").foreign_keys
assert fks == [ assert fks == [
ForeignKey( ForeignKey(
table="child", column="Parent_ID", other_table="parent", other_column="Id" table="child", column="Parent_ID", other_table="parent", other_column="Id"

View file

@ -1,8 +1,10 @@
import sys
import pytest
from sqlite_utils import Database from sqlite_utils import Database
from sqlite_utils.db import TransactionError from sqlite_utils.db import TransactionError
from sqlite_utils.utils import sqlite3 from sqlite_utils.utils import sqlite3
import pytest
import sys
def test_recursive_triggers(): def test_recursive_triggers():
@ -18,8 +20,8 @@ def test_recursive_triggers_off():
def test_memory_name(): def test_memory_name():
db1 = Database(memory_name="shared") db1 = Database(memory_name="shared")
db2 = Database(memory_name="shared") db2 = Database(memory_name="shared")
db1["dogs"].insert({"name": "Cleo"}) db1.table("dogs").insert({"name": "Cleo"})
assert list(db2["dogs"].rows) == [{"name": "Cleo"}] assert list(db2.table("dogs").rows) == [{"name": "Cleo"}]
def test_sqlite_version(): def test_sqlite_version():
@ -34,7 +36,7 @@ def test_sqlite_version():
def test_database_context_manager(tmpdir): def test_database_context_manager(tmpdir):
path = str(tmpdir / "test.db") path = str(tmpdir / "test.db")
with Database(path) as db: with Database(path) as db:
db["t"].insert({"id": 1}) db.table("t").insert({"id": 1})
# Raw writes commit automatically too # Raw writes commit automatically too
db.execute("insert into t (id) values (2)") db.execute("insert into t (id) values (2)")
# An explicitly opened transaction left uncommitted on purpose: # An explicitly opened transaction left uncommitted on purpose:
@ -45,7 +47,7 @@ def test_database_context_manager(tmpdir):
db.execute("select 1") db.execute("select 1")
# ... and the open explicit transaction was rolled back, not committed # ... and the open explicit transaction was rolled back, not committed
db2 = Database(path) db2 = Database(path)
assert [r["id"] for r in db2["t"].rows] == [1, 2] assert [r["id"] for r in db2.table("t").rows] == [1, 2]
db2.close() db2.close()
@ -81,11 +83,12 @@ def test_autocommit_connections_are_rejected(tmpdir, autocommit):
) )
def test_legacy_transaction_control_connection_is_accepted(tmpdir): def test_legacy_transaction_control_connection_is_accepted(tmpdir):
conn = sqlite3.connect( conn = sqlite3.connect(
str(tmpdir / "test.db"), autocommit=sqlite3.LEGACY_TRANSACTION_CONTROL str(tmpdir / "test.db"),
autocommit=sqlite3.LEGACY_TRANSACTION_CONTROL, # type: ignore[arg-type]
) )
db = Database(conn) db = Database(conn)
db["t"].insert({"id": 1}, pk="id") db.table("t").insert({"id": 1}, pk="id")
assert [r["id"] for r in db["t"].rows] == [1] assert [r["id"] for r in db.table("t").rows] == [1]
db.close() db.close()

View file

@ -1,17 +1,17 @@
def test_insert_conversion(fresh_db): def test_insert_conversion(fresh_db):
table = fresh_db["table"] table = fresh_db.table("table")
table.insert({"foo": "bar"}, conversions={"foo": "upper(?)"}) table.insert({"foo": "bar"}, conversions={"foo": "upper(?)"})
assert [{"foo": "BAR"}] == list(table.rows) assert [{"foo": "BAR"}] == list(table.rows)
def test_insert_all_conversion(fresh_db): def test_insert_all_conversion(fresh_db):
table = fresh_db["table"] table = fresh_db.table("table")
table.insert_all([{"foo": "bar"}], conversions={"foo": "upper(?)"}) table.insert_all([{"foo": "bar"}], conversions={"foo": "upper(?)"})
assert [{"foo": "BAR"}] == list(table.rows) assert [{"foo": "BAR"}] == list(table.rows)
def test_upsert_conversion(fresh_db): def test_upsert_conversion(fresh_db):
table = fresh_db["table"] table = fresh_db.table("table")
table.upsert({"id": 1, "foo": "bar"}, pk="id", conversions={"foo": "upper(?)"}) table.upsert({"id": 1, "foo": "bar"}, pk="id", conversions={"foo": "upper(?)"})
assert [{"id": 1, "foo": "BAR"}] == list(table.rows) assert [{"id": 1, "foo": "BAR"}] == list(table.rows)
table.upsert( table.upsert(
@ -21,7 +21,7 @@ def test_upsert_conversion(fresh_db):
def test_upsert_all_conversion(fresh_db): def test_upsert_all_conversion(fresh_db):
table = fresh_db["table"] table = fresh_db.table("table")
table.upsert_all( table.upsert_all(
[{"id": 1, "foo": "bar"}], pk="id", conversions={"foo": "upper(?)"} [{"id": 1, "foo": "bar"}], pk="id", conversions={"foo": "upper(?)"}
) )
@ -29,7 +29,7 @@ def test_upsert_all_conversion(fresh_db):
def test_update_conversion(fresh_db): def test_update_conversion(fresh_db):
table = fresh_db["table"] table = fresh_db.table("table")
table.insert({"id": 5, "foo": "bar"}, pk="id") table.insert({"id": 5, "foo": "bar"}, pk="id")
table.update(5, {"foo": "baz"}, conversions={"foo": "upper(?)"}) table.update(5, {"foo": "baz"}, conversions={"foo": "upper(?)"})
assert [{"id": 5, "foo": "BAZ"}] == list(table.rows) assert [{"id": 5, "foo": "BAZ"}] == list(table.rows)

View file

@ -1,6 +1,7 @@
from sqlite_utils.db import BadMultiValues
import pytest import pytest
from sqlite_utils.db import BadMultiValues
@pytest.mark.parametrize( @pytest.mark.parametrize(
"columns,fn,expected", "columns,fn,expected",
@ -26,7 +27,7 @@ import pytest
), ),
) )
def test_convert(fresh_db, columns, fn, expected): def test_convert(fresh_db, columns, fn, expected):
table = fresh_db["table"] table = fresh_db.table("table")
table.insert({"title": "Mixed Case", "abstract": "Abstract"}) table.insert({"title": "Mixed Case", "abstract": "Abstract"})
table.convert(columns, fn) table.convert(columns, fn)
assert list(table.rows) == [expected] assert list(table.rows) == [expected]
@ -36,7 +37,7 @@ def test_convert(fresh_db, columns, fn, expected):
"where,where_args", (("id > 1", None), ("id > :id", {"id": 1}), ("id > ?", [1])) "where,where_args", (("id > 1", None), ("id > :id", {"id": 1}), ("id > ?", [1]))
) )
def test_convert_where(fresh_db, where, where_args): def test_convert_where(fresh_db, where, where_args):
table = fresh_db["table"] table = fresh_db.table("table")
table.insert_all( table.insert_all(
[ [
{"id": 1, "title": "One"}, {"id": 1, "title": "One"},
@ -52,7 +53,7 @@ def test_convert_where(fresh_db, where, where_args):
def test_convert_handles_falsey_values(fresh_db): def test_convert_handles_falsey_values(fresh_db):
# Falsey values like 0 should be converted (issue #527) # Falsey values like 0 should be converted (issue #527)
table = fresh_db["table"] table = fresh_db.table("table")
table.insert_all([{"x": 0}, {"x": 1}]) table.insert_all([{"x": 0}, {"x": 1}])
assert table.get(1)["x"] == 0 assert table.get(1)["x"] == 0
assert table.get(2)["x"] == 1 assert table.get(2)["x"] == 1
@ -69,14 +70,14 @@ def test_convert_handles_falsey_values(fresh_db):
), ),
) )
def test_convert_output(fresh_db, drop, expected): def test_convert_output(fresh_db, drop, expected):
table = fresh_db["table"] table = fresh_db.table("table")
table.insert({"title": "Mixed Case"}) table.insert({"title": "Mixed Case"})
table.convert("title", lambda v: v.upper(), output="other", drop=drop) table.convert("title", lambda v: v.upper(), output="other", drop=drop)
assert list(table.rows) == [expected] assert list(table.rows) == [expected]
def test_convert_output_multiple_column_error(fresh_db): def test_convert_output_multiple_column_error(fresh_db):
table = fresh_db["table"] table = fresh_db.table("table")
with pytest.raises(ValueError) as excinfo: with pytest.raises(ValueError) as excinfo:
table.convert(["title", "other"], lambda v: v, output="out") table.convert(["title", "other"], lambda v: v, output="out")
assert "output= can only be used with a single column" in str(excinfo.value) assert "output= can only be used with a single column" in str(excinfo.value)
@ -90,14 +91,14 @@ def test_convert_output_multiple_column_error(fresh_db):
), ),
) )
def test_convert_output_type(fresh_db, type, expected): def test_convert_output_type(fresh_db, type, expected):
table = fresh_db["table"] table = fresh_db.table("table")
table.insert({"number": "123"}) table.insert({"number": "123"})
table.convert("number", lambda v: v, output="other", output_type=type, drop=True) table.convert("number", lambda v: v, output="other", output_type=type, drop=True)
assert list(table.rows) == [expected] assert list(table.rows) == [expected]
def test_convert_multi(fresh_db): def test_convert_multi(fresh_db):
table = fresh_db["table"] table = fresh_db.table("table")
table.insert({"title": "Mixed Case"}) table.insert({"title": "Mixed Case"})
table.convert( table.convert(
"title", "title",
@ -122,7 +123,7 @@ def test_convert_multi(fresh_db):
def test_convert_multi_where(fresh_db): def test_convert_multi_where(fresh_db):
table = fresh_db["table"] table = fresh_db.table("table")
table.insert_all( table.insert_all(
[ [
{"id": 1, "title": "One"}, {"id": 1, "title": "One"},
@ -144,14 +145,14 @@ def test_convert_multi_where(fresh_db):
def test_convert_multi_exception(fresh_db): def test_convert_multi_exception(fresh_db):
table = fresh_db["table"] table = fresh_db.table("table")
table.insert({"title": "Mixed Case"}) table.insert({"title": "Mixed Case"})
with pytest.raises(BadMultiValues): with pytest.raises(BadMultiValues):
table.convert("title", lambda v: v.upper(), multi=True) table.convert("title", lambda v: v.upper(), multi=True)
def test_convert_repeated(fresh_db): def test_convert_repeated(fresh_db):
table = fresh_db["table"] table = fresh_db.table("table")
col = "num" col = "num"
table.insert({col: 1}) table.insert({col: 1})
table.convert(col, lambda x: x * 2) table.convert(col, lambda x: x * 2)

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,243 @@
import sqlite3
import hypothesis.strategies as st
import pytest
from hypothesis import given
from sqlite_utils.create_table_parser import (
Check,
ColumnComments,
ParseError,
Unique,
UniqueColumn,
parse_autoincrement,
parse_checks,
parse_column_comments,
parse_uniques,
)
def test_parse_column_and_table_checks():
sql = """
CREATE TABLE people (
age INTEGER CONSTRAINT positive CHECK (age > 0),
status TEXT CHECK(status IN ('active', 'inactive')),
CONSTRAINT adult CHECK(age >= 18)
)
"""
assert parse_checks(sql) == [
Check("age > 0", name="positive", column="age"),
Check(
"status IN ('active', 'inactive')",
column="status",
options=["active", "inactive"],
),
Check("age >= 18", name="adult"),
]
checks = parse_checks(sql)
assert checks[0].sql == "CONSTRAINT positive CHECK (age > 0)"
assert sql[checks[0].start : checks[0].end] == checks[0].sql
assert checks[1].sql == "CHECK(status IN ('active', 'inactive'))"
assert sql[checks[2].start : checks[2].end] == checks[2].sql
def test_comments_are_trivia_not_constraints():
sql = """
CREATE /* fake CHECK (nope), ( */ TABLE t (
a INTEGER /* CHECK (a < 0), phantom */,
b INTEGER CHECK /* between keyword and expression */ (b > 0),
/* CHECK (also_fake) */ CONSTRAINT upper CHECK(b < 10)
)
"""
sqlite3.connect(":memory:").execute(sql)
assert parse_checks(sql) == [
Check("b > 0", column="b"),
Check("b < 10", name="upper"),
]
def test_parse_comments_owned_by_columns():
sql = """
CREATE TABLE t (
-- Before id
id /* Between name and type */ INTEGER /* After id */,
/* Between column definitions */
value TEXT CHECK(value != '') /* After value */,
/* Before a table constraint, not a column */
CHECK(value != 'forbidden')
)
"""
assert parse_column_comments(sql) == {
"id": ColumnComments(before="-- Before id", after="/* After id */"),
"value": ColumnComments(
before="/* Between column definitions */",
after="/* After value */",
),
}
@pytest.mark.parametrize(
"expression,expected",
[
("value IN ('one', 'two')", ["one", "two"]),
("((value IN ('one', 'two')))", ["one", "two"]),
("value NOT IN ('one', 'two')", None),
("value IN ('one', 'two') OR enabled", None),
("other IN ('one', 'two')", None),
("value IN (lower('one'), 'two')", None),
('value IN ("other")', None),
],
)
def test_options_only_for_exact_literal_in_check(expression, expected):
sql = f"CREATE TABLE t(value TEXT CHECK({expression}), enabled INTEGER, other TEXT)"
sqlite3.connect(":memory:").execute(sql)
assert parse_checks(sql)[0].options == expected
@pytest.mark.parametrize("column", ["💩x", "e\u0301"])
def test_unquoted_unicode_identifiers(column):
sql = f"CREATE TABLE t({column} INTEGER CHECK({column} > 0))"
sqlite3.connect(":memory:").execute(sql)
assert parse_checks(sql) == [Check(f"{column} > 0", column=column)]
@pytest.mark.parametrize(
"sql",
[
"SELECT CHECK(x > 0)",
"CREATE TABLE t(x INTEGER CHECK(x > 0)",
"CREATE TABLE t(x TEXT CHECK(x != 'unterminated))",
"CREATE TABLE t(x INTEGER /* unterminated)",
],
)
def test_invalid_sql_raises_parse_error(sql):
with pytest.raises(ParseError):
parse_checks(sql)
def test_virtual_table_has_no_checks():
assert (
parse_checks("CREATE /* comment */ VIRTUAL TABLE search USING fts5(text)") == []
)
@pytest.mark.parametrize(
"sql,expected",
[
(
"CREATE TABLE t(id INTEGER PRIMARY KEY AUTOINCREMENT, value TEXT)",
"id",
),
(
'CREATE TABLE t("quoted id" INTEGER PRIMARY KEY AUTOINCREMENT)',
"quoted id",
),
(
'CREATE TABLE t("autoincrement" INTEGER PRIMARY KEY, value TEXT)',
None,
),
(
"CREATE TABLE t(id INTEGER PRIMARY KEY /* AUTOINCREMENT */, value TEXT)",
None,
),
(
"CREATE TABLE t(id INTEGER PRIMARY KEY, value TEXT CHECK(value != 'AUTOINCREMENT'))",
None,
),
],
)
def test_parse_autoincrement(sql, expected):
sqlite3.connect(":memory:").execute(sql)
assert parse_autoincrement(sql) == expected
def test_parse_column_and_table_uniques():
sql = """
CREATE TABLE memberships (
email TEXT COLLATE RTRIM CONSTRAINT unique_email UNIQUE ON CONFLICT IGNORE,
account_id INTEGER,
CONSTRAINT unique_membership UNIQUE (
account_id DESC,
email COLLATE NOCASE ASC
) ON CONFLICT REPLACE
)
"""
sqlite3.connect(":memory:").execute(sql)
assert parse_uniques(sql) == [
Unique(
(UniqueColumn("email", collation="RTRIM"),),
name="unique_email",
column="email",
conflict="IGNORE",
),
Unique(
(
UniqueColumn("account_id", order="DESC"),
UniqueColumn("email", collation="NOCASE", order="ASC"),
),
name="unique_membership",
conflict="REPLACE",
),
]
uniques = parse_uniques(sql)
assert uniques[0].sql == "CONSTRAINT unique_email UNIQUE ON CONFLICT IGNORE"
assert sql[uniques[1].start : uniques[1].end] == uniques[1].sql
def test_unique_like_text_in_comments_and_checks_is_ignored():
sql = """
CREATE TABLE t (
value TEXT /* UNIQUE ON CONFLICT REPLACE */
CHECK(value != 'UNIQUE(other)'),
other TEXT
)
"""
sqlite3.connect(":memory:").execute(sql)
assert parse_uniques(sql) == []
comment_or_space = st.sampled_from(
[
" ",
"\n ",
"/* comment with , ( ) and CHECK(fake) */",
"-- comment with , ( ) and CHECK(fake)\n",
]
)
@given(gaps=st.lists(comment_or_space, min_size=5, max_size=5))
def test_comments_and_whitespace_can_separate_check_tokens(gaps):
sql = (
f"CREATE{gaps[0]}TABLE{gaps[1]}t{gaps[2]}("
f"value INTEGER CHECK{gaps[3]}(value{gaps[4]}> 0))"
)
connection = sqlite3.connect(":memory:")
connection.execute(sql)
stored_sql = connection.execute(
"select sql from sqlite_master where name = 't'"
).fetchone()[0]
assert parse_checks(stored_sql) == [Check(f"value{gaps[4]}> 0", column="value")]
safe_string_text = st.text(
alphabet=st.characters(
blacklist_categories=("Cc", "Cs"),
blacklist_characters=("'",),
),
max_size=40,
)
@given(value=safe_string_text)
def test_check_like_text_inside_strings_is_opaque(value):
sql = f"CREATE TABLE t(value TEXT CHECK(value != '{value}'))"
connection = sqlite3.connect(":memory:")
connection.execute(sql)
stored_sql = connection.execute(
"select sql from sqlite_master where name = 't'"
).fetchone()[0]
checks = parse_checks(stored_sql)
assert len(checks) == 1
assert checks[0].column == "value"
assert checks[0].check == f"value != '{value}'"

View file

@ -1,4 +1,5 @@
import pytest import pytest
from sqlite_utils.utils import OperationalError from sqlite_utils.utils import OperationalError

View file

@ -31,10 +31,10 @@ EXAMPLES = [
@pytest.mark.parametrize("column_def,initial_value,expected_value", EXAMPLES) @pytest.mark.parametrize("column_def,initial_value,expected_value", EXAMPLES)
def test_quote_default_value(fresh_db, column_def, initial_value, expected_value): def test_quote_default_value(fresh_db, column_def, initial_value, expected_value):
fresh_db.execute("create table foo (col {})".format(column_def)) fresh_db.execute(f"create table foo (col {column_def})")
assert initial_value == fresh_db["foo"].columns[0].default_value assert initial_value == fresh_db.table("foo").columns[0].default_value
assert expected_value == fresh_db.quote_default_value( assert expected_value == fresh_db.quote_default_value(
fresh_db["foo"].columns[0].default_value fresh_db.table("foo").columns[0].default_value
) )
@ -48,7 +48,7 @@ def test_insert_empty_record_uses_default_values(fresh_db):
) )
""") """)
table = fresh_db["has_defaults"] table = fresh_db.table("has_defaults")
table.insert({}) table.insert({})
rows = list(table.rows) rows = list(table.rows)

View file

@ -2,15 +2,15 @@ import sqlite_utils
def test_delete_rowid_table(fresh_db): def test_delete_rowid_table(fresh_db):
table = fresh_db["table"] table = fresh_db.table("table")
table.insert({"foo": 1}).last_pk table.insert({"foo": 1})
rowid = table.insert({"foo": 2}).last_pk rowid = table.insert({"foo": 2}).last_pk
table.delete(rowid) table.delete(rowid)
assert [{"foo": 1}] == list(table.rows) assert [{"foo": 1}] == list(table.rows)
def test_delete_pk_table(fresh_db): def test_delete_pk_table(fresh_db):
table = fresh_db["table"] table = fresh_db.table("table")
table.insert({"id": 1}, pk="id") table.insert({"id": 1}, pk="id")
table.insert({"id": 2}, pk="id") table.insert({"id": 2}, pk="id")
table.delete(1) table.delete(1)
@ -18,7 +18,7 @@ def test_delete_pk_table(fresh_db):
def test_delete_where(fresh_db): def test_delete_where(fresh_db):
table = fresh_db["table"] table = fresh_db.table("table")
for i in range(1, 11): for i in range(1, 11):
table.insert({"id": i}, pk="id") table.insert({"id": i}, pk="id")
assert table.count == 10 assert table.count == 10
@ -27,7 +27,7 @@ def test_delete_where(fresh_db):
def test_delete_where_all(fresh_db): def test_delete_where_all(fresh_db):
table = fresh_db["table"] table = fresh_db.table("table")
for i in range(1, 11): for i in range(1, 11):
table.insert({"id": i}, pk="id") table.insert({"id": i}, pk="id")
assert table.count == 10 assert table.count == 10
@ -38,27 +38,27 @@ def test_delete_where_all(fresh_db):
def test_delete_where_commits(tmpdir): def test_delete_where_commits(tmpdir):
path = str(tmpdir / "test.db") path = str(tmpdir / "test.db")
db = sqlite_utils.Database(path) db = sqlite_utils.Database(path)
db["table"].insert_all([{"id": i} for i in range(5)], pk="id") db.table("table").insert_all([{"id": i} for i in range(5)], pk="id")
db["table"].delete_where("id > ?", [2]) db.table("table").delete_where("id > ?", [2])
# The connection must not be left inside an open transaction, # The connection must not be left inside an open transaction,
# otherwise subsequent atomic() blocks never commit either # otherwise subsequent atomic() blocks never commit either
assert not db.conn.in_transaction assert not db.conn.in_transaction
db["table"].insert({"id": 100}) db.table("table").insert({"id": 100})
db.close() db.close()
db2 = sqlite_utils.Database(path) db2 = sqlite_utils.Database(path)
assert [r["id"] for r in db2["table"].rows] == [0, 1, 2, 100] assert [r["id"] for r in db2.table("table").rows] == [0, 1, 2, 100]
db2.close() db2.close()
def test_delete_where_analyze(fresh_db): def test_delete_where_analyze(fresh_db):
table = fresh_db["table"] table = fresh_db.table("table")
table.insert_all(({"id": i, "i": i} for i in range(10)), pk="id") table.insert_all(({"id": i, "i": i} for i in range(10)), pk="id")
table.create_index(["i"], analyze=True) table.create_index(["i"], analyze=True)
assert "sqlite_stat1" in fresh_db.table_names() assert "sqlite_stat1" in fresh_db.table_names()
assert list(fresh_db["sqlite_stat1"].rows) == [ assert list(fresh_db.table("sqlite_stat1").rows) == [
{"tbl": "table", "idx": "idx_table_i", "stat": "10 1"} {"tbl": "table", "idx": "idx_table_i", "stat": "10 1"}
] ]
table.delete_where("id > ?", [5], analyze=True) table.delete_where("id > ?", [5], analyze=True)
assert list(fresh_db["sqlite_stat1"].rows) == [ assert list(fresh_db.table("sqlite_stat1").rows) == [
{"tbl": "table", "idx": "idx_table_i", "stat": "6 1"} {"tbl": "table", "idx": "idx_table_i", "stat": "6 1"}
] ]

View file

@ -1,8 +1,10 @@
from click.testing import CliRunner
from sqlite_utils import cli, recipes
from pathlib import Path
import pytest
import re import re
from pathlib import Path
import pytest
from click.testing import CliRunner
from sqlite_utils import cli, recipes
docs_path = Path(__file__).parent.parent / "docs" docs_path = Path(__file__).parent.parent / "docs"
commands_re = re.compile(r"(?:\$ | )sqlite-utils (\S+)") commands_re = re.compile(r"(?:\$ | )sqlite-utils (\S+)")
@ -34,7 +36,7 @@ def test_commands_are_documented(documented_commands, command):
@pytest.mark.parametrize("command", cli.cli.commands.values()) @pytest.mark.parametrize("command", cli.cli.commands.values())
def test_commands_have_help(command): def test_commands_have_help(command):
assert command.help, "{} is missing its help".format(command) assert command.help, f"{command} is missing its help"
def test_convert_help(): def test_convert_help():

View file

@ -1,7 +1,9 @@
from sqlite_utils.db import NoTable
import datetime import datetime
import pytest import pytest
from sqlite_utils.db import NoTable
def test_duplicate(fresh_db): def test_duplicate(fresh_db):
# Create table using native Sqlite statement: # Create table using native Sqlite statement:
@ -12,7 +14,7 @@ def test_duplicate(fresh_db):
"bool_col" INTEGER, "bool_col" INTEGER,
"datetime_col" TEXT)""") "datetime_col" TEXT)""")
# Insert one row of mock data: # Insert one row of mock data:
dt = datetime.datetime.now() dt = datetime.datetime.now(datetime.timezone.utc)
data = { data = {
"text_col": "Cleo", "text_col": "Cleo",
"real_col": 3.14, "real_col": 3.14,
@ -20,7 +22,7 @@ def test_duplicate(fresh_db):
"bool_col": True, "bool_col": True,
"datetime_col": str(dt), "datetime_col": str(dt),
} }
table1 = fresh_db["table1"] table1 = fresh_db.table("table1")
row_id = table1.insert(data).last_rowid row_id = table1.insert(data).last_rowid
# Duplicate table: # Duplicate table:
table2 = table1.duplicate("table2") table2 = table1.duplicate("table2")
@ -38,4 +40,4 @@ def test_duplicate(fresh_db):
def test_duplicate_fails_if_table_does_not_exist(fresh_db): def test_duplicate_fails_if_table_does_not_exist(fresh_db):
with pytest.raises(NoTable): with pytest.raises(NoTable):
fresh_db["not_a_table"].duplicate("duplicated") fresh_db.table("not_a_table").duplicate("duplicated")

View file

@ -1,14 +1,14 @@
from sqlite_utils import Database
from sqlite_utils import cli
from click.testing import CliRunner
import pytest import pytest
from click.testing import CliRunner
from sqlite_utils import Database, cli
def test_enable_counts_specific_table(fresh_db): def test_enable_counts_specific_table(fresh_db):
foo = fresh_db["foo"] foo = fresh_db.table("foo")
assert fresh_db.table_names() == [] assert fresh_db.table_names() == []
for i in range(10): for i in range(10):
foo.insert({"name": "item {}".format(i)}) foo.insert({"name": f"item {i}"})
assert fresh_db.table_names() == ["foo"] assert fresh_db.table_names() == ["foo"]
assert foo.count == 10 assert foo.count == 10
# Now enable counts # Now enable counts
@ -41,24 +41,24 @@ def test_enable_counts_specific_table(fresh_db):
), ),
} }
assert fresh_db.table_names() == ["foo", "_counts"] assert fresh_db.table_names() == ["foo", "_counts"]
assert list(fresh_db["_counts"].rows) == [{"count": 10, "table": "foo"}] assert list(fresh_db.table("_counts").rows) == [{"count": 10, "table": "foo"}]
# Add some items to test the triggers # Add some items to test the triggers
for i in range(5): for i in range(5):
foo.insert({"name": "item {}".format(10 + i)}) foo.insert({"name": f"item {10 + i}"})
assert foo.count == 15 assert foo.count == 15
assert list(fresh_db["_counts"].rows) == [{"count": 15, "table": "foo"}] assert list(fresh_db.table("_counts").rows) == [{"count": 15, "table": "foo"}]
# Delete some items # Delete some items
foo.delete_where("rowid < 7") foo.delete_where("rowid < 7")
assert foo.count == 9 assert foo.count == 9
assert list(fresh_db["_counts"].rows) == [{"count": 9, "table": "foo"}] assert list(fresh_db.table("_counts").rows) == [{"count": 9, "table": "foo"}]
foo.delete_where() foo.delete_where()
assert foo.count == 0 assert foo.count == 0
assert list(fresh_db["_counts"].rows) == [{"count": 0, "table": "foo"}] assert list(fresh_db.table("_counts").rows) == [{"count": 0, "table": "foo"}]
def test_enable_counts_all_tables(fresh_db): def test_enable_counts_all_tables(fresh_db):
foo = fresh_db["foo"] foo = fresh_db.table("foo")
bar = fresh_db["bar"] bar = fresh_db.table("bar")
foo.insert({"name": "Cleo"}) foo.insert({"name": "Cleo"})
bar.insert({"name": "Cleo"}) bar.insert({"name": "Cleo"})
foo.enable_fts(["name"]) foo.enable_fts(["name"])
@ -73,7 +73,7 @@ def test_enable_counts_all_tables(fresh_db):
"foo_fts_config", "foo_fts_config",
"_counts", "_counts",
} }
assert list(fresh_db["_counts"].rows) == [ assert list(fresh_db.table("_counts").rows) == [
{"count": 1, "table": "foo"}, {"count": 1, "table": "foo"},
{"count": 1, "table": "bar"}, {"count": 1, "table": "bar"},
{"count": 3, "table": "foo_fts_data"}, {"count": 3, "table": "foo_fts_data"},
@ -87,10 +87,10 @@ def test_enable_counts_all_tables(fresh_db):
def counts_db_path(tmpdir): def counts_db_path(tmpdir):
path = str(tmpdir / "test.db") path = str(tmpdir / "test.db")
db = Database(path) db = Database(path)
db["foo"].insert({"name": "bar"}) db.table("foo").insert({"name": "bar"})
db["bar"].insert({"name": "bar"}) db.table("bar").insert({"name": "bar"})
db["bar"].insert({"name": "bar"}) db.table("bar").insert({"name": "bar"})
db["baz"].insert({"name": "bar"}) db.table("baz").insert({"name": "bar"})
return path return path
@ -163,25 +163,25 @@ def test_uses_counts_after_enable_counts(counts_db_path):
def test_reset_counts(counts_db_path): def test_reset_counts(counts_db_path):
db = Database(counts_db_path) db = Database(counts_db_path)
db["foo"].enable_counts() db.table("foo").enable_counts()
db["bar"].enable_counts() db.table("bar").enable_counts()
assert db.cached_counts() == {"foo": 1, "bar": 2} assert db.cached_counts() == {"foo": 1, "bar": 2}
# Corrupt the value # Corrupt the value
db["_counts"].update("foo", {"count": 3}) db.table("_counts").update("foo", {"count": 3})
assert db.cached_counts() == {"foo": 3, "bar": 2} assert db.cached_counts() == {"foo": 3, "bar": 2}
assert db["foo"].count == 3 assert db.table("foo").count == 3
# Reset them # Reset them
db.reset_counts() db.reset_counts()
assert db.cached_counts() == {"foo": 1, "bar": 2} assert db.cached_counts() == {"foo": 1, "bar": 2}
assert db["foo"].count == 1 assert db.table("foo").count == 1
def test_reset_counts_cli(counts_db_path): def test_reset_counts_cli(counts_db_path):
db = Database(counts_db_path) db = Database(counts_db_path)
db["foo"].enable_counts() db.table("foo").enable_counts()
db["bar"].enable_counts() db.table("bar").enable_counts()
assert db.cached_counts() == {"foo": 1, "bar": 2} assert db.cached_counts() == {"foo": 1, "bar": 2}
db["_counts"].update("foo", {"count": 3}) db.table("_counts").update("foo", {"count": 3})
result = CliRunner().invoke(cli.cli, ["reset-counts", counts_db_path]) result = CliRunner().invoke(cli.cli, ["reset-counts", counts_db_path])
assert result.exit_code == 0 assert result.exit_code == 0
assert db.cached_counts() == {"foo": 1, "bar": 2} assert db.cached_counts() == {"foo": 1, "bar": 2}

View file

@ -1,19 +1,22 @@
from sqlite_utils.db import InvalidColumns
import itertools import itertools
import pytest import pytest
from sqlite_utils import ANY
from sqlite_utils.db import InvalidColumns
@pytest.mark.parametrize("table", [None, "Species"]) @pytest.mark.parametrize("table", [None, "Species"])
@pytest.mark.parametrize("fk_column", [None, "species"]) @pytest.mark.parametrize("fk_column", [None, "species"])
def test_extract_single_column(fresh_db, table, fk_column): def test_extract_single_column(fresh_db, table, fk_column):
expected_table = table or "species" expected_table = table or "species"
expected_fk = fk_column or "{}_id".format(expected_table) expected_fk = fk_column or f"{expected_table}_id"
iter_species = itertools.cycle(["Palm", "Spruce", "Mangrove", "Oak"]) iter_species = itertools.cycle(["Palm", "Spruce", "Mangrove", "Oak"])
fresh_db["tree"].insert_all( fresh_db.table("tree").insert_all(
( (
{ {
"id": i, "id": i,
"name": "Tree {}".format(i), "name": f"Tree {i}",
"species": next(iter_species), "species": next(iter_species),
"end": 1, "end": 1,
} }
@ -21,28 +24,27 @@ def test_extract_single_column(fresh_db, table, fk_column):
), ),
pk="id", pk="id",
) )
fresh_db["tree"].extract("species", table=table, fk_column=fk_column) fresh_db.table("tree").extract("species", table=table, fk_column=fk_column)
assert fresh_db["tree"].schema == ( assert fresh_db.table("tree").schema == (
'CREATE TABLE "tree" (\n' 'CREATE TABLE "tree" (\n'
' "id" INTEGER PRIMARY KEY,\n' ' "id" INTEGER PRIMARY KEY,\n'
' "name" TEXT,\n' ' "name" TEXT,\n'
' "{}" INTEGER REFERENCES "{}"("id"),\n'.format(expected_fk, expected_table) f' "{expected_fk}" INTEGER REFERENCES "{expected_table}"("id"),\n'
+ ' "end" INTEGER\n' + ' "end" INTEGER\n'
+ ")" + ")"
) )
assert fresh_db[expected_table].schema == ( assert fresh_db.table(expected_table).schema == (
'CREATE TABLE "{}" (\n'.format(expected_table) f'CREATE TABLE "{expected_table}" (\n' + ' "id" INTEGER PRIMARY KEY,\n'
+ ' "id" INTEGER PRIMARY KEY,\n'
' "species" TEXT\n' ' "species" TEXT\n'
")" ")"
) )
assert list(fresh_db[expected_table].rows) == [ assert list(fresh_db.table(expected_table).rows) == [
{"id": 1, "species": "Palm"}, {"id": 1, "species": "Palm"},
{"id": 2, "species": "Spruce"}, {"id": 2, "species": "Spruce"},
{"id": 3, "species": "Mangrove"}, {"id": 3, "species": "Mangrove"},
{"id": 4, "species": "Oak"}, {"id": 4, "species": "Oak"},
] ]
assert list(itertools.islice(fresh_db["tree"].rows, 0, 4)) == [ assert list(itertools.islice(fresh_db.table("tree").rows, 0, 4)) == [
{"id": 1, "name": "Tree 1", expected_fk: 1, "end": 1}, {"id": 1, "name": "Tree 1", expected_fk: 1, "end": 1},
{"id": 2, "name": "Tree 2", expected_fk: 2, "end": 1}, {"id": 2, "name": "Tree 2", expected_fk: 2, "end": 1},
{"id": 3, "name": "Tree 3", expected_fk: 3, "end": 1}, {"id": 3, "name": "Tree 3", expected_fk: 3, "end": 1},
@ -53,11 +55,11 @@ def test_extract_single_column(fresh_db, table, fk_column):
def test_extract_multiple_columns_with_rename(fresh_db): def test_extract_multiple_columns_with_rename(fresh_db):
iter_common = itertools.cycle(["Palm", "Spruce", "Mangrove", "Oak"]) iter_common = itertools.cycle(["Palm", "Spruce", "Mangrove", "Oak"])
iter_latin = itertools.cycle(["Arecaceae", "Picea", "Rhizophora", "Quercus"]) iter_latin = itertools.cycle(["Arecaceae", "Picea", "Rhizophora", "Quercus"])
fresh_db["tree"].insert_all( fresh_db.table("tree").insert_all(
( (
{ {
"id": i, "id": i,
"name": "Tree {}".format(i), "name": f"Tree {i}",
"common_name": next(iter_common), "common_name": next(iter_common),
"latin_name": next(iter_latin), "latin_name": next(iter_latin),
} }
@ -66,30 +68,30 @@ def test_extract_multiple_columns_with_rename(fresh_db):
pk="id", pk="id",
) )
fresh_db["tree"].extract( fresh_db.table("tree").extract(
["common_name", "latin_name"], rename={"common_name": "name"} ["common_name", "latin_name"], rename={"common_name": "name"}
) )
assert fresh_db["tree"].schema == ( assert fresh_db.table("tree").schema == (
'CREATE TABLE "tree" (\n' 'CREATE TABLE "tree" (\n'
' "id" INTEGER PRIMARY KEY,\n' ' "id" INTEGER PRIMARY KEY,\n'
' "name" TEXT,\n' ' "name" TEXT,\n'
' "common_name_latin_name_id" INTEGER REFERENCES "common_name_latin_name"("id")\n' ' "common_name_latin_name_id" INTEGER REFERENCES "common_name_latin_name"("id")\n'
")" ")"
) )
assert fresh_db["common_name_latin_name"].schema == ( assert fresh_db.table("common_name_latin_name").schema == (
'CREATE TABLE "common_name_latin_name" (\n' 'CREATE TABLE "common_name_latin_name" (\n'
' "id" INTEGER PRIMARY KEY,\n' ' "id" INTEGER PRIMARY KEY,\n'
' "name" TEXT,\n' ' "name" TEXT,\n'
' "latin_name" TEXT\n' ' "latin_name" TEXT\n'
")" ")"
) )
assert list(fresh_db["common_name_latin_name"].rows) == [ assert list(fresh_db.table("common_name_latin_name").rows) == [
{"name": "Palm", "id": 1, "latin_name": "Arecaceae"}, {"name": "Palm", "id": 1, "latin_name": "Arecaceae"},
{"name": "Spruce", "id": 2, "latin_name": "Picea"}, {"name": "Spruce", "id": 2, "latin_name": "Picea"},
{"name": "Mangrove", "id": 3, "latin_name": "Rhizophora"}, {"name": "Mangrove", "id": 3, "latin_name": "Rhizophora"},
{"name": "Oak", "id": 4, "latin_name": "Quercus"}, {"name": "Oak", "id": 4, "latin_name": "Quercus"},
] ]
assert list(itertools.islice(fresh_db["tree"].rows, 0, 4)) == [ assert list(itertools.islice(fresh_db.table("tree").rows, 0, 4)) == [
{"id": 1, "name": "Tree 1", "common_name_latin_name_id": 1}, {"id": 1, "name": "Tree 1", "common_name_latin_name_id": 1},
{"id": 2, "name": "Tree 2", "common_name_latin_name_id": 2}, {"id": 2, "name": "Tree 2", "common_name_latin_name_id": 2},
{"id": 3, "name": "Tree 3", "common_name_latin_name_id": 3}, {"id": 3, "name": "Tree 3", "common_name_latin_name_id": 3},
@ -98,7 +100,7 @@ def test_extract_multiple_columns_with_rename(fresh_db):
def test_extract_invalid_columns(fresh_db): def test_extract_invalid_columns(fresh_db):
fresh_db["tree"].insert( fresh_db.table("tree").insert(
{ {
"id": 1, "id": 1,
"name": "Tree 1", "name": "Tree 1",
@ -108,19 +110,19 @@ def test_extract_invalid_columns(fresh_db):
pk="id", pk="id",
) )
with pytest.raises(InvalidColumns): with pytest.raises(InvalidColumns):
fresh_db["tree"].extract(["bad_column"]) fresh_db.table("tree").extract(["bad_column"])
def test_extract_rowid_table(fresh_db): def test_extract_rowid_table(fresh_db):
fresh_db["tree"].insert( fresh_db.table("tree").insert(
{ {
"name": "Tree 1", "name": "Tree 1",
"common_name": "Palm", "common_name": "Palm",
"latin_name": "Arecaceae", "latin_name": "Arecaceae",
} }
) )
fresh_db["tree"].extract(["common_name", "latin_name"]) fresh_db.table("tree").extract(["common_name", "latin_name"])
assert fresh_db["tree"].schema == ( assert fresh_db.table("tree").schema == (
'CREATE TABLE "tree" (\n' 'CREATE TABLE "tree" (\n'
' "name" TEXT,\n' ' "name" TEXT,\n'
' "common_name_latin_name_id" INTEGER REFERENCES "common_name_latin_name"("id")\n' ' "common_name_latin_name_id" INTEGER REFERENCES "common_name_latin_name"("id")\n'
@ -138,68 +140,68 @@ def test_extract_rowid_table(fresh_db):
def test_reuse_lookup_table(fresh_db): def test_reuse_lookup_table(fresh_db):
fresh_db["species"].insert({"id": 1, "name": "Wolf"}, pk="id") fresh_db.table("species").insert({"id": 1, "name": "Wolf"}, pk="id")
fresh_db["sightings"].insert({"id": 10, "species": "Wolf"}, pk="id") fresh_db.table("sightings").insert({"id": 10, "species": "Wolf"}, pk="id")
fresh_db["individuals"].insert( fresh_db.table("individuals").insert(
{"id": 10, "name": "Terriana", "species": "Fox"}, pk="id" {"id": 10, "name": "Terriana", "species": "Fox"}, pk="id"
) )
fresh_db["sightings"].extract("species", rename={"species": "name"}) fresh_db.table("sightings").extract("species", rename={"species": "name"})
fresh_db["individuals"].extract("species", rename={"species": "name"}) fresh_db.table("individuals").extract("species", rename={"species": "name"})
assert fresh_db["sightings"].schema == ( assert fresh_db.table("sightings").schema == (
'CREATE TABLE "sightings" (\n' 'CREATE TABLE "sightings" (\n'
' "id" INTEGER PRIMARY KEY,\n' ' "id" INTEGER PRIMARY KEY,\n'
' "species_id" INTEGER REFERENCES "species"("id")\n' ' "species_id" INTEGER REFERENCES "species"("id")\n'
")" ")"
) )
assert fresh_db["individuals"].schema == ( assert fresh_db.table("individuals").schema == (
'CREATE TABLE "individuals" (\n' 'CREATE TABLE "individuals" (\n'
' "id" INTEGER PRIMARY KEY,\n' ' "id" INTEGER PRIMARY KEY,\n'
' "name" TEXT,\n' ' "name" TEXT,\n'
' "species_id" INTEGER REFERENCES "species"("id")\n' ' "species_id" INTEGER REFERENCES "species"("id")\n'
")" ")"
) )
assert list(fresh_db["species"].rows) == [ assert list(fresh_db.table("species").rows) == [
{"id": 1, "name": "Wolf"}, {"id": 1, "name": "Wolf"},
{"id": 2, "name": "Fox"}, {"id": 2, "name": "Fox"},
] ]
def test_extract_error_on_incompatible_existing_lookup_table(fresh_db): def test_extract_error_on_incompatible_existing_lookup_table(fresh_db):
fresh_db["species"].insert({"id": 1}) fresh_db.table("species").insert({"id": 1})
fresh_db["tree"].insert({"name": "Tree 1", "common_name": "Palm"}) fresh_db.table("tree").insert({"name": "Tree 1", "common_name": "Palm"})
with pytest.raises(InvalidColumns): with pytest.raises(InvalidColumns):
fresh_db["tree"].extract("common_name", table="species") fresh_db.table("tree").extract("common_name", table="species")
# Try again with incompatible existing column type # Try again with incompatible existing column type
fresh_db["species2"].insert({"id": 1, "common_name": 3.5}) fresh_db.table("species2").insert({"id": 1, "common_name": 3.5})
with pytest.raises(InvalidColumns): with pytest.raises(InvalidColumns):
fresh_db["tree"].extract("common_name", table="species2") fresh_db.table("tree").extract("common_name", table="species2")
def test_extract_works_with_null_values(fresh_db): def test_extract_works_with_null_values(fresh_db):
fresh_db["listens"].insert_all( fresh_db.table("listens").insert_all(
[ [
{"id": 1, "track_title": "foo", "album_title": "bar"}, {"id": 1, "track_title": "foo", "album_title": "bar"},
{"id": 2, "track_title": "baz", "album_title": None}, {"id": 2, "track_title": "baz", "album_title": None},
], ],
pk="id", pk="id",
) )
fresh_db["listens"].extract( fresh_db.table("listens").extract(
columns=["album_title"], table="albums", fk_column="album_id" columns=["album_title"], table="albums", fk_column="album_id"
) )
assert list(fresh_db["listens"].rows) == [ assert list(fresh_db.table("listens").rows) == [
{"id": 1, "track_title": "foo", "album_id": 1}, {"id": 1, "track_title": "foo", "album_id": 1},
{"id": 2, "track_title": "baz", "album_id": None}, {"id": 2, "track_title": "baz", "album_id": None},
] ]
assert list(fresh_db["albums"].rows) == [ assert list(fresh_db.table("albums").rows) == [
{"id": 1, "album_title": "bar"}, {"id": 1, "album_title": "bar"},
] ]
def test_extract_null_values_single_column(fresh_db): def test_extract_null_values_single_column(fresh_db):
# https://github.com/simonw/sqlite-utils/issues/186 # https://github.com/simonw/sqlite-utils/issues/186
fresh_db["species"].insert({"id": 1, "species": "Wolf"}, pk="id") fresh_db.table("species").insert({"id": 1, "species": "Wolf"}, pk="id")
fresh_db["individuals"].insert_all( fresh_db.table("individuals").insert_all(
[ [
{"id": 10, "name": "Terriana", "species": "Fox"}, {"id": 10, "name": "Terriana", "species": "Fox"},
{"id": 11, "name": "Spenidorm", "species": None}, {"id": 11, "name": "Spenidorm", "species": None},
@ -209,13 +211,13 @@ def test_extract_null_values_single_column(fresh_db):
], ],
pk="id", pk="id",
) )
fresh_db["individuals"].extract("species") fresh_db.table("individuals").extract("species")
# No null row should have been added to species # No null row should have been added to species
assert list(fresh_db["species"].rows) == [ assert list(fresh_db.table("species").rows) == [
{"id": 1, "species": "Wolf"}, {"id": 1, "species": "Wolf"},
{"id": 2, "species": "Fox"}, {"id": 2, "species": "Fox"},
] ]
assert list(fresh_db["individuals"].rows) == [ assert list(fresh_db.table("individuals").rows) == [
{"id": 10, "name": "Terriana", "species_id": 2}, {"id": 10, "name": "Terriana", "species_id": 2},
{"id": 11, "name": "Spenidorm", "species_id": None}, {"id": 11, "name": "Spenidorm", "species_id": None},
{"id": 12, "name": "Grantheim", "species_id": 1}, {"id": 12, "name": "Grantheim", "species_id": 1},
@ -227,7 +229,7 @@ def test_extract_null_values_single_column(fresh_db):
def test_extract_null_values_multiple_columns(fresh_db): def test_extract_null_values_multiple_columns(fresh_db):
# A row should be extracted if at least one column is not null - # A row should be extracted if at least one column is not null -
# only rows where ALL extracted columns are null are left alone # only rows where ALL extracted columns are null are left alone
fresh_db["circulation"].insert_all( fresh_db.table("circulation").insert_all(
[ [
{"id": 1, "title": "title one", "creator": "creator one", "year": 2018}, {"id": 1, "title": "title one", "creator": "creator one", "year": 2018},
{"id": 2, "title": "title two", "creator": None, "year": 2019}, {"id": 2, "title": "title two", "creator": None, "year": 2019},
@ -236,14 +238,14 @@ def test_extract_null_values_multiple_columns(fresh_db):
], ],
pk="id", pk="id",
) )
fresh_db["circulation"].extract( fresh_db.table("circulation").extract(
["title", "creator"], table="books", fk_column="book_id" ["title", "creator"], table="books", fk_column="book_id"
) )
assert list(fresh_db["books"].rows) == [ assert list(fresh_db.table("books").rows) == [
{"id": 1, "title": "title one", "creator": "creator one"}, {"id": 1, "title": "title one", "creator": "creator one"},
{"id": 2, "title": "title two", "creator": None}, {"id": 2, "title": "title two", "creator": None},
] ]
assert list(fresh_db["circulation"].rows) == [ assert list(fresh_db.table("circulation").rows) == [
{"id": 1, "book_id": 1, "year": 2018}, {"id": 1, "book_id": 1, "year": 2018},
{"id": 2, "book_id": 2, "year": 2019}, {"id": 2, "book_id": 2, "year": 2019},
{"id": 3, "book_id": None, "year": 2020}, {"id": 3, "book_id": None, "year": 2020},
@ -254,20 +256,20 @@ def test_extract_null_values_multiple_columns(fresh_db):
def test_extract_null_values_existing_lookup_table_with_null_row(fresh_db): def test_extract_null_values_existing_lookup_table_with_null_row(fresh_db):
# Even if the lookup table already contains an all-null row, rows where # Even if the lookup table already contains an all-null row, rows where
# every extracted column is null should keep a null foreign key # every extracted column is null should keep a null foreign key
fresh_db["species"].insert({"id": 1, "species": None}, pk="id") fresh_db.table("species").insert({"id": 1, "species": None}, pk="id")
fresh_db["individuals"].insert_all( fresh_db.table("individuals").insert_all(
[ [
{"id": 10, "name": "Terriana", "species": "Fox"}, {"id": 10, "name": "Terriana", "species": "Fox"},
{"id": 11, "name": "Spenidorm", "species": None}, {"id": 11, "name": "Spenidorm", "species": None},
], ],
pk="id", pk="id",
) )
fresh_db["individuals"].extract("species") fresh_db.table("individuals").extract("species")
assert list(fresh_db["species"].rows) == [ assert list(fresh_db.table("species").rows) == [
{"id": 1, "species": None}, {"id": 1, "species": None},
{"id": 2, "species": "Fox"}, {"id": 2, "species": "Fox"},
] ]
assert list(fresh_db["individuals"].rows) == [ assert list(fresh_db.table("individuals").rows) == [
{"id": 10, "name": "Terriana", "species_id": 2}, {"id": 10, "name": "Terriana", "species_id": 2},
{"id": 11, "name": "Spenidorm", "species_id": None}, {"id": 11, "name": "Spenidorm", "species_id": None},
] ]
@ -278,17 +280,19 @@ def test_extract_repeated_into_shared_lookup_with_nulls(fresh_db):
# cannot dedupe NULL-containing rows against the existing lookup # cannot dedupe NULL-containing rows against the existing lookup
# table - extracting a second table into the same lookup previously # table - extracting a second table into the same lookup previously
# inserted duplicate rows that nothing pointed to # inserted duplicate rows that nothing pointed to
fresh_db["t1"].insert_all( fresh_db.table("t1").insert_all(
[ [
{"id": 1, "species": None, "common": "X"}, {"id": 1, "species": None, "common": "X"},
{"id": 2, "species": "Oak", "common": "Oak"}, {"id": 2, "species": "Oak", "common": "Oak"},
], ],
pk="id", pk="id",
) )
fresh_db["t2"].insert_all([{"id": 1, "species": None, "common": "X"}], pk="id") fresh_db.table("t2").insert_all(
fresh_db["t1"].extract(["species", "common"], table="lk") [{"id": 1, "species": None, "common": "X"}], pk="id"
fresh_db["t2"].extract(["species", "common"], table="lk") )
assert fresh_db["lk"].count == 2 fresh_db.table("t1").extract(["species", "common"], table="lk")
fresh_db.table("t2").extract(["species", "common"], table="lk")
assert fresh_db.table("lk").count == 2
# Both tables point at the same lookup row # Both tables point at the same lookup row
t1_fk = fresh_db.execute("select lk_id from t1 where id = 1").fetchone()[0] t1_fk = fresh_db.execute("select lk_id from t1 where id = 1").fetchone()[0]
t2_fk = fresh_db.execute("select lk_id from t2 where id = 1").fetchone()[0] t2_fk = fresh_db.execute("select lk_id from t2 where id = 1").fetchone()[0]
@ -297,8 +301,43 @@ def test_extract_repeated_into_shared_lookup_with_nulls(fresh_db):
def test_extract_repeated_into_shared_lookup_no_nulls(fresh_db): def test_extract_repeated_into_shared_lookup_no_nulls(fresh_db):
# Non-NULL rows were already deduped by the unique index - keep it so # Non-NULL rows were already deduped by the unique index - keep it so
fresh_db["t1"].insert_all([{"id": 1, "species": "Oak"}], pk="id") fresh_db.table("t1").insert_all([{"id": 1, "species": "Oak"}], pk="id")
fresh_db["t2"].insert_all([{"id": 1, "species": "Oak"}], pk="id") fresh_db.table("t2").insert_all([{"id": 1, "species": "Oak"}], pk="id")
fresh_db["t1"].extract(["species"], table="lk") fresh_db.table("t1").extract(["species"], table="lk")
fresh_db["t2"].extract(["species"], table="lk") fresh_db.table("t2").extract(["species"], table="lk")
assert fresh_db["lk"].count == 1 assert fresh_db.table("lk").count == 1
def test_extract_preserves_strict_any(fresh_db):
if not fresh_db.supports_strict:
pytest.skip("SQLite version does not support strict tables")
fresh_db.execute("create table items (id integer primary key, data any) strict")
fresh_db.execute("insert into items values (1, ?)", ("000123",))
fresh_db["items"].extract("data", table="data_values")
lookup = fresh_db["data_values"]
assert lookup.strict is True
assert lookup.columns_dict == {"id": int, "data": ANY}
assert fresh_db.execute(
"select typeof(data), data from data_values"
).fetchone() == ("text", "000123")
def test_extract_strict_any_rejects_non_strict_lookup(fresh_db):
if not fresh_db.supports_strict:
pytest.skip("SQLite version does not support strict tables")
fresh_db.execute("create table items (data any) strict")
fresh_db.execute("insert into items values (?)", ("000123",))
fresh_db.execute("create table data_values (id integer primary key, data any)")
with pytest.raises(
InvalidColumns,
match="is not STRICT, so it cannot preserve ANY column values",
):
fresh_db["items"].extract("data", table="data_values")
assert fresh_db.execute("select typeof(data), data from items").fetchone() == (
"text",
"000123",
)

View file

@ -1,13 +1,14 @@
from sqlite_utils.db import Index
import pytest import pytest
from sqlite_utils.db import Index
@pytest.mark.parametrize( @pytest.mark.parametrize(
"kwargs,expected_table", "kwargs,expected_table",
[ [
(dict(extracts={"species_id": "Species"}), "Species"), ({"extracts": {"species_id": "Species"}}, "Species"),
(dict(extracts=["species_id"]), "species_id"), ({"extracts": ["species_id"]}, "species_id"),
(dict(extracts=("species_id",)), "species_id"), ({"extracts": ("species_id",)}, "species_id"),
], ],
) )
@pytest.mark.parametrize("use_table_factory", [True, False]) @pytest.mark.parametrize("use_table_factory", [True, False])
@ -30,20 +31,16 @@ def test_extracts(fresh_db, kwargs, expected_table, use_table_factory):
# Should now have two tables: Trees and Species # Should now have two tables: Trees and Species
assert {expected_table, "Trees"} == set(fresh_db.table_names()) assert {expected_table, "Trees"} == set(fresh_db.table_names())
assert ( assert (
'CREATE TABLE "{}" (\n "id" INTEGER PRIMARY KEY,\n "value" TEXT\n)'.format( f'CREATE TABLE "{expected_table}" (\n "id" INTEGER PRIMARY KEY,\n "value" TEXT\n)'
expected_table == fresh_db.table(expected_table).schema
)
== fresh_db[expected_table].schema
) )
assert ( assert (
'CREATE TABLE "Trees" (\n "id" INTEGER,\n "species_id" INTEGER REFERENCES "{}"("id")\n)'.format( f'CREATE TABLE "Trees" (\n "id" INTEGER,\n "species_id" INTEGER REFERENCES "{expected_table}"("id")\n)'
expected_table == fresh_db.table("Trees").schema
)
== fresh_db["Trees"].schema
) )
# Should have a foreign key reference # Should have a foreign key reference
assert len(fresh_db["Trees"].foreign_keys) == 1 assert len(fresh_db.table("Trees").foreign_keys) == 1
fk = fresh_db["Trees"].foreign_keys[0] fk = fresh_db.table("Trees").foreign_keys[0]
assert fk.table == "Trees" assert fk.table == "Trees"
assert fk.column == "species_id" assert fk.column == "species_id"
@ -51,28 +48,28 @@ def test_extracts(fresh_db, kwargs, expected_table, use_table_factory):
assert [ assert [
Index( Index(
seq=0, seq=0,
name="idx_{}_value".format(expected_table), name=f"idx_{expected_table}_value",
unique=1, unique=1,
origin="c", origin="c",
partial=0, partial=0,
columns=["value"], columns=["value"],
) )
] == fresh_db[expected_table].indexes ] == fresh_db.table(expected_table).indexes
# Finally, check the rows # Finally, check the rows
assert [{"id": 1, "value": "Oak"}, {"id": 2, "value": "Palm"}] == list( assert [{"id": 1, "value": "Oak"}, {"id": 2, "value": "Palm"}] == list(
fresh_db[expected_table].rows fresh_db.table(expected_table).rows
) )
assert [ assert [
{"id": 1, "species_id": 1}, {"id": 1, "species_id": 1},
{"id": 2, "species_id": 1}, {"id": 2, "species_id": 1},
{"id": 3, "species_id": 2}, {"id": 3, "species_id": 2},
] == list(fresh_db["Trees"].rows) ] == list(fresh_db.table("Trees").rows)
def test_extracts_null_values(fresh_db): def test_extracts_null_values(fresh_db):
# https://github.com/simonw/sqlite-utils/issues/186 # https://github.com/simonw/sqlite-utils/issues/186
# Null values should stay null, not be extracted into the lookup table # Null values should stay null, not be extracted into the lookup table
fresh_db["Trees"].insert_all( fresh_db.table("Trees").insert_all(
[ [
{"id": 1, "species_id": "Oak"}, {"id": 1, "species_id": "Oak"},
{"id": 2, "species_id": None}, {"id": 2, "species_id": None},
@ -81,11 +78,11 @@ def test_extracts_null_values(fresh_db):
], ],
extracts={"species_id": "Species"}, extracts={"species_id": "Species"},
) )
assert list(fresh_db["Species"].rows) == [ assert list(fresh_db.table("Species").rows) == [
{"id": 1, "value": "Oak"}, {"id": 1, "value": "Oak"},
{"id": 2, "value": "Palm"}, {"id": 2, "value": "Palm"},
] ]
assert list(fresh_db["Trees"].rows) == [ assert list(fresh_db.table("Trees").rows) == [
{"id": 1, "species_id": 1}, {"id": 1, "species_id": 1},
{"id": 2, "species_id": None}, {"id": 2, "species_id": None},
{"id": 3, "species_id": 2}, {"id": 3, "species_id": 2},
@ -95,7 +92,7 @@ def test_extracts_null_values(fresh_db):
def test_extracts_null_values_list_mode(fresh_db): def test_extracts_null_values_list_mode(fresh_db):
# Same as test_extracts_null_values but for list-based records # Same as test_extracts_null_values but for list-based records
fresh_db["Trees"].insert_all( fresh_db.table("Trees").insert_all(
[ [
["id", "species_id"], ["id", "species_id"],
[1, "Oak"], [1, "Oak"],
@ -105,11 +102,11 @@ def test_extracts_null_values_list_mode(fresh_db):
], ],
extracts={"species_id": "Species"}, extracts={"species_id": "Species"},
) )
assert list(fresh_db["Species"].rows) == [ assert list(fresh_db.table("Species").rows) == [
{"id": 1, "value": "Oak"}, {"id": 1, "value": "Oak"},
{"id": 2, "value": "Palm"}, {"id": 2, "value": "Palm"},
] ]
assert list(fresh_db["Trees"].rows) == [ assert list(fresh_db.table("Trees").rows) == [
{"id": 1, "species_id": 1}, {"id": 1, "species_id": 1},
{"id": 2, "species_id": None}, {"id": 2, "species_id": None},
{"id": 3, "species_id": 2}, {"id": 3, "species_id": 2},

View file

@ -1,6 +1,7 @@
"""Tests for compound (multi-column) foreign keys - issue #594.""" """Tests for compound (multi-column) foreign keys - issue #594."""
import pytest import pytest
from sqlite_utils import Database from sqlite_utils import Database
from sqlite_utils.db import AlterError, ForeignKey from sqlite_utils.db import AlterError, ForeignKey
from sqlite_utils.utils import sqlite3 from sqlite_utils.utils import sqlite3
@ -31,7 +32,7 @@ def compound_db():
def test_compound_foreign_key(compound_db): def test_compound_foreign_key(compound_db):
fks = compound_db["courses"].foreign_keys fks = compound_db.table("courses").foreign_keys
assert len(fks) == 1 assert len(fks) == 1
fk = fks[0] fk = fks[0]
assert fk.is_compound is True assert fk.is_compound is True
@ -45,10 +46,10 @@ def test_compound_foreign_key(compound_db):
def test_single_foreign_key_gets_columns_fields(fresh_db): def test_single_foreign_key_gets_columns_fields(fresh_db):
fresh_db["authors"].insert({"id": 1, "name": "Sally"}, pk="id") fresh_db.table("authors").insert({"id": 1, "name": "Sally"}, pk="id")
fresh_db["books"].insert({"title": "Hedgehogs", "author_id": 1}) fresh_db.table("books").insert({"title": "Hedgehogs", "author_id": 1})
fresh_db["books"].add_foreign_key("author_id", "authors", "id") fresh_db.table("books").add_foreign_key("author_id", "authors", "id")
fk = fresh_db["books"].foreign_keys[0] fk = fresh_db.table("books").foreign_keys[0]
assert fk.is_compound is False assert fk.is_compound is False
assert fk.column == "author_id" assert fk.column == "author_id"
assert fk.other_column == "id" assert fk.other_column == "id"
@ -59,27 +60,29 @@ def test_single_foreign_key_gets_columns_fields(fresh_db):
def test_foreign_key_no_longer_unpacks_as_tuple(fresh_db): def test_foreign_key_no_longer_unpacks_as_tuple(fresh_db):
# Clean break in 4.0: ForeignKey is a dataclass, not a namedtuple, so the # Clean break in 4.0: ForeignKey is a dataclass, not a namedtuple, so the
# old tuple unpacking and indexing patterns now fail hard. # old tuple unpacking and indexing patterns now fail hard.
fresh_db["authors"].insert({"id": 1, "name": "Sally"}, pk="id") fresh_db.table("authors").insert({"id": 1, "name": "Sally"}, pk="id")
fresh_db["books"].insert({"title": "Hedgehogs", "author_id": 1}) fresh_db.table("books").insert({"title": "Hedgehogs", "author_id": 1})
fresh_db["books"].add_foreign_key("author_id", "authors", "id") fresh_db.table("books").add_foreign_key("author_id", "authors", "id")
fk = fresh_db["books"].foreign_keys[0] fk = fresh_db.table("books").foreign_keys[0]
with pytest.raises(TypeError): with pytest.raises(TypeError):
table, column, other_table, other_column = fk _table, _column, _other_table, _other_column = fk
with pytest.raises(TypeError): with pytest.raises(TypeError):
fk[0] fk[0]
def test_foreign_keys_are_sortable(fresh_db): def test_foreign_keys_are_sortable(fresh_db):
fresh_db["authors"].insert({"id": 1, "name": "Sally"}, pk="id") fresh_db.table("authors").insert({"id": 1, "name": "Sally"}, pk="id")
fresh_db["categories"].insert({"id": 1, "name": "Wildlife"}, pk="id") fresh_db.table("categories").insert({"id": 1, "name": "Wildlife"}, pk="id")
fresh_db["books"].insert({"title": "Hedgehogs", "author_id": 1, "category_id": 1}) fresh_db.table("books").insert(
{"title": "Hedgehogs", "author_id": 1, "category_id": 1}
)
fresh_db.add_foreign_keys( fresh_db.add_foreign_keys(
[ [
("books", "author_id", "authors", "id"), ("books", "author_id", "authors", "id"),
("books", "category_id", "categories", "id"), ("books", "category_id", "categories", "id"),
] ]
) )
fks = sorted(fresh_db["books"].foreign_keys) fks = sorted(fresh_db.table("books").foreign_keys)
assert fks[0].column == "author_id" assert fks[0].column == "author_id"
assert fks[1].column == "category_id" assert fks[1].column == "category_id"
@ -104,7 +107,7 @@ def test_mixed_compound_and_single_foreign_keys_are_sortable():
REFERENCES departments(campus_name, dept_code) REFERENCES departments(campus_name, dept_code)
); );
""") """)
fks = db["courses"].foreign_keys fks = db.table("courses").foreign_keys
assert len(fks) == 2 assert len(fks) == 2
assert {fk.is_compound for fk in fks} == {True, False} assert {fk.is_compound for fk in fks} == {True, False}
fks_sorted = sorted(fks) fks_sorted = sorted(fks)
@ -162,8 +165,8 @@ def test_create_table_with_compound_foreign_key(departments_db, foreign_keys):
pk="course_code", pk="course_code",
foreign_keys=foreign_keys, foreign_keys=foreign_keys,
) )
assert departments_db["courses"].schema == EXPECTED_COURSES_SCHEMA assert departments_db.table("courses").schema == EXPECTED_COURSES_SCHEMA
fks = departments_db["courses"].foreign_keys fks = departments_db.table("courses").foreign_keys
assert len(fks) == 1 assert len(fks) == 1
fk = fks[0] fk = fks[0]
assert fk.is_compound is True assert fk.is_compound is True
@ -180,10 +183,10 @@ def test_create_table_compound_foreign_key_enforced(departments_db):
pk="course_code", pk="course_code",
foreign_keys=[(("campus_name", "dept_code"), "departments")], foreign_keys=[(("campus_name", "dept_code"), "departments")],
) )
departments_db["departments"].insert( departments_db.table("departments").insert(
{"campus_name": "Berkeley", "dept_code": "CS", "dept_name": "Computer Science"} {"campus_name": "Berkeley", "dept_code": "CS", "dept_name": "Computer Science"}
) )
departments_db["courses"].insert( departments_db.table("courses").insert(
{"course_code": "CS101", "campus_name": "Berkeley", "dept_code": "CS"} {"course_code": "CS101", "campus_name": "Berkeley", "dept_code": "CS"}
) )
with pytest.raises(sqlite3.IntegrityError): with pytest.raises(sqlite3.IntegrityError):
@ -206,8 +209,8 @@ def test_create_table_compound_foreign_key_missing_other_column(departments_db):
def test_transform_preserves_compound_foreign_key(compound_db): def test_transform_preserves_compound_foreign_key(compound_db):
compound_db["courses"].transform(rename={"course_name": "title"}) compound_db.table("courses").transform(rename={"course_name": "title"})
fks = compound_db["courses"].foreign_keys fks = compound_db.table("courses").foreign_keys
assert len(fks) == 1 assert len(fks) == 1
fk = fks[0] fk = fks[0]
assert fk.is_compound is True assert fk.is_compound is True
@ -217,8 +220,8 @@ def test_transform_preserves_compound_foreign_key(compound_db):
def test_transform_rename_member_column_updates_compound_foreign_key(compound_db): def test_transform_rename_member_column_updates_compound_foreign_key(compound_db):
compound_db["courses"].transform(rename={"campus_name": "campus"}) compound_db.table("courses").transform(rename={"campus_name": "campus"})
fks = compound_db["courses"].foreign_keys fks = compound_db.table("courses").foreign_keys
assert len(fks) == 1 assert len(fks) == 1
fk = fks[0] fk = fks[0]
assert fk.is_compound is True assert fk.is_compound is True
@ -230,9 +233,9 @@ def test_transform_rename_member_column_updates_compound_foreign_key(compound_db
def test_transform_drop_member_column_drops_compound_foreign_key(compound_db): def test_transform_drop_member_column_drops_compound_foreign_key(compound_db):
# Matches single-column behavior: dropping the column silently # Matches single-column behavior: dropping the column silently
# drops the foreign key that used it # drops the foreign key that used it
compound_db["courses"].transform(drop={"dept_code"}) compound_db.table("courses").transform(drop={"dept_code"})
assert compound_db["courses"].foreign_keys == [] assert compound_db.table("courses").foreign_keys == []
assert "FOREIGN KEY" not in compound_db["courses"].schema assert "FOREIGN KEY" not in compound_db.table("courses").schema
@pytest.mark.parametrize( @pytest.mark.parametrize(
@ -245,11 +248,11 @@ def test_transform_drop_member_column_drops_compound_foreign_key(compound_db):
), ),
) )
def test_transform_drop_compound_foreign_key(compound_db, drop_foreign_keys): def test_transform_drop_compound_foreign_key(compound_db, drop_foreign_keys):
compound_db["courses"].transform(drop_foreign_keys=drop_foreign_keys) compound_db.table("courses").transform(drop_foreign_keys=drop_foreign_keys)
assert compound_db["courses"].foreign_keys == [] assert compound_db.table("courses").foreign_keys == []
# The columns themselves survive # The columns themselves survive
assert {"campus_name", "dept_code"} <= set( assert {"campus_name", "dept_code"} <= set(
compound_db["courses"].columns_dict.keys() compound_db.table("courses").columns_dict.keys()
) )
@ -264,12 +267,12 @@ def courses_db(departments_db):
def test_add_compound_foreign_key(courses_db): def test_add_compound_foreign_key(courses_db):
t = courses_db["courses"].add_foreign_key( t = courses_db.table("courses").add_foreign_key(
("campus_name", "dept_code"), "departments", ("campus_name", "dept_code") ("campus_name", "dept_code"), "departments", ("campus_name", "dept_code")
) )
# Returns self # Returns self
assert t.name == "courses" assert t.name == "courses"
fks = courses_db["courses"].foreign_keys fks = courses_db.table("courses").foreign_keys
assert len(fks) == 1 assert len(fks) == 1
fk = fks[0] fk = fks[0]
assert fk.is_compound is True assert fk.is_compound is True
@ -280,27 +283,33 @@ def test_add_compound_foreign_key(courses_db):
def test_add_compound_foreign_key_guesses_other_columns(courses_db): def test_add_compound_foreign_key_guesses_other_columns(courses_db):
# Lists work here too, though tuples are the documented form # Lists work here too, though tuples are the documented form
courses_db["courses"].add_foreign_key(["campus_name", "dept_code"], "departments") courses_db.table("courses").add_foreign_key(
fk = courses_db["courses"].foreign_keys[0] ["campus_name", "dept_code"], "departments"
)
fk = courses_db.table("courses").foreign_keys[0]
assert fk.other_columns == ("campus_name", "dept_code") assert fk.other_columns == ("campus_name", "dept_code")
def test_add_compound_foreign_key_error_if_already_exists(courses_db): def test_add_compound_foreign_key_error_if_already_exists(courses_db):
courses_db["courses"].add_foreign_key(("campus_name", "dept_code"), "departments") courses_db.table("courses").add_foreign_key(
("campus_name", "dept_code"), "departments"
)
with pytest.raises(AlterError) as ex: with pytest.raises(AlterError) as ex:
courses_db["courses"].add_foreign_key( courses_db.table("courses").add_foreign_key(
("campus_name", "dept_code"), "departments" ("campus_name", "dept_code"), "departments"
) )
assert "already exists" in ex.value.args[0] assert "already exists" in ex.value.args[0]
# ignore=True should not raise # ignore=True should not raise
courses_db["courses"].add_foreign_key( courses_db.table("courses").add_foreign_key(
("campus_name", "dept_code"), "departments", ignore=True ("campus_name", "dept_code"), "departments", ignore=True
) )
def test_add_compound_foreign_key_error_if_column_missing(courses_db): def test_add_compound_foreign_key_error_if_column_missing(courses_db):
with pytest.raises(AlterError): with pytest.raises(AlterError):
courses_db["courses"].add_foreign_key(("campus_name", "nope"), "departments") courses_db.table("courses").add_foreign_key(
("campus_name", "nope"), "departments"
)
def test_db_add_foreign_keys_compound(courses_db): def test_db_add_foreign_keys_compound(courses_db):
@ -314,14 +323,14 @@ def test_db_add_foreign_keys_compound(courses_db):
) )
] ]
) )
fk = courses_db["courses"].foreign_keys[0] fk = courses_db.table("courses").foreign_keys[0]
assert fk.is_compound is True assert fk.is_compound is True
assert fk.columns == ("campus_name", "dept_code") assert fk.columns == ("campus_name", "dept_code")
def test_index_foreign_keys_compound_creates_composite_index(compound_db): def test_index_foreign_keys_compound_creates_composite_index(compound_db):
compound_db.index_foreign_keys() compound_db.index_foreign_keys()
index_columns = [i.columns for i in compound_db["courses"].indexes] index_columns = [i.columns for i in compound_db.table("courses").indexes]
assert ["campus_name", "dept_code"] in index_columns assert ["campus_name", "dept_code"] in index_columns
# No separate single-column indexes for the members # No separate single-column indexes for the members
assert ["campus_name"] not in index_columns assert ["campus_name"] not in index_columns
@ -338,22 +347,22 @@ def test_foreign_key_captures_on_delete_and_on_update():
ON DELETE CASCADE ON UPDATE RESTRICT ON DELETE CASCADE ON UPDATE RESTRICT
); );
""") """)
fk = db["books"].foreign_keys[0] fk = db.table("books").foreign_keys[0]
assert fk.on_delete == "CASCADE" assert fk.on_delete == "CASCADE"
assert fk.on_update == "RESTRICT" assert fk.on_update == "RESTRICT"
def test_foreign_key_on_delete_defaults_to_no_action(fresh_db): def test_foreign_key_on_delete_defaults_to_no_action(fresh_db):
fresh_db["authors"].insert({"id": 1}, pk="id") fresh_db.table("authors").insert({"id": 1}, pk="id")
fresh_db["books"].insert({"id": 1, "author_id": 1}, pk="id") fresh_db.table("books").insert({"id": 1, "author_id": 1}, pk="id")
fresh_db["books"].add_foreign_key("author_id", "authors", "id") fresh_db.table("books").add_foreign_key("author_id", "authors", "id")
fk = fresh_db["books"].foreign_keys[0] fk = fresh_db.table("books").foreign_keys[0]
assert fk.on_delete == "NO ACTION" assert fk.on_delete == "NO ACTION"
assert fk.on_update == "NO ACTION" assert fk.on_update == "NO ACTION"
def test_create_table_foreign_key_with_on_delete(fresh_db): def test_create_table_foreign_key_with_on_delete(fresh_db):
fresh_db["authors"].insert({"id": 1}, pk="id") fresh_db.table("authors").insert({"id": 1}, pk="id")
fresh_db.create_table( fresh_db.create_table(
"books", "books",
{"id": int, "author_id": int}, {"id": int, "author_id": int},
@ -368,8 +377,8 @@ def test_create_table_foreign_key_with_on_delete(fresh_db):
) )
], ],
) )
assert "ON DELETE CASCADE" in fresh_db["books"].schema assert "ON DELETE CASCADE" in fresh_db.table("books").schema
assert fresh_db["books"].foreign_keys[0].on_delete == "CASCADE" assert fresh_db.table("books").foreign_keys[0].on_delete == "CASCADE"
def test_transform_preserves_on_delete_cascade(): def test_transform_preserves_on_delete_cascade():
@ -382,11 +391,11 @@ def test_transform_preserves_on_delete_cascade():
author_id INTEGER REFERENCES authors(id) ON DELETE CASCADE author_id INTEGER REFERENCES authors(id) ON DELETE CASCADE
); );
""") """)
db["books"].transform(rename={"title": "book_title"}) db.table("books").transform(rename={"title": "book_title"})
fk = db["books"].foreign_keys[0] fk = db.table("books").foreign_keys[0]
assert fk.on_delete == "CASCADE" assert fk.on_delete == "CASCADE"
assert fk.on_update == "NO ACTION" assert fk.on_update == "NO ACTION"
assert "ON DELETE CASCADE" in db["books"].schema assert "ON DELETE CASCADE" in db.table("books").schema
def test_transform_preserves_compound_foreign_key_on_delete(): def test_transform_preserves_compound_foreign_key_on_delete():
@ -405,11 +414,11 @@ def test_transform_preserves_compound_foreign_key_on_delete():
REFERENCES departments(campus_name, dept_code) ON DELETE CASCADE REFERENCES departments(campus_name, dept_code) ON DELETE CASCADE
); );
""") """)
db["courses"].transform(rename={"course_code": "code"}) db.table("courses").transform(rename={"course_code": "code"})
fk = db["courses"].foreign_keys[0] fk = db.table("courses").foreign_keys[0]
assert fk.is_compound is True assert fk.is_compound is True
assert fk.on_delete == "CASCADE" assert fk.on_delete == "CASCADE"
assert "ON DELETE CASCADE" in db["courses"].schema assert "ON DELETE CASCADE" in db.table("courses").schema
def test_implicit_primary_key_reference_is_resolved(): def test_implicit_primary_key_reference_is_resolved():
@ -423,7 +432,7 @@ def test_implicit_primary_key_reference_is_resolved():
author_id INTEGER REFERENCES authors author_id INTEGER REFERENCES authors
); );
""") """)
fk = db["books"].foreign_keys[0] fk = db.table("books").foreign_keys[0]
assert fk.is_compound is False assert fk.is_compound is False
assert fk.other_column == "author_id" assert fk.other_column == "author_id"
assert fk.other_columns == ("author_id",) assert fk.other_columns == ("author_id",)
@ -444,7 +453,7 @@ def test_implicit_compound_primary_key_reference_is_resolved():
FOREIGN KEY (campus_name, dept_code) REFERENCES departments FOREIGN KEY (campus_name, dept_code) REFERENCES departments
); );
""") """)
fk = db["courses"].foreign_keys[0] fk = db.table("courses").foreign_keys[0]
assert fk.is_compound is True assert fk.is_compound is True
assert fk.other_columns == ("campus_name", "dept_code") assert fk.other_columns == ("campus_name", "dept_code")
@ -469,14 +478,14 @@ def test_add_foreign_keys_preserves_actions(fresh_db):
# https://github.com/simonw/sqlite-utils/issues/594 review finding: # https://github.com/simonw/sqlite-utils/issues/594 review finding:
# ForeignKey objects passed to db.add_foreign_keys() were flattened # ForeignKey objects passed to db.add_foreign_keys() were flattened
# to plain tuples, losing on_delete/on_update # to plain tuples, losing on_delete/on_update
fresh_db["authors"].insert({"id": 1}, pk="id") fresh_db.table("authors").insert({"id": 1}, pk="id")
fresh_db["books"].insert({"id": 1, "author_id": 1}, pk="id") fresh_db.table("books").insert({"id": 1, "author_id": 1}, pk="id")
fresh_db.add_foreign_keys( fresh_db.add_foreign_keys(
[ForeignKey("books", "author_id", "authors", "id", on_delete="CASCADE")] [ForeignKey("books", "author_id", "authors", "id", on_delete="CASCADE")]
) )
fk = fresh_db["books"].foreign_keys[0] fk = fresh_db.table("books").foreign_keys[0]
assert fk.on_delete == "CASCADE" assert fk.on_delete == "CASCADE"
assert "ON DELETE CASCADE" in fresh_db["books"].schema assert "ON DELETE CASCADE" in fresh_db.table("books").schema
def test_add_foreign_keys_preserves_actions_compound(courses_db): def test_add_foreign_keys_preserves_actions_compound(courses_db):
@ -494,36 +503,36 @@ def test_add_foreign_keys_preserves_actions_compound(courses_db):
) )
] ]
) )
fk = courses_db["courses"].foreign_keys[0] fk = courses_db.table("courses").foreign_keys[0]
assert fk.is_compound is True assert fk.is_compound is True
assert fk.on_delete == "CASCADE" assert fk.on_delete == "CASCADE"
assert "ON DELETE CASCADE" in courses_db["courses"].schema assert "ON DELETE CASCADE" in courses_db.table("courses").schema
def test_add_foreign_key_on_delete_on_update(fresh_db): def test_add_foreign_key_on_delete_on_update(fresh_db):
fresh_db["authors"].insert({"id": 1}, pk="id") fresh_db.table("authors").insert({"id": 1}, pk="id")
fresh_db["books"].insert({"id": 1, "author_id": 1}, pk="id") fresh_db.table("books").insert({"id": 1, "author_id": 1}, pk="id")
fresh_db["books"].add_foreign_key( fresh_db.table("books").add_foreign_key(
"author_id", "authors", "id", on_delete="CASCADE", on_update="RESTRICT" "author_id", "authors", "id", on_delete="CASCADE", on_update="RESTRICT"
) )
fk = fresh_db["books"].foreign_keys[0] fk = fresh_db.table("books").foreign_keys[0]
assert fk.on_delete == "CASCADE" assert fk.on_delete == "CASCADE"
assert fk.on_update == "RESTRICT" assert fk.on_update == "RESTRICT"
assert "ON UPDATE RESTRICT ON DELETE CASCADE" in fresh_db["books"].schema assert "ON UPDATE RESTRICT ON DELETE CASCADE" in fresh_db.table("books").schema
# The cascade should actually fire # The cascade should actually fire
fresh_db.execute("PRAGMA foreign_keys = ON") fresh_db.execute("PRAGMA foreign_keys = ON")
fresh_db.execute("delete from authors where id = 1") fresh_db.execute("delete from authors where id = 1")
assert fresh_db["books"].count == 0 assert fresh_db.table("books").count == 0
def test_add_compound_foreign_key_on_delete(courses_db): def test_add_compound_foreign_key_on_delete(courses_db):
courses_db["courses"].add_foreign_key( courses_db.table("courses").add_foreign_key(
("campus_name", "dept_code"), "departments", on_delete="SET NULL" ("campus_name", "dept_code"), "departments", on_delete="SET NULL"
) )
fk = courses_db["courses"].foreign_keys[0] fk = courses_db.table("courses").foreign_keys[0]
assert fk.is_compound is True assert fk.is_compound is True
assert fk.on_delete == "SET NULL" assert fk.on_delete == "SET NULL"
assert "ON DELETE SET NULL" in courses_db["courses"].schema assert "ON DELETE SET NULL" in courses_db.table("courses").schema
def test_implicit_compound_foreign_key_resolves_pk_declaration_order(fresh_db): def test_implicit_compound_foreign_key_resolves_pk_declaration_order(fresh_db):
@ -535,7 +544,7 @@ def test_implicit_compound_foreign_key_resolves_pk_declaration_order(fresh_db):
fresh_db.execute( fresh_db.execute(
"create table child (x text, y text, foreign key (x, y) references other)" "create table child (x text, y text, foreign key (x, y) references other)"
) )
fk = fresh_db["child"].foreign_keys[0] fk = fresh_db.table("child").foreign_keys[0]
assert fk.other_columns == ("a", "b") assert fk.other_columns == ("a", "b")
@ -548,46 +557,46 @@ def test_transform_implicit_compound_foreign_key_stays_valid(fresh_db):
"create table child (x text, y text, foreign key (x, y) references other)" "create table child (x text, y text, foreign key (x, y) references other)"
) )
fresh_db.execute("PRAGMA foreign_keys = ON") fresh_db.execute("PRAGMA foreign_keys = ON")
fresh_db["other"].insert({"a": "A", "b": "B"}) fresh_db.table("other").insert({"a": "A", "b": "B"})
fresh_db["child"].insert({"x": "A", "y": "B"}) fresh_db.table("child").insert({"x": "A", "y": "B"})
fresh_db["child"].transform(types={"x": str}) fresh_db.table("child").transform(types={"x": str})
assert fresh_db["child"].foreign_keys[0].other_columns == ("a", "b") assert fresh_db.table("child").foreign_keys[0].other_columns == ("a", "b")
# The constraint still points the right way around # The constraint still points the right way around
fresh_db["child"].insert({"x": "A", "y": "B"}) fresh_db.table("child").insert({"x": "A", "y": "B"})
with pytest.raises(sqlite3.IntegrityError): with pytest.raises(sqlite3.IntegrityError):
fresh_db["child"].insert({"x": "B", "y": "A"}) fresh_db.table("child").insert({"x": "B", "y": "A"})
def test_create_compound_foreign_key_guesses_pk_declaration_order(fresh_db): def test_create_compound_foreign_key_guesses_pk_declaration_order(fresh_db):
fresh_db.execute("create table other (b text, a text, primary key (a, b))") fresh_db.execute("create table other (b text, a text, primary key (a, b))")
fresh_db["other"].insert({"a": "A", "b": "B"}) fresh_db.table("other").insert({"a": "A", "b": "B"})
fresh_db["child"].create( fresh_db.table("child").create(
{"id": int, "x": str, "y": str}, {"id": int, "x": str, "y": str},
pk="id", pk="id",
foreign_keys=[(("x", "y"), "other")], foreign_keys=[(("x", "y"), "other")],
) )
assert fresh_db["child"].foreign_keys[0].other_columns == ("a", "b") assert fresh_db.table("child").foreign_keys[0].other_columns == ("a", "b")
fresh_db.execute("PRAGMA foreign_keys = ON") fresh_db.execute("PRAGMA foreign_keys = ON")
fresh_db["child"].insert({"id": 1, "x": "A", "y": "B"}) fresh_db.table("child").insert({"id": 1, "x": "A", "y": "B"})
with pytest.raises(sqlite3.IntegrityError): with pytest.raises(sqlite3.IntegrityError):
fresh_db["child"].insert({"id": 2, "x": "B", "y": "A"}) fresh_db.table("child").insert({"id": 2, "x": "B", "y": "A"})
def test_add_compound_foreign_key_guesses_pk_declaration_order(fresh_db): def test_add_compound_foreign_key_guesses_pk_declaration_order(fresh_db):
fresh_db.execute("create table other (b text, a text, primary key (a, b))") fresh_db.execute("create table other (b text, a text, primary key (a, b))")
fresh_db["child"].insert({"id": 1, "x": "A", "y": "B"}, pk="id") fresh_db.table("child").insert({"id": 1, "x": "A", "y": "B"}, pk="id")
fresh_db["child"].add_foreign_key(("x", "y"), "other") fresh_db.table("child").add_foreign_key(("x", "y"), "other")
assert fresh_db["child"].foreign_keys[0].other_columns == ("a", "b") assert fresh_db.table("child").foreign_keys[0].other_columns == ("a", "b")
def test_foreign_keys_are_hashable(fresh_db): def test_foreign_keys_are_hashable(fresh_db):
# set() over foreign_keys worked with the 3.x namedtuple and must # set() over foreign_keys worked with the 3.x namedtuple and must
# keep working with the dataclass # keep working with the dataclass
fresh_db["p"].insert({"id": 1}, pk="id") fresh_db.table("p").insert({"id": 1}, pk="id")
fresh_db["c"].insert( fresh_db.table("c").insert(
{"id": 1, "pid": 1}, pk="id", foreign_keys=[("pid", "p", "id")] {"id": 1, "pid": 1}, pk="id", foreign_keys=[("pid", "p", "id")]
) )
fks = set(fresh_db["c"].foreign_keys) fks = set(fresh_db.table("c").foreign_keys)
assert len(fks) == 1 assert len(fks) == 1
assert ForeignKey("c", "pid", "p", "id") in fks assert ForeignKey("c", "pid", "p", "id") in fks
# Usable as dict keys too # Usable as dict keys too
@ -599,7 +608,7 @@ def test_foreign_key_is_immutable():
fk = ForeignKey("c", "pid", "p", "id") fk = ForeignKey("c", "pid", "p", "id")
with pytest.raises(dataclasses.FrozenInstanceError): with pytest.raises(dataclasses.FrozenInstanceError):
fk.table = "other" setattr(fk, "table", "other")
def test_foreign_key_equality_and_hash_include_actions(): def test_foreign_key_equality_and_hash_include_actions():
@ -616,9 +625,9 @@ def test_create_table_mixed_foreign_keys_list(fresh_db):
# 3.x accepted a mix of ForeignKey objects, tuples and bare column # 3.x accepted a mix of ForeignKey objects, tuples and bare column
# strings in foreign_keys= (ForeignKey was a namedtuple, so it passed # strings in foreign_keys= (ForeignKey was a namedtuple, so it passed
# the tuple check) - keep accepting the mix # the tuple check) - keep accepting the mix
fresh_db["authors"].insert({"id": 1}, pk="id") fresh_db.table("authors").insert({"id": 1}, pk="id")
fresh_db["publishers"].insert({"id": 1}, pk="id") fresh_db.table("publishers").insert({"id": 1}, pk="id")
fresh_db["books"].create( fresh_db.table("books").create(
{"id": int, "author_id": int, "publisher_id": int}, {"id": int, "author_id": int, "publisher_id": int},
pk="id", pk="id",
foreign_keys=[ foreign_keys=[
@ -626,14 +635,14 @@ def test_create_table_mixed_foreign_keys_list(fresh_db):
("publisher_id", "publishers", "id"), ("publisher_id", "publishers", "id"),
], ],
) )
fks = {fk.column: fk.other_table for fk in fresh_db["books"].foreign_keys} fks = {fk.column: fk.other_table for fk in fresh_db.table("books").foreign_keys}
assert fks == {"author_id": "authors", "publisher_id": "publishers"} assert fks == {"author_id": "authors", "publisher_id": "publishers"}
def test_create_table_mixed_foreign_keys_with_string(fresh_db): def test_create_table_mixed_foreign_keys_with_string(fresh_db):
fresh_db["authors"].insert({"id": 1}, pk="id") fresh_db.table("authors").insert({"id": 1}, pk="id")
fresh_db["publishers"].insert({"id": 1}, pk="id") fresh_db.table("publishers").insert({"id": 1}, pk="id")
fresh_db["books"].create( fresh_db.table("books").create(
{"id": int, "author_id": int, "publisher_id": int}, {"id": int, "author_id": int, "publisher_id": int},
pk="id", pk="id",
foreign_keys=[ foreign_keys=[
@ -641,15 +650,15 @@ def test_create_table_mixed_foreign_keys_with_string(fresh_db):
("publisher_id", "publishers", "id"), ("publisher_id", "publishers", "id"),
], ],
) )
fks = {fk.column: fk.other_table for fk in fresh_db["books"].foreign_keys} fks = {fk.column: fk.other_table for fk in fresh_db.table("books").foreign_keys}
assert fks == {"author_id": "authors", "publisher_id": "publishers"} assert fks == {"author_id": "authors", "publisher_id": "publishers"}
def test_add_foreign_keys_existing_with_different_actions_errors(fresh_db): def test_add_foreign_keys_existing_with_different_actions_errors(fresh_db):
# Requesting an existing foreign key with different ON DELETE/ON UPDATE # Requesting an existing foreign key with different ON DELETE/ON UPDATE
# actions was silently skipped, dropping the requested change # actions was silently skipped, dropping the requested change
fresh_db["authors"].insert({"id": 1}, pk="id") fresh_db.table("authors").insert({"id": 1}, pk="id")
fresh_db["books"].insert( fresh_db.table("books").insert(
{"id": 1, "author_id": 1}, {"id": 1, "author_id": 1},
pk="id", pk="id",
foreign_keys=[("author_id", "authors", "id")], foreign_keys=[("author_id", "authors", "id")],
@ -659,19 +668,21 @@ def test_add_foreign_keys_existing_with_different_actions_errors(fresh_db):
[ForeignKey("books", "author_id", "authors", "id", on_delete="CASCADE")] [ForeignKey("books", "author_id", "authors", "id", on_delete="CASCADE")]
) )
assert "ON DELETE" in str(ex.value) assert "ON DELETE" in str(ex.value)
assert fresh_db["books"].foreign_keys[0].on_delete == "NO ACTION" assert fresh_db.table("books").foreign_keys[0].on_delete == "NO ACTION"
def test_add_foreign_keys_identical_existing_is_noop(fresh_db): def test_add_foreign_keys_identical_existing_is_noop(fresh_db):
# An exact match, including actions, is silently skipped so repeated # An exact match, including actions, is silently skipped so repeated
# calls stay idempotent # calls stay idempotent
fresh_db["authors"].insert({"id": 1}, pk="id") fresh_db.table("authors").insert({"id": 1}, pk="id")
fresh_db["books"].insert({"id": 1, "author_id": 1}, pk="id") fresh_db.table("books").insert({"id": 1, "author_id": 1}, pk="id")
fresh_db["books"].add_foreign_key("author_id", "authors", "id", on_delete="CASCADE") fresh_db.table("books").add_foreign_key(
"author_id", "authors", "id", on_delete="CASCADE"
)
fresh_db.add_foreign_keys( fresh_db.add_foreign_keys(
[ForeignKey("books", "author_id", "authors", "id", on_delete="CASCADE")] [ForeignKey("books", "author_id", "authors", "id", on_delete="CASCADE")]
) )
fks = fresh_db["books"].foreign_keys fks = fresh_db.table("books").foreign_keys
assert len(fks) == 1 assert len(fks) == 1
assert fks[0].on_delete == "CASCADE" assert fks[0].on_delete == "CASCADE"
@ -679,13 +690,13 @@ def test_add_foreign_keys_identical_existing_is_noop(fresh_db):
def test_add_foreign_keys_compound_column_count_mismatch_errors(fresh_db): def test_add_foreign_keys_compound_column_count_mismatch_errors(fresh_db):
# Previously the extra other-column was silently discarded, creating # Previously the extra other-column was silently discarded, creating
# a single-column foreign key to just ("id") # a single-column foreign key to just ("id")
fresh_db["departments"].insert( fresh_db.table("departments").insert(
{"campus": "north", "code": "cs"}, pk=("campus", "code") {"campus": "north", "code": "cs"}, pk=("campus", "code")
) )
fresh_db["courses"].insert({"id": 1, "campus": "north"}, pk="id") fresh_db.table("courses").insert({"id": 1, "campus": "north"}, pk="id")
with pytest.raises(ValueError) as ex: with pytest.raises(ValueError) as ex:
fresh_db.add_foreign_keys( fresh_db.add_foreign_keys(
[("courses", ("campus",), "departments", ("campus", "code"))] [("courses", ("campus",), "departments", ("campus", "code"))]
) )
assert "same number of columns" in str(ex.value) assert "same number of columns" in str(ex.value)
assert fresh_db["courses"].foreign_keys == [] assert fresh_db.table("courses").foreign_keys == []

View file

@ -1,7 +1,9 @@
from unittest.mock import ANY
import pytest import pytest
from sqlite_utils import Database from sqlite_utils import Database
from sqlite_utils.utils import sqlite3 from sqlite_utils.utils import sqlite3
from unittest.mock import ANY
search_records = [ search_records = [
{ {
@ -18,7 +20,7 @@ search_records = [
def test_enable_fts(fresh_db): def test_enable_fts(fresh_db):
table = fresh_db["searchable"] table = fresh_db.table("searchable")
table.insert_all(search_records) table.insert_all(search_records)
assert ["searchable"] == fresh_db.table_names() assert ["searchable"] == fresh_db.table_names()
table.enable_fts(["text", "country"], fts_version="FTS4") table.enable_fts(["text", "country"], fts_version="FTS4")
@ -52,7 +54,7 @@ def test_enable_fts(fresh_db):
def test_enable_fts_escape_table_names(fresh_db): def test_enable_fts_escape_table_names(fresh_db):
# Table names with restricted chars are handled correctly. # Table names with restricted chars are handled correctly.
# colons and dots are restricted characters for table names. # colons and dots are restricted characters for table names.
table = fresh_db["http://example.com"] table = fresh_db.table("http://example.com")
table.insert_all(search_records) table.insert_all(search_records)
assert ["http://example.com"] == fresh_db.table_names() assert ["http://example.com"] == fresh_db.table_names()
table.enable_fts(["text", "country"], fts_version="FTS4") table.enable_fts(["text", "country"], fts_version="FTS4")
@ -85,7 +87,7 @@ def test_enable_fts_escape_table_names(fresh_db):
def test_search_duplicate_columns_are_deduped(fresh_db): def test_search_duplicate_columns_are_deduped(fresh_db):
# https://github.com/simonw/sqlite-utils/issues/624 # https://github.com/simonw/sqlite-utils/issues/624
table = fresh_db["t"] table = fresh_db.table("t")
table.insert_all(search_records) table.insert_all(search_records)
table.enable_fts(["text", "country"], fts_version="FTS4") table.enable_fts(["text", "country"], fts_version="FTS4")
rows = list(table.search("tanuki", columns=["text", "text"])) rows = list(table.search("tanuki", columns=["text", "text"]))
@ -98,20 +100,32 @@ def test_search_duplicate_columns_are_deduped(fresh_db):
def test_search_limit_offset(fresh_db): def test_search_limit_offset(fresh_db):
table = fresh_db["t"] table = fresh_db.table("t")
table.insert_all(search_records) table.insert_all(search_records)
table.enable_fts(["text", "country"], fts_version="FTS4") table.enable_fts(["text", "country"], fts_version="FTS4")
assert len(list(table.search("are"))) == 2 assert len(list(table.search("are"))) == 2
assert len(list(table.search("are", limit=1))) == 1 assert len(list(table.search("are", limit=1))) == 1
assert list(table.search("are", limit=1, order_by="rowid"))[0]["rowid"] == 1 assert next(iter(table.search("are", limit=1, order_by="rowid")))["rowid"] == 1
assert ( assert (
list(table.search("are", limit=1, offset=1, order_by="rowid"))[0]["rowid"] == 2 next(iter(table.search("are", limit=1, offset=1, order_by="rowid")))["rowid"]
== 2
) )
def test_search_offset_without_limit(fresh_db):
table = fresh_db.table("t")
table.insert_all(search_records)
table.enable_fts(["text", "country"], fts_version="FTS4")
assert [row["rowid"] for row in table.search("are", order_by="rowid")] == [1, 2]
assert [
row["rowid"] for row in table.search("are", offset=1, order_by="rowid")
] == [2]
assert table.search_sql(offset=1).strip().endswith("limit -1 offset 1")
@pytest.mark.parametrize("fts_version", ("FTS4", "FTS5")) @pytest.mark.parametrize("fts_version", ("FTS4", "FTS5"))
def test_search_where(fresh_db, fts_version): def test_search_where(fresh_db, fts_version):
table = fresh_db["t"] table = fresh_db.table("t")
table.insert_all(search_records) table.insert_all(search_records)
table.enable_fts(["text", "country"], fts_version=fts_version) table.enable_fts(["text", "country"], fts_version=fts_version)
results = list( results = list(
@ -128,7 +142,7 @@ def test_search_where(fresh_db, fts_version):
def test_search_where_args_disallows_query(fresh_db): def test_search_where_args_disallows_query(fresh_db):
table = fresh_db["t"] table = fresh_db.table("t")
with pytest.raises(ValueError) as ex: with pytest.raises(ValueError) as ex:
list( list(
table.search( table.search(
@ -142,7 +156,7 @@ def test_search_where_args_disallows_query(fresh_db):
def test_search_include_rank(fresh_db): def test_search_include_rank(fresh_db):
table = fresh_db["t"] table = fresh_db.table("t")
table.insert_all(search_records) table.insert_all(search_records)
table.enable_fts(["text", "country"], fts_version="FTS5") table.enable_fts(["text", "country"], fts_version="FTS5")
results = list(table.search("are", include_rank=True)) results = list(table.search("are", include_rank=True))
@ -168,7 +182,7 @@ def test_search_include_rank(fresh_db):
def test_enable_fts_table_names_containing_spaces(fresh_db): def test_enable_fts_table_names_containing_spaces(fresh_db):
table = fresh_db["test"] table = fresh_db.table("test")
table.insert({"column with spaces": "in its name"}) table.insert({"column with spaces": "in its name"})
table.enable_fts(["column with spaces"]) table.enable_fts(["column with spaces"])
assert [ assert [
@ -182,7 +196,7 @@ def test_enable_fts_table_names_containing_spaces(fresh_db):
def test_populate_fts(fresh_db): def test_populate_fts(fresh_db):
table = fresh_db["populatable"] table = fresh_db.table("populatable")
table.insert(search_records[0]) table.insert(search_records[0])
table.enable_fts(["text", "country"], fts_version="FTS4") table.enable_fts(["text", "country"], fts_version="FTS4")
assert [] == list(table.search("trash pandas")) assert [] == list(table.search("trash pandas"))
@ -203,7 +217,7 @@ def test_populate_fts(fresh_db):
def test_populate_fts_escape_table_names(fresh_db): def test_populate_fts_escape_table_names(fresh_db):
# Restricted characters such as colon and dots should be escaped. # Restricted characters such as colon and dots should be escaped.
table = fresh_db["http://example.com"] table = fresh_db.table("http://example.com")
table.insert(search_records[0]) table.insert(search_records[0])
table.enable_fts(["text", "country"], fts_version="FTS4") table.enable_fts(["text", "country"], fts_version="FTS4")
assert [] == list(table.search("trash pandas")) assert [] == list(table.search("trash pandas"))
@ -223,20 +237,20 @@ def test_populate_fts_escape_table_names(fresh_db):
@pytest.mark.parametrize("fts_version", ("4", "5")) @pytest.mark.parametrize("fts_version", ("4", "5"))
def test_fts_tokenize(fresh_db, fts_version): def test_fts_tokenize(fresh_db, fts_version):
table_name = "searchable_{}".format(fts_version) table_name = f"searchable_{fts_version}"
table = fresh_db[table_name] table = fresh_db.table(table_name)
table.insert_all(search_records) table.insert_all(search_records)
# Test without porter stemming # Test without porter stemming
table.enable_fts( table.enable_fts(
["text", "country"], ["text", "country"],
fts_version="FTS{}".format(fts_version), fts_version=f"FTS{fts_version}",
) )
assert [] == list(table.search("bite")) assert [] == list(table.search("bite"))
# Test WITH stemming # Test WITH stemming
table.disable_fts() table.disable_fts()
table.enable_fts( table.enable_fts(
["text", "country"], ["text", "country"],
fts_version="FTS{}".format(fts_version), fts_version=f"FTS{fts_version}",
tokenize="porter", tokenize="porter",
) )
rows = list(table.search("bite", order_by="rowid")) rows = list(table.search("bite", order_by="rowid"))
@ -249,12 +263,24 @@ def test_fts_tokenize(fresh_db, fts_version):
}.items() <= rows[0].items() }.items() <= rows[0].items()
def test_fts_tokenize_escaped(fresh_db):
# A malicious tokenize value must not be able to break out of the
# string literal in the CREATE VIRTUAL TABLE statement.
table = fresh_db.table("searchable")
table.insert_all(search_records)
malicious = "porter'); CREATE TABLE injected(x); --"
with pytest.raises(Exception):
table.enable_fts(["text"], tokenize=malicious)
# The injected statement must not have executed
assert "injected" not in fresh_db.table_names()
def test_optimize_fts(fresh_db): def test_optimize_fts(fresh_db):
for fts_version in ("4", "5"): for fts_version in ("4", "5"):
table_name = "searchable_{}".format(fts_version) table_name = f"searchable_{fts_version}"
table = fresh_db[table_name] table = fresh_db.table(table_name)
table.insert_all(search_records) table.insert_all(search_records)
table.enable_fts(["text", "country"], fts_version="FTS{}".format(fts_version)) table.enable_fts(["text", "country"], fts_version=f"FTS{fts_version}")
# You can call optimize successfully against the tables OR their _fts equivalents: # You can call optimize successfully against the tables OR their _fts equivalents:
for table_name in ( for table_name in (
"searchable_4", "searchable_4",
@ -262,11 +288,11 @@ def test_optimize_fts(fresh_db):
"searchable_4_fts", "searchable_4_fts",
"searchable_5_fts", "searchable_5_fts",
): ):
fresh_db[table_name].optimize() fresh_db.table(table_name).optimize()
def test_enable_fts_with_triggers(fresh_db): def test_enable_fts_with_triggers(fresh_db):
table = fresh_db["searchable"] table = fresh_db.table("searchable")
table.insert(search_records[0]) table.insert(search_records[0])
table.enable_fts(["text", "country"], fts_version="FTS4", create_triggers=True) table.enable_fts(["text", "country"], fts_version="FTS4", create_triggers=True)
rows1 = list(table.search("tanuki")) rows1 = list(table.search("tanuki"))
@ -295,7 +321,7 @@ def test_enable_fts_with_triggers(fresh_db):
@pytest.mark.parametrize("create_triggers", [True, False]) @pytest.mark.parametrize("create_triggers", [True, False])
def test_disable_fts(fresh_db, create_triggers): def test_disable_fts(fresh_db, create_triggers):
table = fresh_db["searchable"] table = fresh_db.table("searchable")
table.insert(search_records[0]) table.insert(search_records[0])
table.enable_fts(["text", "country"], create_triggers=create_triggers) table.enable_fts(["text", "country"], create_triggers=create_triggers)
assert { assert {
@ -310,12 +336,12 @@ def test_disable_fts(fresh_db, create_triggers):
expected_triggers = {"searchable_ai", "searchable_ad", "searchable_au"} expected_triggers = {"searchable_ai", "searchable_ad", "searchable_au"}
else: else:
expected_triggers = set() expected_triggers = set()
assert expected_triggers == set( assert expected_triggers == {
r[0] r[0]
for r in fresh_db.execute( for r in fresh_db.execute(
"select name from sqlite_master where type = 'trigger'" "select name from sqlite_master where type = 'trigger'"
).fetchall() ).fetchall()
) }
# Now run .disable_fts() and confirm it worked # Now run .disable_fts() and confirm it worked
table.disable_fts() table.disable_fts()
assert ( assert (
@ -328,7 +354,7 @@ def test_disable_fts(fresh_db, create_triggers):
def test_rebuild_fts(fresh_db): def test_rebuild_fts(fresh_db):
table = fresh_db["searchable"] table = fresh_db.table("searchable")
table.insert(search_records[0]) table.insert(search_records[0])
table.enable_fts(["text", "country"]) table.enable_fts(["text", "country"])
# Run a search # Run a search
@ -354,7 +380,7 @@ def test_rebuild_fts(fresh_db):
def test_optimize_and_rebuild_fts_commit(tmpdir, method): def test_optimize_and_rebuild_fts_commit(tmpdir, method):
path = str(tmpdir / "test.db") path = str(tmpdir / "test.db")
db = Database(path) db = Database(path)
table = db["searchable"] table = db.table("searchable")
table.insert(search_records[0]) table.insert(search_records[0])
table.enable_fts(["text", "country"]) table.enable_fts(["text", "country"])
getattr(table, method)() getattr(table, method)()
@ -364,16 +390,16 @@ def test_optimize_and_rebuild_fts_commit(tmpdir, method):
table.insert(search_records[1]) table.insert(search_records[1])
db.close() db.close()
db2 = Database(path) db2 = Database(path)
assert db2["searchable"].count == 2 assert db2.table("searchable").count == 2
db2.close() db2.close()
@pytest.mark.parametrize("invalid_table", ["does_not_exist", "not_searchable"]) @pytest.mark.parametrize("invalid_table", ["does_not_exist", "not_searchable"])
def test_rebuild_fts_invalid(fresh_db, invalid_table): def test_rebuild_fts_invalid(fresh_db, invalid_table):
fresh_db["not_searchable"].insert({"foo": "bar"}) fresh_db.table("not_searchable").insert({"foo": "bar"})
# Raise OperationalError on invalid table # Raise OperationalError on invalid table
with pytest.raises(sqlite3.OperationalError): with pytest.raises(sqlite3.OperationalError):
fresh_db[invalid_table].rebuild_fts() fresh_db.table(invalid_table).rebuild_fts()
@pytest.mark.parametrize("fts_version", ["FTS4", "FTS5"]) @pytest.mark.parametrize("fts_version", ["FTS4", "FTS5"])
@ -382,15 +408,17 @@ def test_rebuild_removes_junk_docsize_rows(tmpdir, fts_version):
path = tmpdir / "test.db" path = tmpdir / "test.db"
db = Database(str(path), recursive_triggers=False) db = Database(str(path), recursive_triggers=False)
licenses = [{"key": "apache2", "name": "Apache 2"}, {"key": "bsd", "name": "BSD"}] licenses = [{"key": "apache2", "name": "Apache 2"}, {"key": "bsd", "name": "BSD"}]
db["licenses"].insert_all(licenses, pk="key", replace=True) db.table("licenses").insert_all(licenses, pk="key", replace=True)
db["licenses"].enable_fts(["name"], create_triggers=True, fts_version=fts_version) db.table("licenses").enable_fts(
assert db["licenses_fts_docsize"].count == 2 ["name"], create_triggers=True, fts_version=fts_version
)
assert db.table("licenses_fts_docsize").count == 2
# Bug: insert with replace increases the number of rows in _docsize: # Bug: insert with replace increases the number of rows in _docsize:
db["licenses"].insert_all(licenses, pk="key", replace=True) db.table("licenses").insert_all(licenses, pk="key", replace=True)
assert db["licenses_fts_docsize"].count == 4 assert db.table("licenses_fts_docsize").count == 4
# rebuild should fix this: # rebuild should fix this:
db["licenses_fts"].rebuild_fts() db.table("licenses_fts").rebuild_fts()
assert db["licenses_fts_docsize"].count == 2 assert db.table("licenses_fts_docsize").count == 2
@pytest.mark.parametrize( @pytest.mark.parametrize(
@ -404,7 +432,7 @@ def test_rebuild_removes_junk_docsize_rows(tmpdir, fts_version):
) )
def test_enable_fts_replace(kwargs): def test_enable_fts_replace(kwargs):
db = Database(memory=True) db = Database(memory=True)
db["books"].insert( db.table("books").insert(
{ {
"id": 1, "id": 1,
"title": "Habits of Australian Marsupials", "title": "Habits of Australian Marsupials",
@ -412,31 +440,31 @@ def test_enable_fts_replace(kwargs):
}, },
pk="id", pk="id",
) )
db["books"].enable_fts(["title", "author"]) db.table("books").enable_fts(["title", "author"])
assert not db["books"].triggers assert not db.table("books").triggers
assert db["books_fts"].columns_dict.keys() == {"title", "author"} assert db.table("books_fts").columns_dict.keys() == {"title", "author"}
assert "FTS5" in db["books_fts"].schema assert "FTS5" in db.table("books_fts").schema
assert "porter" not in db["books_fts"].schema assert "porter" not in db.table("books_fts").schema
# Now modify the FTS configuration # Now modify the FTS configuration
should_have_changed_columns = "columns" in kwargs should_have_changed_columns = "columns" in kwargs
if "columns" not in kwargs: if "columns" not in kwargs:
kwargs["columns"] = ["title", "author"] kwargs["columns"] = ["title", "author"]
db["books"].enable_fts(**kwargs, replace=True) db.table("books").enable_fts(**kwargs, replace=True)
# Check that the new configuration is correct # Check that the new configuration is correct
if should_have_changed_columns: if should_have_changed_columns:
assert db["books_fts"].columns_dict.keys() == set(["title"]) assert db.table("books_fts").columns_dict.keys() == {"title"}
if "create_triggers" in kwargs: if "create_triggers" in kwargs:
assert db["books"].triggers assert db.table("books").triggers
if "fts_version" in kwargs: if "fts_version" in kwargs:
assert "FTS4" in db["books_fts"].schema assert "FTS4" in db.table("books_fts").schema
if "tokenize" in kwargs: if "tokenize" in kwargs:
assert "porter" in db["books_fts"].schema assert "porter" in db.table("books_fts").schema
def test_enable_fts_replace_does_nothing_if_args_the_same(): def test_enable_fts_replace_does_nothing_if_args_the_same():
queries = [] queries = []
db = Database(memory=True, tracer=lambda sql, params: queries.append((sql, params))) db = Database(memory=True, tracer=lambda sql, params: queries.append((sql, params)))
db["books"].insert( db.table("books").insert(
{ {
"id": 1, "id": 1,
"title": "Habits of Australian Marsupials", "title": "Habits of Australian Marsupials",
@ -444,17 +472,19 @@ def test_enable_fts_replace_does_nothing_if_args_the_same():
}, },
pk="id", pk="id",
) )
db["books"].enable_fts(["title", "author"], create_triggers=True) db.table("books").enable_fts(["title", "author"], create_triggers=True)
queries.clear() queries.clear()
# Running that again shouldn't run much SQL: # Running that again shouldn't run much SQL:
db["books"].enable_fts(["title", "author"], create_triggers=True, replace=True) db.table("books").enable_fts(
["title", "author"], create_triggers=True, replace=True
)
# The only SQL that executed should be select statements # The only SQL that executed should be select statements
assert all(q[0].startswith("select ") for q in queries) assert all(q[0].startswith("select ") for q in queries)
def test_enable_fts_replace_handles_legacy_bracket_quoted_content_table(): def test_enable_fts_replace_handles_legacy_bracket_quoted_content_table():
db = Database(memory=True) db = Database(memory=True)
db["books"].insert( db.table("books").insert(
{ {
"id": 1, "id": 1,
"title": "Habits of Australian Marsupials", "title": "Habits of Australian Marsupials",
@ -469,10 +499,10 @@ def test_enable_fts_replace_handles_legacy_bracket_quoted_content_table():
); );
""") """)
db["books"].enable_fts(["title", "author"], replace=True) db.table("books").enable_fts(["title", "author"], replace=True)
assert db["books_fts"].columns_dict.keys() == {"title", "author"} assert db.table("books_fts").columns_dict.keys() == {"title", "author"}
assert 'content="books"' in db["books_fts"].schema assert 'content="books"' in db.table("books_fts").schema
def test_view_has_no_enable_fts(): def test_view_has_no_enable_fts():
@ -480,7 +510,7 @@ def test_view_has_no_enable_fts():
db.create_view("hello", "select 1 + 1") db.create_view("hello", "select 1 + 1")
# Views deliberately do not have an enable_fts() method # Views deliberately do not have an enable_fts() method
with pytest.raises(AttributeError): with pytest.raises(AttributeError):
db["hello"].enable_fts() # type: ignore[union-attr] db.view("hello").enable_fts() # type: ignore[attr-defined]
@pytest.mark.parametrize( @pytest.mark.parametrize(
@ -686,14 +716,14 @@ def test_view_has_no_enable_fts():
) )
def test_search_sql(kwargs, fts, expected): def test_search_sql(kwargs, fts, expected):
db = Database(memory=True) db = Database(memory=True)
db["books"].insert( db.table("books").insert(
{ {
"title": "Habits of Australian Marsupials", "title": "Habits of Australian Marsupials",
"author": "Marlee Hawkins", "author": "Marlee Hawkins",
} }
) )
db["books"].enable_fts(["title", "author"], fts_version=fts) db.table("books").enable_fts(["title", "author"], fts_version=fts)
sql = db["books"].search_sql(**kwargs) sql = db.table("books").search_sql(**kwargs)
assert sql == expected assert sql == expected
@ -714,7 +744,7 @@ def test_search_sql(kwargs, fts, expected):
), ),
) )
def test_quote_fts_query(fresh_db, input, expected): def test_quote_fts_query(fresh_db, input, expected):
table = fresh_db["searchable"] table = fresh_db.table("searchable")
table.insert_all(search_records) table.insert_all(search_records)
table.enable_fts(["text", "country"]) table.enable_fts(["text", "country"])
quoted = fresh_db.quote_fts(input) quoted = fresh_db.quote_fts(input)
@ -724,7 +754,7 @@ def test_quote_fts_query(fresh_db, input, expected):
def test_search_quote(fresh_db): def test_search_quote(fresh_db):
table = fresh_db["searchable"] table = fresh_db.table("searchable")
table.insert_all(search_records) table.insert_all(search_records)
table.enable_fts(["text", "country"]) table.enable_fts(["text", "country"])
query = "cat's" query = "cat's"
@ -737,10 +767,11 @@ def test_search_quote(fresh_db):
def test_enable_fts_cli_on_view_errors(tmpdir): def test_enable_fts_cli_on_view_errors(tmpdir):
db_path = str(tmpdir / "test.db") db_path = str(tmpdir / "test.db")
db = Database(db_path) db = Database(db_path)
db["t"].insert({"text": "hello"}) db.table("t").insert({"text": "hello"})
db.create_view("v", "select * from t") db.create_view("v", "select * from t")
db.close() db.close()
from click.testing import CliRunner from click.testing import CliRunner
from sqlite_utils import cli as cli_module from sqlite_utils import cli as cli_module
result = CliRunner().invoke(cli_module.cli, ["enable-fts", db_path, "v", "text"]) result = CliRunner().invoke(cli_module.cli, ["enable-fts", db_path, "v", "text"])

View file

@ -1,16 +1,17 @@
import pytest import pytest
from sqlite_utils.db import NotFoundError from sqlite_utils.db import NotFoundError
def test_get_rowid(fresh_db): def test_get_rowid(fresh_db):
dogs = fresh_db["dogs"] dogs = fresh_db.table("dogs")
cleo = {"name": "Cleo", "age": 4} cleo = {"name": "Cleo", "age": 4}
row_id = dogs.insert(cleo).last_rowid row_id = dogs.insert(cleo).last_rowid
assert cleo == dogs.get(row_id) assert cleo == dogs.get(row_id)
def test_get_primary_key(fresh_db): def test_get_primary_key(fresh_db):
dogs = fresh_db["dogs"] dogs = fresh_db.table("dogs")
cleo = {"name": "Cleo", "age": 4, "id": 5} cleo = {"name": "Cleo", "age": 4, "id": 5}
last_pk = dogs.insert(cleo, pk="id").last_pk last_pk = dogs.insert(cleo, pk="id").last_pk
assert 5 == last_pk assert 5 == last_pk
@ -22,10 +23,10 @@ def test_get_primary_key(fresh_db):
[(100, None), (None, None), ((1, 2), "Need 1 primary key value"), ("2", None)], [(100, None), (None, None), ((1, 2), "Need 1 primary key value"), ("2", None)],
) )
def test_get_not_found(argument, expected_msg, fresh_db): def test_get_not_found(argument, expected_msg, fresh_db):
fresh_db["dogs"].insert( fresh_db.table("dogs").insert(
{"id": 1, "name": "Cleo", "age": 4, "is_good": True}, pk="id" {"id": 1, "name": "Cleo", "age": 4, "is_good": True}, pk="id"
) )
with pytest.raises(NotFoundError) as excinfo: with pytest.raises(NotFoundError) as excinfo:
fresh_db["dogs"].get(argument) fresh_db.table("dogs").get(argument)
if expected_msg is not None: if expected_msg is not None:
assert expected_msg == excinfo.value.args[0] assert expected_msg == excinfo.value.args[0]

View file

@ -1,7 +1,8 @@
import json import json
import pytest
import pytest
from click.testing import CliRunner from click.testing import CliRunner
from sqlite_utils.cli import cli from sqlite_utils.cli import cli
from sqlite_utils.db import Database from sqlite_utils.db import Database
from sqlite_utils.utils import find_spatialite, sqlite3 from sqlite_utils.utils import find_spatialite, sqlite3
@ -44,7 +45,7 @@ def test_add_geometry_column():
coord_dimension="XY", coord_dimension="XY",
) )
assert db["geometry_columns"].get(["locations", "geometry"]) == { assert db.table("geometry_columns").get(["locations", "geometry"]) == {
"f_table_name": "locations", "f_table_name": "locations",
"f_geometry_column": "geometry", "f_geometry_column": "geometry",
"geometry_type": 1, # point "geometry_type": 1, # point
@ -104,7 +105,7 @@ def test_query_load_extension(use_spatialite_shortcut):
[ [
":memory:", ":memory:",
"select spatialite_version()", "select spatialite_version()",
"--load-extension={}".format(load_extension), f"--load-extension={load_extension}",
], ],
) )
assert result.exit_code == 0, result.stdout assert result.exit_code == 0, result.stdout
@ -132,7 +133,7 @@ def test_cli_add_geometry_column(tmpdir):
db = Database(str(db_path)) db = Database(str(db_path))
db.init_spatialite() db.init_spatialite()
table = db["locations"].create({"name": str}) table = db.table("locations").create({"name": str})
result = CliRunner().invoke( result = CliRunner().invoke(
cli, cli,
@ -148,7 +149,7 @@ def test_cli_add_geometry_column(tmpdir):
assert result.exit_code == 0 assert result.exit_code == 0
assert db["geometry_columns"].get(["locations", "geometry"]) == { assert db.table("geometry_columns").get(["locations", "geometry"]) == {
"f_table_name": "locations", "f_table_name": "locations",
"f_geometry_column": "geometry", "f_geometry_column": "geometry",
"geometry_type": 1, # point "geometry_type": 1, # point
@ -163,7 +164,7 @@ def test_cli_add_geometry_column_options(tmpdir):
db_path = tmpdir / "spatial.db" db_path = tmpdir / "spatial.db"
db = Database(str(db_path)) db = Database(str(db_path))
db.init_spatialite() db.init_spatialite()
table = db["locations"].create({"name": str}) table = db.table("locations").create({"name": str})
result = CliRunner().invoke( result = CliRunner().invoke(
cli, cli,
@ -182,7 +183,7 @@ def test_cli_add_geometry_column_options(tmpdir):
assert result.exit_code == 0 assert result.exit_code == 0
assert db["geometry_columns"].get(["locations", "geometry"]) == { assert db.table("geometry_columns").get(["locations", "geometry"]) == {
"f_table_name": "locations", "f_table_name": "locations",
"f_geometry_column": "geometry", "f_geometry_column": "geometry",
"geometry_type": 3, # polygon "geometry_type": 3, # polygon
@ -201,7 +202,7 @@ def test_cli_add_geometry_column_invalid_type(tmpdir):
db = Database(str(db_path)) db = Database(str(db_path))
db.init_spatialite() db.init_spatialite()
table = db["locations"].create({"name": str}) table = db.table("locations").create({"name": str})
result = CliRunner().invoke( result = CliRunner().invoke(
cli, cli,
@ -224,7 +225,7 @@ def test_cli_create_spatial_index(tmpdir):
db = Database(str(db_path)) db = Database(str(db_path))
db.init_spatialite() db.init_spatialite()
table = db["locations"].create({"name": str}) table = db.table("locations").create({"name": str})
table.add_geometry_column("geometry", "POINT") table.add_geometry_column("geometry", "POINT")
result = CliRunner().invoke( result = CliRunner().invoke(

View file

@ -1,5 +1,6 @@
from hypothesis import given
import hypothesis.strategies as st import hypothesis.strategies as st
from hypothesis import given
import sqlite_utils import sqlite_utils
@ -10,8 +11,8 @@ def test_roundtrip_integers(integer):
row = { row = {
"integer": integer, "integer": integer,
} }
db["test"].insert(row) db.table("test").insert(row)
assert list(db["test"].rows) == [row] assert list(db.table("test").rows) == [row]
@given(st.text()) @given(st.text())
@ -20,8 +21,8 @@ def test_roundtrip_text(text):
row = { row = {
"text": text, "text": text,
} }
db["test"].insert(row) db.table("test").insert(row)
assert list(db["test"].rows) == [row] assert list(db.table("test").rows) == [row]
@given(st.binary(max_size=1024 * 1024)) @given(st.binary(max_size=1024 * 1024))
@ -30,8 +31,8 @@ def test_roundtrip_binary(binary):
row = { row = {
"binary": binary, "binary": binary,
} }
db["test"].insert(row) db.table("test").insert(row)
assert list(db["test"].rows) == [row] assert list(db.table("test").rows) == [row]
@given(st.floats(allow_nan=False)) @given(st.floats(allow_nan=False))
@ -40,5 +41,5 @@ def test_roundtrip_floats(floats):
row = { row = {
"floats": floats, "floats": floats,
} }
db["test"].insert(row) db.table("test").insert(row)
assert list(db["test"].rows) == [row] assert list(db.table("test").rows) == [row]

View file

@ -1,10 +1,12 @@
from sqlite_utils import cli, Database
from click.testing import CliRunner
import os import os
import pathlib import pathlib
import pytest
import sys import sys
import pytest
from click.testing import CliRunner
from sqlite_utils import Database, cli
@pytest.mark.parametrize("silent", (False, True)) @pytest.mark.parametrize("silent", (False, True))
@pytest.mark.parametrize( @pytest.mark.parametrize(
@ -44,7 +46,7 @@ def test_insert_files(silent, pk_args, expected_pks):
) )
cols = [] cols = []
for coltype in coltypes: for coltype in coltypes:
cols += ["-c", "{}:{}".format(coltype, coltype)] cols += ["-c", f"{coltype}:{coltype}"]
result = runner.invoke( result = runner.invoke(
cli.cli, cli.cli,
["insert-files", db_path, "files", str(tmpdir)] ["insert-files", db_path, "files", str(tmpdir)]
@ -55,7 +57,7 @@ def test_insert_files(silent, pk_args, expected_pks):
) )
assert result.exit_code == 0, result.stdout assert result.exit_code == 0, result.stdout
db = Database(db_path) db = Database(db_path)
rows_by_path = {r["path"]: r for r in db["files"].rows} rows_by_path = {r["path"]: r for r in db.table("files").rows}
one, two, three = ( one, two, three = (
rows_by_path["one.txt"], rows_by_path["one.txt"],
rows_by_path["two.txt"], rows_by_path["two.txt"],
@ -112,7 +114,7 @@ def test_insert_files(silent, pk_args, expected_pks):
for colname, expected_type in expected_types.items(): for colname, expected_type in expected_types.items():
for row in (one, two, three): for row in (one, two, three):
assert isinstance(row[colname], expected_type) assert isinstance(row[colname], expected_type)
assert set(db["files"].pks) == set(expected_pks) assert set(db.table("files").pks) == set(expected_pks)
@pytest.mark.parametrize( @pytest.mark.parametrize(
@ -142,7 +144,7 @@ def test_insert_files_stdin(use_text, encoding, input, expected):
) )
assert result.exit_code == 0, result.stdout assert result.exit_code == 0, result.stdout
db = Database(db_path) db = Database(db_path)
row = list(db["files"].rows)[0] row = next(iter(db.table("files").rows))
key = "content" key = "content"
if use_text: if use_text:
key = "content_text" key = "content_text"
@ -167,5 +169,5 @@ def test_insert_files_bad_text_encoding_error():
) )
assert result.exit_code == 1, result.output assert result.exit_code == 1, result.output
assert result.output.strip().startswith( assert result.output.strip().startswith(
"Error: Could not read file '{}' as text".format(str(latin.resolve())) f"Error: Could not read file '{latin.resolve()!s}' as text"
) )

View file

@ -1,6 +1,7 @@
from sqlite_utils.db import Index, View, Database, XIndex, XIndexColumn
import pytest import pytest
from sqlite_utils.db import Check, Database, Index, Table, View, XIndex, XIndexColumn
def _check_supports_strict(): def _check_supports_strict():
"""Check if SQLite supports strict tables without leaking the database.""" """Check if SQLite supports strict tables without leaking the database."""
@ -20,10 +21,10 @@ def test_view_names(fresh_db):
def test_table_names_fts4(existing_db): def test_table_names_fts4(existing_db):
existing_db["woo"].insert({"title": "Hello"}).enable_fts( existing_db.table("woo").insert({"title": "Hello"}).enable_fts(
["title"], fts_version="FTS4" ["title"], fts_version="FTS4"
) )
existing_db["woo2"].insert({"title": "Hello"}).enable_fts( existing_db.table("woo2").insert({"title": "Hello"}).enable_fts(
["title"], fts_version="FTS5" ["title"], fts_version="FTS5"
) )
assert ["woo_fts"] == existing_db.table_names(fts4=True) assert ["woo_fts"] == existing_db.table_names(fts4=True)
@ -31,17 +32,17 @@ def test_table_names_fts4(existing_db):
def test_detect_fts(existing_db): def test_detect_fts(existing_db):
existing_db["woo"].insert({"title": "Hello"}).enable_fts( existing_db.table("woo").insert({"title": "Hello"}).enable_fts(
["title"], fts_version="FTS4" ["title"], fts_version="FTS4"
) )
existing_db["woo2"].insert({"title": "Hello"}).enable_fts( existing_db.table("woo2").insert({"title": "Hello"}).enable_fts(
["title"], fts_version="FTS5" ["title"], fts_version="FTS5"
) )
assert "woo_fts" == existing_db["woo"].detect_fts() assert "woo_fts" == existing_db.table("woo").detect_fts()
assert "woo_fts" == existing_db["woo_fts"].detect_fts() assert "woo_fts" == existing_db.table("woo_fts").detect_fts()
assert "woo2_fts" == existing_db["woo2"].detect_fts() assert "woo2_fts" == existing_db.table("woo2").detect_fts()
assert "woo2_fts" == existing_db["woo2_fts"].detect_fts() assert "woo2_fts" == existing_db.table("woo2_fts").detect_fts()
assert existing_db["foo"].detect_fts() is None assert existing_db.table("foo").detect_fts() is None
@pytest.mark.parametrize("reverse_order", (True, False)) @pytest.mark.parametrize("reverse_order", (True, False))
@ -51,14 +52,14 @@ def test_detect_fts_similar_tables(fresh_db, reverse_order):
if reverse_order: if reverse_order:
table1, table2 = table2, table1 table1, table2 = table2, table1
fresh_db[table1].insert({"title": "Hello"}).enable_fts( fresh_db.table(table1).insert({"title": "Hello"}).enable_fts(
["title"], fts_version="FTS4" ["title"], fts_version="FTS4"
) )
fresh_db[table2].insert({"title": "Hello"}).enable_fts( fresh_db.table(table2).insert({"title": "Hello"}).enable_fts(
["title"], fts_version="FTS4" ["title"], fts_version="FTS4"
) )
assert fresh_db[table1].detect_fts() == "{}_fts".format(table1) assert fresh_db.table(table1).detect_fts() == f"{table1}_fts"
assert fresh_db[table2].detect_fts() == "{}_fts".format(table2) assert fresh_db.table(table2).detect_fts() == f"{table2}_fts"
def test_tables(existing_db): def test_tables(existing_db):
@ -76,26 +77,34 @@ def test_views(fresh_db):
assert view.columns_dict == {"1": str} assert view.columns_dict == {"1": str}
def test_getitem_returns_table_or_view(fresh_db):
fresh_db.table("items").insert({"id": 1}, pk="id")
fresh_db.create_view("item_ids", "select id from items")
assert isinstance(fresh_db["items"], Table)
assert isinstance(fresh_db["item_ids"], View)
def test_count(existing_db): def test_count(existing_db):
assert existing_db["foo"].count == 3 assert existing_db.table("foo").count == 3
assert existing_db["foo"].count_where() == 3 assert existing_db.table("foo").count_where() == 3
assert existing_db["foo"].execute_count() == 3 assert existing_db.table("foo").execute_count() == 3
def test_count_where(existing_db): def test_count_where(existing_db):
assert existing_db["foo"].count_where("text != ?", ["two"]) == 2 assert existing_db.table("foo").count_where("text != ?", ["two"]) == 2
assert existing_db["foo"].count_where("text != :t", {"t": "two"}) == 2 assert existing_db.table("foo").count_where("text != :t", {"t": "two"}) == 2
def test_columns(existing_db): def test_columns(existing_db):
table = existing_db["foo"] table = existing_db.table("foo")
assert [{"name": "text", "type": "TEXT"}] == [ assert [{"name": "text", "type": "TEXT"}] == [
{"name": col.name, "type": col.type} for col in table.columns {"name": col.name, "type": col.type} for col in table.columns
] ]
def test_table_schema(existing_db): def test_table_schema(existing_db):
assert existing_db["foo"].schema == "CREATE TABLE foo (text TEXT)" assert existing_db.table("foo").schema == "CREATE TABLE foo (text TEXT)"
def test_database_schema(existing_db): def test_database_schema(existing_db):
@ -103,9 +112,9 @@ def test_database_schema(existing_db):
def test_table_repr(fresh_db): def test_table_repr(fresh_db):
table = fresh_db["dogs"].insert({"name": "Cleo", "age": 4}) table = fresh_db.table("dogs").insert({"name": "Cleo", "age": 4})
assert "<Table dogs (name, age)>" == repr(table) assert "<Table dogs (name, age)>" == repr(table)
assert "<Table cats (does not exist yet)>" == repr(fresh_db["cats"]) assert "<Table cats (does not exist yet)>" == repr(fresh_db.table("cats"))
def test_indexes(fresh_db): def test_indexes(fresh_db):
@ -124,7 +133,7 @@ def test_indexes(fresh_db):
columns=["c2", "c3"], columns=["c2", "c3"],
), ),
Index(seq=1, name="Gosh_c1", unique=0, origin="c", partial=0, columns=["c1"]), Index(seq=1, name="Gosh_c1", unique=0, origin="c", partial=0, columns=["c1"]),
] == fresh_db["Gosh"].indexes ] == fresh_db.table("Gosh").indexes
def test_xindexes(fresh_db): def test_xindexes(fresh_db):
@ -133,7 +142,7 @@ def test_xindexes(fresh_db):
create index Gosh_c1 on Gosh(c1); create index Gosh_c1 on Gosh(c1);
create index Gosh_c2c3 on Gosh(c2, c3 desc); create index Gosh_c2c3 on Gosh(c2, c3 desc);
""") """)
assert fresh_db["Gosh"].xindexes == [ assert fresh_db.table("Gosh").xindexes == [
XIndex( XIndex(
name="Gosh_c2c3", name="Gosh_c2c3",
columns=[ columns=[
@ -152,6 +161,31 @@ def test_xindexes(fresh_db):
] ]
def test_indexes_with_double_quotes_in_identifiers(fresh_db):
fresh_db['Go"sh'].insert({"id": 1, 'c"1': 2}, pk="id")
fresh_db['Go"sh'].create_index(['c"1'])
assert [(index.name, index.columns) for index in fresh_db['Go"sh'].indexes] == [
('idx_Go"sh_c"1', ['c"1'])
]
assert fresh_db['Go"sh'].xindexes == [
XIndex(
name='idx_Go"sh_c"1',
columns=[
XIndexColumn(seqno=0, cid=1, name='c"1', desc=0, coll="BINARY", key=1),
XIndexColumn(seqno=1, cid=-1, name=None, desc=0, coll="BINARY", key=0),
],
)
]
def test_transform_table_with_double_quotes_in_identifiers(fresh_db):
fresh_db['Go"sh'].insert({"id": 1, 'c"1': 2, "c2": 3}, pk="id")
fresh_db['Go"sh'].create_index(['c"1'])
fresh_db['Go"sh'].transform(types={"c2": str})
assert fresh_db['Go"sh'].columns_dict["c2"] is str
assert [index.columns for index in fresh_db['Go"sh'].indexes] == [['c"1']]
@pytest.mark.parametrize( @pytest.mark.parametrize(
"column,expected_table_guess", "column,expected_table_guess",
( (
@ -165,30 +199,55 @@ def test_xindexes(fresh_db):
def test_guess_foreign_table(fresh_db, column, expected_table_guess): def test_guess_foreign_table(fresh_db, column, expected_table_guess):
fresh_db.create_table("authors", {"name": str}) fresh_db.create_table("authors", {"name": str})
fresh_db.create_table("genre", {"name": str}) fresh_db.create_table("genre", {"name": str})
assert expected_table_guess == fresh_db["books"].guess_foreign_table(column) assert expected_table_guess == fresh_db.table("books").guess_foreign_table(column)
@pytest.mark.parametrize( @pytest.mark.parametrize(
"pk,expected", ((None, ["rowid"]), ("id", ["id"]), (["id", "id2"], ["id", "id2"])) "pk,expected", ((None, ["rowid"]), ("id", ["id"]), (["id", "id2"], ["id", "id2"]))
) )
def test_pks(fresh_db, pk, expected): def test_pks(fresh_db, pk, expected):
fresh_db["foo"].insert_all([{"id": 1, "id2": 2}], pk=pk) fresh_db.table("foo").insert_all([{"id": 1, "id2": 2}], pk=pk)
assert expected == fresh_db["foo"].pks assert expected == fresh_db.table("foo").pks
def test_checks(fresh_db):
fresh_db.execute("""
CREATE TABLE scores (
score INTEGER CONSTRAINT positive CHECK(score > 0),
maximum INTEGER,
CONSTRAINT within_maximum CHECK(score <= maximum)
)
""")
scores = fresh_db.table("scores")
expected_column = Check("score > 0", name="positive", column="score")
expected_table = Check("score <= maximum", name="within_maximum")
assert scores.checks == [expected_column, expected_table]
assert scores.column_checks == {"score": [expected_column]}
assert scores.table_checks == [expected_table]
assert scores.checks[0].sql == "CONSTRAINT positive CHECK(score > 0)"
def test_checks_nonexistent_and_virtual_tables(fresh_db):
assert fresh_db.table("does_not_exist").checks == []
fresh_db.table("searchable").insert({"text": "hello"}).enable_fts(
["text"], fts_version="FTS5"
)
assert fresh_db.table("searchable_fts").checks == []
def test_triggers_and_triggers_dict(fresh_db): def test_triggers_and_triggers_dict(fresh_db):
assert [] == fresh_db.triggers assert [] == fresh_db.triggers
authors = fresh_db["authors"] authors = fresh_db.table("authors")
authors.insert_all( authors.insert_all(
[ [
{"name": "Frank Herbert", "famous_works": "Dune"}, {"name": "Frank Herbert", "famous_works": "Dune"},
{"name": "Neal Stephenson", "famous_works": "Cryptonomicon"}, {"name": "Neal Stephenson", "famous_works": "Cryptonomicon"},
] ]
) )
fresh_db["other"].insert({"foo": "bar"}) fresh_db.table("other").insert({"foo": "bar"})
assert authors.triggers == [] assert authors.triggers == []
assert authors.triggers_dict == {} assert authors.triggers_dict == {}
assert fresh_db["other"].triggers == [] assert fresh_db.table("other").triggers == []
assert fresh_db.triggers_dict == {} assert fresh_db.triggers_dict == {}
authors.enable_fts( authors.enable_fts(
["name", "famous_works"], fts_version="FTS4", create_triggers=True ["name", "famous_works"], fts_version="FTS4", create_triggers=True
@ -200,7 +259,7 @@ def test_triggers_and_triggers_dict(fresh_db):
} }
assert expected_triggers == {(t.name, t.table) for t in fresh_db.triggers} assert expected_triggers == {(t.name, t.table) for t in fresh_db.triggers}
assert expected_triggers == { assert expected_triggers == {
(t.name, t.table) for t in fresh_db["authors"].triggers (t.name, t.table) for t in fresh_db.table("authors").triggers
} }
expected_triggers = { expected_triggers = {
"authors_ai": ( "authors_ai": (
@ -220,13 +279,13 @@ def test_triggers_and_triggers_dict(fresh_db):
), ),
} }
assert authors.triggers_dict == expected_triggers assert authors.triggers_dict == expected_triggers
assert fresh_db["other"].triggers == [] assert fresh_db.table("other").triggers == []
assert fresh_db["other"].triggers_dict == {} assert fresh_db.table("other").triggers_dict == {}
assert fresh_db.triggers_dict == expected_triggers assert fresh_db.triggers_dict == expected_triggers
def test_has_counts_triggers(fresh_db): def test_has_counts_triggers(fresh_db):
authors = fresh_db["authors"] authors = fresh_db.table("authors")
authors.insert({"name": "Frank Herbert"}) authors.insert({"name": "Frank Herbert"})
assert not authors.has_counts_triggers assert not authors.has_counts_triggers
authors.enable_counts() authors.enable_counts()
@ -275,14 +334,14 @@ def test_has_counts_triggers(fresh_db):
) )
def test_virtual_table_using(fresh_db, sql, expected_name, expected_using): def test_virtual_table_using(fresh_db, sql, expected_name, expected_using):
fresh_db.execute(sql) fresh_db.execute(sql)
assert fresh_db[expected_name].virtual_table_using == expected_using assert fresh_db.table(expected_name).virtual_table_using == expected_using
def test_use_rowid(fresh_db): def test_use_rowid(fresh_db):
fresh_db["rowid_table"].insert({"name": "Cleo"}) fresh_db.table("rowid_table").insert({"name": "Cleo"})
fresh_db["regular_table"].insert({"id": 1, "name": "Cleo"}, pk="id") fresh_db.table("regular_table").insert({"id": 1, "name": "Cleo"}, pk="id")
assert fresh_db["rowid_table"].use_rowid assert fresh_db.table("rowid_table").use_rowid
assert not fresh_db["regular_table"].use_rowid assert not fresh_db.table("regular_table").use_rowid
@pytest.mark.skipif( @pytest.mark.skipif(
@ -301,7 +360,7 @@ def test_use_rowid(fresh_db):
) )
def test_table_strict(fresh_db, create_table, expected_strict): def test_table_strict(fresh_db, create_table, expected_strict):
fresh_db.execute(create_table) fresh_db.execute(create_table)
table = fresh_db["t"] table = fresh_db.table("t")
assert table.strict == expected_strict assert table.strict == expected_strict
@ -311,28 +370,54 @@ def test_table_strict(fresh_db, create_table, expected_strict):
1, 1,
1.3, 1.3,
"foo", "foo",
"O'Brien",
True, True,
b"binary", b"binary",
), ),
) )
def test_table_default_values(fresh_db, value): def test_table_default_values(fresh_db, value):
fresh_db["default_values"].insert( fresh_db.table("default_values").insert(
{"nodefault": 1, "value": value}, defaults={"value": value} {"nodefault": 1, "value": value}, defaults={"value": value}
) )
default_values = fresh_db["default_values"].default_values default_values = fresh_db.table("default_values").default_values
assert default_values == {"value": value} assert default_values == {"value": value}
def test_table_default_values_escaped_quotes(fresh_db):
# SQLite stores string defaults with single quotes doubled, so
# introspection needs to unescape them again
fresh_db.execute(
"create table t (id integer primary key, name text default 'O''Brien')"
)
assert "default 'O''Brien'" in fresh_db.table("t").schema
assert fresh_db.table("t").default_values == {"name": "O'Brien"}
def test_table_default_values_keyword_literals(fresh_db):
fresh_db.execute(
"create table t ("
"enabled integer default TRUE, "
"disabled integer default false, "
"nullable text default NULL"
")"
)
assert fresh_db.table("t").default_values == {
"enabled": True,
"disabled": False,
"nullable": None,
}
def test_pks_use_primary_key_declaration_order(fresh_db): def test_pks_use_primary_key_declaration_order(fresh_db):
# PRIMARY KEY (a, b) declared against columns stored in order (b, a) - # PRIMARY KEY (a, b) declared against columns stored in order (b, a) -
# pks must follow the declaration order, which is what SQLite uses to # pks must follow the declaration order, which is what SQLite uses to
# resolve implicit foreign key references and compound pk lookups # resolve implicit foreign key references and compound pk lookups
fresh_db.execute("create table t (b text, a text, primary key (a, b))") fresh_db.execute("create table t (b text, a text, primary key (a, b))")
assert fresh_db["t"].pks == ["a", "b"] assert fresh_db.table("t").pks == ["a", "b"]
def test_transform_preserves_compound_pk_declaration_order(fresh_db): def test_transform_preserves_compound_pk_declaration_order(fresh_db):
fresh_db.execute("create table t (a text, b text, c text, primary key (b, a))") fresh_db.execute("create table t (a text, b text, c text, primary key (b, a))")
fresh_db["t"].transform(drop={"c"}) fresh_db.table("t").transform(drop={"c"})
assert fresh_db["t"].pks == ["b", "a"] assert fresh_db.table("t").pks == ["b", "a"]
assert 'PRIMARY KEY ("b", "a")' in fresh_db["t"].schema assert 'PRIMARY KEY ("b", "a")' in fresh_db.table("t").schema

View file

@ -3,6 +3,7 @@ Tests for list-based iteration in insert_all and upsert_all
""" """
import pytest import pytest
from sqlite_utils import Database from sqlite_utils import Database
@ -18,9 +19,9 @@ def test_insert_all_list_mode_basic():
yield [2, "Bob", 25] yield [2, "Bob", 25]
yield [3, "Charlie", 35] yield [3, "Charlie", 35]
db["people"].insert_all(data_generator()) db.table("people").insert_all(data_generator())
rows = list(db["people"].rows) rows = list(db.table("people").rows)
assert len(rows) == 3 assert len(rows) == 3
assert rows[0] == {"id": 1, "name": "Alice", "age": 30} assert rows[0] == {"id": 1, "name": "Alice", "age": 30}
assert rows[1] == {"id": 2, "name": "Bob", "age": 25} assert rows[1] == {"id": 2, "name": "Bob", "age": 25}
@ -36,10 +37,10 @@ def test_insert_all_list_mode_with_pk():
yield [1, "Alice", 95] yield [1, "Alice", 95]
yield [2, "Bob", 87] yield [2, "Bob", 87]
db["scores"].insert_all(data_generator(), pk="id") db.table("scores").insert_all(data_generator(), pk="id")
assert db["scores"].pks == ["id"] assert db.table("scores").pks == ["id"]
rows = list(db["scores"].rows) rows = list(db.table("scores").rows)
assert len(rows) == 2 assert len(rows) == 2
@ -53,7 +54,7 @@ def test_upsert_all_list_mode():
yield [1, "Alice", 100] yield [1, "Alice", 100]
yield [2, "Bob", 200] yield [2, "Bob", 200]
db["data"].insert_all(initial_data(), pk="id") db.table("data").insert_all(initial_data(), pk="id")
# Upsert with some updates and new records # Upsert with some updates and new records
def upsert_data(): def upsert_data():
@ -61,9 +62,9 @@ def test_upsert_all_list_mode():
yield [1, "Alice", 150] # Update existing yield [1, "Alice", 150] # Update existing
yield [3, "Charlie", 300] # Insert new yield [3, "Charlie", 300] # Insert new
db["data"].upsert_all(upsert_data(), pk="id") db.table("data").upsert_all(upsert_data(), pk="id")
rows = list(db["data"].rows_where(order_by="id")) rows = list(db.table("data").rows_where(order_by="id"))
assert len(rows) == 3 assert len(rows) == 3
assert rows[0] == {"id": 1, "name": "Alice", "value": 150} assert rows[0] == {"id": 1, "name": "Alice", "value": 150}
assert rows[1] == {"id": 2, "name": "Bob", "value": 200} assert rows[1] == {"id": 2, "name": "Bob", "value": 200}
@ -80,9 +81,9 @@ def test_list_mode_with_various_types():
yield [2, "Bob", 87.3, False] yield [2, "Bob", 87.3, False]
yield [3, "Charlie", None, True] yield [3, "Charlie", None, True]
db["mixed"].insert_all(data_generator()) db.table("mixed").insert_all(data_generator())
rows = list(db["mixed"].rows) rows = list(db.table("mixed").rows)
assert len(rows) == 3 assert len(rows) == 3
assert rows[0]["score"] == 95.5 assert rows[0]["score"] == 95.5
assert rows[1]["active"] == 0 # SQLite stores boolean as int assert rows[1]["active"] == 0 # SQLite stores boolean as int
@ -98,7 +99,7 @@ def test_list_mode_error_non_string_columns():
yield ["a", "b", "c"] yield ["a", "b", "c"]
with pytest.raises(ValueError, match="must be a list of column name strings"): with pytest.raises(ValueError, match="must be a list of column name strings"):
db["bad"].insert_all(bad_data()) db.table("bad").insert_all(bad_data()) # type: ignore[arg-type]
def test_list_mode_error_mixed_types(): def test_list_mode_error_mixed_types():
@ -110,7 +111,7 @@ def test_list_mode_error_mixed_types():
yield {"id": 1, "name": "Alice"} # Should be a list, not dict yield {"id": 1, "name": "Alice"} # Should be a list, not dict
with pytest.raises(ValueError, match="must also be lists"): with pytest.raises(ValueError, match="must also be lists"):
db["bad"].insert_all(bad_data()) db.table("bad").insert_all(bad_data()) # type: ignore[arg-type]
def test_list_mode_empty_after_headers(): def test_list_mode_empty_after_headers():
@ -121,9 +122,9 @@ def test_list_mode_empty_after_headers():
yield ["id", "name", "age"] yield ["id", "name", "age"]
# No data rows # No data rows
result = db["people"].insert_all(data_generator()) result = db.table("people").insert_all(data_generator())
assert result is not None assert result is not None
assert not db["people"].exists() assert not db.table("people").exists()
def test_list_mode_batch_processing(): def test_list_mode_batch_processing():
@ -135,7 +136,7 @@ def test_list_mode_batch_processing():
for i in range(1000): for i in range(1000):
yield [i, f"value_{i}"] yield [i, f"value_{i}"]
db["large"].insert_all(large_data(), batch_size=100) db.table("large").insert_all(large_data(), batch_size=100)
count = db.execute("SELECT COUNT(*) as c FROM large").fetchone()[0] count = db.execute("SELECT COUNT(*) as c FROM large").fetchone()[0]
assert count == 1000 assert count == 1000
@ -151,9 +152,9 @@ def test_list_mode_shorter_rows():
yield [2, "Bob"] # Missing age and city yield [2, "Bob"] # Missing age and city
yield [3, "Charlie", 35] # Missing city yield [3, "Charlie", 35] # Missing city
db["people"].insert_all(data_generator()) db.table("people").insert_all(data_generator())
rows = list(db["people"].rows_where(order_by="id")) rows = list(db.table("people").rows_where(order_by="id"))
assert rows[0] == {"id": 1, "name": "Alice", "age": 30, "city": "NYC"} assert rows[0] == {"id": 1, "name": "Alice", "age": 30, "city": "NYC"}
assert rows[1] == {"id": 2, "name": "Bob", "age": None, "city": None} assert rows[1] == {"id": 2, "name": "Bob", "age": None, "city": None}
assert rows[2] == {"id": 3, "name": "Charlie", "age": 35, "city": None} assert rows[2] == {"id": 3, "name": "Charlie", "age": 35, "city": None}
@ -169,9 +170,9 @@ def test_backwards_compatibility_dict_mode():
{"id": 2, "name": "Bob", "age": 25}, {"id": 2, "name": "Bob", "age": 25},
] ]
db["people"].insert_all(data) db.table("people").insert_all(data)
rows = list(db["people"].rows) rows = list(db.table("people").rows)
assert len(rows) == 2 assert len(rows) == 2
assert rows[0] == {"id": 1, "name": "Alice", "age": 30} assert rows[0] == {"id": 1, "name": "Alice", "age": 30}
@ -188,9 +189,9 @@ def test_insert_all_tuple_mode_basic():
yield (2, "Bob", 25) yield (2, "Bob", 25)
yield (3, "Charlie", 35) yield (3, "Charlie", 35)
db["people"].insert_all(data_generator()) db.table("people").insert_all(data_generator())
rows = list(db["people"].rows) rows = list(db.table("people").rows)
assert len(rows) == 3 assert len(rows) == 3
assert rows[0] == {"id": 1, "name": "Alice", "age": 30} assert rows[0] == {"id": 1, "name": "Alice", "age": 30}
assert rows[1] == {"id": 2, "name": "Bob", "age": 25} assert rows[1] == {"id": 2, "name": "Bob", "age": 25}
@ -210,9 +211,9 @@ def test_insert_all_mixed_list_tuple():
yield [3, "Charlie", 35] yield [3, "Charlie", 35]
yield (4, "Diana", 40) yield (4, "Diana", 40)
db["people"].insert_all(data_generator()) db.table("people").insert_all(data_generator())
rows = list(db["people"].rows) rows = list(db.table("people").rows)
assert len(rows) == 4 assert len(rows) == 4
assert rows[0] == {"id": 1, "name": "Alice", "age": 30} assert rows[0] == {"id": 1, "name": "Alice", "age": 30}
assert rows[1] == {"id": 2, "name": "Bob", "age": 25} assert rows[1] == {"id": 2, "name": "Bob", "age": 25}
@ -230,7 +231,7 @@ def test_upsert_all_tuple_mode():
yield (1, "Alice", 100) yield (1, "Alice", 100)
yield (2, "Bob", 200) yield (2, "Bob", 200)
db["data"].insert_all(initial_data(), pk="id") db.table("data").insert_all(initial_data(), pk="id")
# Upsert with tuples # Upsert with tuples
def upsert_data(): def upsert_data():
@ -238,9 +239,9 @@ def test_upsert_all_tuple_mode():
yield (1, "Alice", 150) # Update existing yield (1, "Alice", 150) # Update existing
yield (3, "Charlie", 300) # Insert new yield (3, "Charlie", 300) # Insert new
db["data"].upsert_all(upsert_data(), pk="id") db.table("data").upsert_all(upsert_data(), pk="id")
rows = list(db["data"].rows_where(order_by="id")) rows = list(db.table("data").rows_where(order_by="id"))
assert len(rows) == 3 assert len(rows) == 3
assert rows[0] == {"id": 1, "name": "Alice", "value": 150} assert rows[0] == {"id": 1, "name": "Alice", "value": 150}
assert rows[1] == {"id": 2, "name": "Bob", "value": 200} assert rows[1] == {"id": 2, "name": "Bob", "value": 200}
@ -257,9 +258,9 @@ def test_tuple_mode_shorter_rows():
yield 2, "Bob" # Missing age and city yield 2, "Bob" # Missing age and city
yield 3, "Charlie", 35 # Missing city yield 3, "Charlie", 35 # Missing city
db["people"].insert_all(data_generator()) db.table("people").insert_all(data_generator())
rows = list(db["people"].rows_where(order_by="id")) rows = list(db.table("people").rows_where(order_by="id"))
assert rows[0] == {"id": 1, "name": "Alice", "age": 30, "city": "NYC"} assert rows[0] == {"id": 1, "name": "Alice", "age": 30, "city": "NYC"}
assert rows[1] == {"id": 2, "name": "Bob", "age": None, "city": None} assert rows[1] == {"id": 2, "name": "Bob", "age": None, "city": None}
assert rows[2] == {"id": 3, "name": "Charlie", "age": 35, "city": None} assert rows[2] == {"id": 3, "name": "Charlie", "age": 35, "city": None}
@ -270,18 +271,18 @@ def test_list_mode_single_record_upsert_last_pk():
db = Database(memory=True) db = Database(memory=True)
# Create table first # Create table first
db["data"].insert({"id": 1, "name": "Alice", "value": 100}, pk="id") db.table("data").insert({"id": 1, "name": "Alice", "value": 100}, pk="id")
# Now upsert a single record using list mode # Now upsert a single record using list mode
def upsert_data(): def upsert_data():
yield ["id", "name", "value"] yield ["id", "name", "value"]
yield [1, "Alice", 150] # Update existing yield [1, "Alice", 150] # Update existing
table = db["data"] table = db.table("data")
table.upsert_all(upsert_data(), pk="id") table.upsert_all(upsert_data(), pk="id")
# Verify the data was updated # Verify the data was updated
rows = list(db["data"].rows) rows = list(db.table("data").rows)
assert rows == [{"id": 1, "name": "Alice", "value": 150}] assert rows == [{"id": 1, "name": "Alice", "value": 150}]
# Verify last_pk is populated correctly # Verify last_pk is populated correctly

View file

@ -1,9 +1,10 @@
from sqlite_utils.db import Index
import pytest import pytest
from sqlite_utils.db import Index
def test_lookup_new_table(fresh_db): def test_lookup_new_table(fresh_db):
species = fresh_db["species"] species = fresh_db.table("species")
palm_id = species.lookup({"name": "Palm"}) palm_id = species.lookup({"name": "Palm"})
oak_id = species.lookup({"name": "Oak"}) oak_id = species.lookup({"name": "Oak"})
cherry_id = species.lookup({"name": "Cherry"}) cherry_id = species.lookup({"name": "Cherry"})
@ -25,7 +26,7 @@ def test_lookup_new_table(fresh_db):
def test_lookup_new_table_compound_key(fresh_db): def test_lookup_new_table_compound_key(fresh_db):
species = fresh_db["species"] species = fresh_db.table("species")
palm_id = species.lookup({"name": "Palm", "type": "Tree"}) palm_id = species.lookup({"name": "Palm", "type": "Tree"})
oak_id = species.lookup({"name": "Oak", "type": "Tree"}) oak_id = species.lookup({"name": "Oak", "type": "Tree"})
assert palm_id == species.lookup({"name": "Palm", "type": "Tree"}) assert palm_id == species.lookup({"name": "Palm", "type": "Tree"})
@ -69,7 +70,7 @@ def test_lookup_fails_if_constraint_cannot_be_added(fresh_db):
def test_lookup_with_extra_values(fresh_db): def test_lookup_with_extra_values(fresh_db):
species = fresh_db["species"] species = fresh_db.table("species")
id = species.lookup({"name": "Palm", "type": "Tree"}, {"first_seen": "2020-01-01"}) id = species.lookup({"name": "Palm", "type": "Tree"}, {"first_seen": "2020-01-01"})
assert species.get(id) == { assert species.get(id) == {
"id": 1, "id": 1,
@ -89,9 +90,9 @@ def test_lookup_with_extra_values(fresh_db):
def test_lookup_with_extra_insert_parameters(fresh_db): def test_lookup_with_extra_insert_parameters(fresh_db):
other_table = fresh_db["other_table"] other_table = fresh_db.table("other_table")
other_table.insert({"id": 1, "name": "Name"}, pk="id") other_table.insert({"id": 1, "name": "Name"}, pk="id")
species = fresh_db["species"] species = fresh_db.table("species")
id = species.lookup( id = species.lookup(
{"name": "Palm", "type": "Tree"}, {"name": "Palm", "type": "Tree"},
{ {
@ -155,15 +156,15 @@ def test_lookup_with_extra_insert_parameters(fresh_db):
@pytest.mark.parametrize("strict", (False, True)) @pytest.mark.parametrize("strict", (False, True))
def test_lookup_new_table_strict(fresh_db, strict): def test_lookup_new_table_strict(fresh_db, strict):
fresh_db["species"].lookup({"name": "Palm"}, strict=strict) fresh_db.table("species").lookup({"name": "Palm"}, strict=strict)
assert fresh_db["species"].strict == strict or not fresh_db.supports_strict assert fresh_db.table("species").strict == strict or not fresh_db.supports_strict
def test_lookup_null_value_idempotent(fresh_db): def test_lookup_null_value_idempotent(fresh_db):
# https://github.com/simonw/sqlite-utils/issues/186 # https://github.com/simonw/sqlite-utils/issues/186
# Repeated lookups of a null value should return the same row, # Repeated lookups of a null value should return the same row,
# not insert a duplicate row each time # not insert a duplicate row each time
species = fresh_db["species"] species = fresh_db.table("species")
first_id = species.lookup({"name": None}) first_id = species.lookup({"name": None})
second_id = species.lookup({"name": None}) second_id = species.lookup({"name": None})
assert first_id == second_id assert first_id == second_id
@ -171,7 +172,7 @@ def test_lookup_null_value_idempotent(fresh_db):
def test_lookup_compound_key_with_null_idempotent(fresh_db): def test_lookup_compound_key_with_null_idempotent(fresh_db):
species = fresh_db["species"] species = fresh_db.table("species")
palm_id = species.lookup({"name": "Palm", "type": None}) palm_id = species.lookup({"name": "Palm", "type": None})
oak_id = species.lookup({"name": "Oak", "type": "Tree"}) oak_id = species.lookup({"name": "Oak", "type": "Tree"})
assert palm_id == species.lookup({"name": "Palm", "type": None}) assert palm_id == species.lookup({"name": "Palm", "type": None})

View file

@ -1,47 +1,48 @@
from sqlite_utils.db import ForeignKey, NoObviousTable
import pytest import pytest
from sqlite_utils.db import ForeignKey, NoObviousTable
def test_insert_m2m_single(fresh_db): def test_insert_m2m_single(fresh_db):
dogs = fresh_db["dogs"] dogs = fresh_db.table("dogs")
dogs.insert({"id": 1, "name": "Cleo"}, pk="id").m2m( dogs.insert({"id": 1, "name": "Cleo"}, pk="id").m2m(
"humans", {"id": 1, "name": "Natalie D"}, pk="id" "humans", {"id": 1, "name": "Natalie D"}, pk="id"
) )
assert {"dogs_humans", "humans", "dogs"} == set(fresh_db.table_names()) assert {"dogs_humans", "humans", "dogs"} == set(fresh_db.table_names())
humans = fresh_db["humans"] humans = fresh_db.table("humans")
dogs_humans = fresh_db["dogs_humans"] dogs_humans = fresh_db.table("dogs_humans")
assert [{"id": 1, "name": "Natalie D"}] == list(humans.rows) assert [{"id": 1, "name": "Natalie D"}] == list(humans.rows)
assert [{"humans_id": 1, "dogs_id": 1}] == list(dogs_humans.rows) assert [{"humans_id": 1, "dogs_id": 1}] == list(dogs_humans.rows)
def test_insert_m2m_alter(fresh_db): def test_insert_m2m_alter(fresh_db):
dogs = fresh_db["dogs"] dogs = fresh_db.table("dogs")
dogs.insert({"id": 1, "name": "Cleo"}, pk="id").m2m( dogs.insert({"id": 1, "name": "Cleo"}, pk="id").m2m(
"humans", {"id": 1, "name": "Natalie D"}, pk="id" "humans", {"id": 1, "name": "Natalie D"}, pk="id"
) )
dogs.update(1).m2m( dogs.update(1).m2m(
"humans", {"id": 2, "name": "Simon W", "nerd": True}, pk="id", alter=True "humans", {"id": 2, "name": "Simon W", "nerd": True}, pk="id", alter=True
) )
assert list(fresh_db["humans"].rows) == [ assert list(fresh_db.table("humans").rows) == [
{"id": 1, "name": "Natalie D", "nerd": None}, {"id": 1, "name": "Natalie D", "nerd": None},
{"id": 2, "name": "Simon W", "nerd": 1}, {"id": 2, "name": "Simon W", "nerd": 1},
] ]
assert list(fresh_db["dogs_humans"].rows) == [ assert list(fresh_db.table("dogs_humans").rows) == [
{"humans_id": 1, "dogs_id": 1}, {"humans_id": 1, "dogs_id": 1},
{"humans_id": 2, "dogs_id": 1}, {"humans_id": 2, "dogs_id": 1},
] ]
def test_insert_m2m_list(fresh_db): def test_insert_m2m_list(fresh_db):
dogs = fresh_db["dogs"] dogs = fresh_db.table("dogs")
dogs.insert({"id": 1, "name": "Cleo"}, pk="id").m2m( dogs.insert({"id": 1, "name": "Cleo"}, pk="id").m2m(
"humans", "humans",
[{"id": 1, "name": "Natalie D"}, {"id": 2, "name": "Simon W"}], [{"id": 1, "name": "Natalie D"}, {"id": 2, "name": "Simon W"}],
pk="id", pk="id",
) )
assert {"dogs", "humans", "dogs_humans"} == set(fresh_db.table_names()) assert {"dogs", "humans", "dogs_humans"} == set(fresh_db.table_names())
humans = fresh_db["humans"] humans = fresh_db.table("humans")
dogs_humans = fresh_db["dogs_humans"] dogs_humans = fresh_db.table("dogs_humans")
assert [{"humans_id": 1, "dogs_id": 1}, {"humans_id": 2, "dogs_id": 1}] == list( assert [{"humans_id": 1, "dogs_id": 1}, {"humans_id": 2, "dogs_id": 1}] == list(
dogs_humans.rows dogs_humans.rows
) )
@ -65,10 +66,9 @@ def test_insert_m2m_iterable(fresh_db):
iterable_records = ({"id": 1, "name": "Phineas"}, {"id": 2, "name": "Ferb"}) iterable_records = ({"id": 1, "name": "Phineas"}, {"id": 2, "name": "Ferb"})
def iterable(): def iterable():
for record in iterable_records: yield from iterable_records
yield record
platypuses = fresh_db["platypuses"] platypuses = fresh_db.table("platypuses")
platypuses.insert({"id": 1, "name": "Perry"}, pk="id").m2m( platypuses.insert({"id": 1, "name": "Perry"}, pk="id").m2m(
"humans", "humans",
iterable(), iterable(),
@ -76,8 +76,8 @@ def test_insert_m2m_iterable(fresh_db):
) )
assert {"platypuses", "humans", "humans_platypuses"} == set(fresh_db.table_names()) assert {"platypuses", "humans", "humans_platypuses"} == set(fresh_db.table_names())
humans = fresh_db["humans"] humans = fresh_db.table("humans")
humans_platypuses = fresh_db["humans_platypuses"] humans_platypuses = fresh_db.table("humans_platypuses")
assert [ assert [
{"humans_id": 1, "platypuses_id": 1}, {"humans_id": 1, "platypuses_id": 1},
{"humans_id": 2, "platypuses_id": 1}, {"humans_id": 2, "platypuses_id": 1},
@ -111,14 +111,14 @@ def test_m2m_with_table_objects(fresh_db):
assert expected_tables == set(fresh_db.table_names()) assert expected_tables == set(fresh_db.table_names())
assert dogs.count == 1 assert dogs.count == 1
assert humans.count == 2 assert humans.count == 2
assert fresh_db["dogs_humans"].count == 2 assert fresh_db.table("dogs_humans").count == 2
def test_m2m_lookup(fresh_db): def test_m2m_lookup(fresh_db):
people = fresh_db.table("people", pk="id") people = fresh_db.table("people", pk="id")
people.insert({"name": "Wahyu"}).m2m("tags", lookup={"tag": "Coworker"}) people.insert({"name": "Wahyu"}).m2m("tags", lookup={"tag": "Coworker"})
people_tags = fresh_db["people_tags"] people_tags = fresh_db.table("people_tags")
tags = fresh_db["tags"] tags = fresh_db.table("tags")
assert people_tags.exists() assert people_tags.exists()
assert tags.exists() assert tags.exists()
assert [ assert [
@ -150,9 +150,9 @@ def test_m2m_explicit_table_name_argument(fresh_db):
people.insert({"name": "Wahyu"}).m2m( people.insert({"name": "Wahyu"}).m2m(
"tags", lookup={"tag": "Coworker"}, m2m_table="tagged" "tags", lookup={"tag": "Coworker"}, m2m_table="tagged"
) )
assert fresh_db["tags"].exists assert fresh_db.table("tags").exists
assert fresh_db["tagged"].exists assert fresh_db.table("tagged").exists
assert not fresh_db["people_tags"].exists() assert not fresh_db.table("people_tags").exists()
def test_m2m_table_candidates(fresh_db): def test_m2m_table_candidates(fresh_db):
@ -181,25 +181,25 @@ def test_uses_existing_m2m_table_if_exists(fresh_db):
# Code should look for an existing table with fks to both tables # Code should look for an existing table with fks to both tables
# and use that if it exists. # and use that if it exists.
people = fresh_db.create_table("people", {"id": int, "name": str}, pk="id") people = fresh_db.create_table("people", {"id": int, "name": str}, pk="id")
fresh_db["tags"].lookup({"tag": "Coworker"}) fresh_db.table("tags").lookup({"tag": "Coworker"})
fresh_db.create_table( fresh_db.create_table(
"tagged", "tagged",
{"people_id": int, "tags_id": int}, {"people_id": int, "tags_id": int},
foreign_keys=["people_id", "tags_id"], foreign_keys=["people_id", "tags_id"],
) )
people.insert({"name": "Wahyu"}).m2m("tags", lookup={"tag": "Coworker"}) people.insert({"name": "Wahyu"}).m2m("tags", lookup={"tag": "Coworker"})
assert fresh_db["tags"].exists() assert fresh_db.table("tags").exists()
assert fresh_db["tagged"].exists() assert fresh_db.table("tagged").exists()
assert not fresh_db["people_tags"].exists() assert not fresh_db.table("people_tags").exists()
assert not fresh_db["tags_people"].exists() assert not fresh_db.table("tags_people").exists()
assert [{"people_id": 1, "tags_id": 1}] == list(fresh_db["tagged"].rows) assert [{"people_id": 1, "tags_id": 1}] == list(fresh_db.table("tagged").rows)
def test_requires_explicit_m2m_table_if_multiple_options(fresh_db): def test_requires_explicit_m2m_table_if_multiple_options(fresh_db):
# If the code scans for m2m tables and finds more than one candidate # If the code scans for m2m tables and finds more than one candidate
# it should require that the m2m_table=x argument is used # it should require that the m2m_table=x argument is used
people = fresh_db.create_table("people", {"id": int, "name": str}, pk="id") people = fresh_db.create_table("people", {"id": int, "name": str}, pk="id")
fresh_db["tags"].lookup({"tag": "Coworker"}) fresh_db.table("tags").lookup({"tag": "Coworker"})
fresh_db.create_table( fresh_db.create_table(
"tagged", "tagged",
{"people_id": int, "tags_id": int}, {"people_id": int, "tags_id": int},

View file

@ -1,4 +1,5 @@
import pytest import pytest
import sqlite_utils import sqlite_utils
from sqlite_utils import Migrations from sqlite_utils import Migrations
@ -9,11 +10,11 @@ def migrations():
@migrations() @migrations()
def m001(db): def m001(db):
db["dogs"].insert({"name": "Cleo"}) db.table("dogs").insert({"name": "Cleo"})
@migrations() @migrations()
def m002(db): def m002(db):
db["cats"].create({"name": str}) db.table("cats").create({"name": str})
db.execute("insert into dogs (name) values ('Pancakes')") db.execute("insert into dogs (name) values ('Pancakes')")
return migrations return migrations
@ -27,11 +28,11 @@ def migrations_not_ordered_alphabetically():
@migrations() @migrations()
def m002(db): def m002(db):
db["dogs"].insert({"name": "Cleo"}) db.table("dogs").insert({"name": "Cleo"})
@migrations() @migrations()
def m001(db): def m001(db):
db["cats"].create({"name": str}) db.table("cats").create({"name": str})
db.execute("insert into dogs (name) values ('Pancakes')") db.execute("insert into dogs (name) values ('Pancakes')")
return migrations return migrations
@ -43,7 +44,7 @@ def migrations2():
@migrations() @migrations()
def m001(db): def m001(db):
db["dogs2"].insert({"name": "Cleo"}) db.table("dogs2").insert({"name": "Cleo"})
return migrations return migrations
@ -95,7 +96,7 @@ def test_applied_at_is_a_string(migrations):
def test_failing_migration_rolls_back(migrations): def test_failing_migration_rolls_back(migrations):
@migrations() @migrations()
def m003(db): def m003(db):
db["birds"].create({"name": str}) db.table("birds").create({"name": str})
db.execute("insert into dogs (name) values ('Dozer')") db.execute("insert into dogs (name) values ('Dozer')")
raise ValueError("boom") raise ValueError("boom")
@ -104,7 +105,7 @@ def test_failing_migration_rolls_back(migrations):
migrations.apply(db) migrations.apply(db)
# m001 and m002 committed before the failure and stay applied # m001 and m002 committed before the failure and stay applied
assert set(db.table_names()) == {"_sqlite_migrations", "dogs", "cats"} assert set(db.table_names()) == {"_sqlite_migrations", "dogs", "cats"}
assert [r["name"] for r in db["dogs"].rows] == ["Cleo", "Pancakes"] assert [r["name"] for r in db.table("dogs").rows] == ["Cleo", "Pancakes"]
assert [m.name for m in migrations.applied(db)] == ["m001", "m002"] assert [m.name for m in migrations.applied(db)] == ["m001", "m002"]
# Everything m003 did was rolled back and it is still pending # Everything m003 did was rolled back and it is still pending
assert [m.name for m in migrations.pending(db)] == ["m003"] assert [m.name for m in migrations.pending(db)] == ["m003"]
@ -116,11 +117,11 @@ def test_rerun_after_failure_applies_each_migration_once():
@migrations() @migrations()
def m001(db): def m001(db):
db["dogs"].insert({"name": "Cleo"}) db.table("dogs").insert({"name": "Cleo"})
@migrations() @migrations()
def m002(db): def m002(db):
db["dogs"].insert({"name": "Pancakes"}) db.table("dogs").insert({"name": "Pancakes"})
if state["fail"]: if state["fail"]:
raise ValueError("boom") raise ValueError("boom")
@ -130,7 +131,7 @@ def test_rerun_after_failure_applies_each_migration_once():
state["fail"] = False state["fail"] = False
migrations.apply(db) migrations.apply(db)
# m001 must not have been re-applied, m002 applied exactly once # m001 must not have been re-applied, m002 applied exactly once
assert [r["name"] for r in db["dogs"].rows] == ["Cleo", "Pancakes"] assert [r["name"] for r in db.table("dogs").rows] == ["Cleo", "Pancakes"]
assert [m.name for m in migrations.applied(db)] == ["m001", "m002"] assert [m.name for m in migrations.applied(db)] == ["m001", "m002"]
@ -141,7 +142,7 @@ def test_non_transactional_migration_allows_vacuum(tmpdir):
@migrations() @migrations()
def m001(db): def m001(db):
db["dogs"].insert({"name": "Cleo"}) db.table("dogs").insert({"name": "Cleo"})
@migrations(transactional=False) @migrations(transactional=False)
def m002(db): def m002(db):
@ -154,8 +155,7 @@ def test_non_transactional_migration_allows_vacuum(tmpdir):
def test_apply_composes_inside_outer_transaction(migrations): def test_apply_composes_inside_outer_transaction(migrations):
db = sqlite_utils.Database(memory=True) db = sqlite_utils.Database(memory=True)
with pytest.raises(ZeroDivisionError): with pytest.raises(ZeroDivisionError), db.atomic():
with db.atomic():
migrations.apply(db) migrations.apply(db)
raise ZeroDivisionError raise ZeroDivisionError
# The outer transaction rolled back, taking the migrations with it # The outer transaction rolled back, taking the migrations with it
@ -185,11 +185,13 @@ def test_apply_composes_inside_outer_transaction(migrations):
) )
def test_upgrades_sqlite_migrations(migrations, create_table, pk): def test_upgrades_sqlite_migrations(migrations, create_table, pk):
db = sqlite_utils.Database(memory=True) db = sqlite_utils.Database(memory=True)
db["_sqlite_migrations"].create(create_table, pk=pk) db.table("_sqlite_migrations").create(create_table, pk=pk)
assert db.table_names() == ["_sqlite_migrations"] assert db.table_names() == ["_sqlite_migrations"]
assert db["_sqlite_migrations"].pks == ([pk] if isinstance(pk, str) else list(pk)) assert db.table("_sqlite_migrations").pks == (
[pk] if isinstance(pk, str) else list(pk)
)
migrations.apply(db) migrations.apply(db)
assert db["_sqlite_migrations"].pks == ["id"] assert db.table("_sqlite_migrations").pks == ["id"]
def test_pending_and_applied_are_read_only(migrations): def test_pending_and_applied_are_read_only(migrations):
@ -227,7 +229,7 @@ def test_stop_before_applied_migration_errors(migrations):
assert "m001" in str(ex.value) assert "m001" in str(ex.value)
assert "already been applied" in str(ex.value) assert "already been applied" in str(ex.value)
# Nothing else was applied # Nothing else was applied
assert not db["cats"].exists() assert not db.table("cats").exists()
def test_stop_before_applied_migration_errors_before_any_apply(migrations): def test_stop_before_applied_migration_errors_before_any_apply(migrations):
@ -238,9 +240,9 @@ def test_stop_before_applied_migration_errors_before_any_apply(migrations):
@only_second() @only_second()
def m002(db): def m002(db):
db["cats"].create({"name": str}) db.table("cats").create({"name": str})
only_second.apply(db) # m002 applied, m001 still pending only_second.apply(db) # m002 applied, m001 still pending
with pytest.raises(ValueError): with pytest.raises(ValueError):
migrations.apply(db, stop_before="m002") migrations.apply(db, stop_before="m002")
assert not db["dogs"].exists() assert not db.table("dogs").exists()

View file

@ -0,0 +1,154 @@
import pytest
from sqlite_utils import Database
from sqlite_utils.utils import sqlite3
BASELINE_ROWS = [(1, "one"), (2, "two")]
def insert(table):
table.insert({"id": 3, "value": "three"}, pk="id")
def insert_all(table):
table.insert_all(
[
{"id": 3, "value": "three"},
{"id": 4, "value": "four"},
],
pk="id",
batch_size=1,
)
def upsert(table):
table.upsert({"id": 2, "value": "TWO"}, pk="id")
def upsert_all(table):
table.upsert_all(
[
{"id": 2, "value": "TWO"},
{"id": 3, "value": "three"},
],
pk="id",
batch_size=1,
)
def update(table):
table.update(2, {"value": "TWO"})
def delete(table):
table.delete(2)
def delete_where(table):
table.delete_where("id > ?", [1])
MUTATOR_CASES = (
pytest.param(
insert,
[(1, "one"), (2, "two"), (3, "three")],
id="insert",
),
pytest.param(
insert_all,
[(1, "one"), (2, "two"), (3, "three"), (4, "four")],
id="insert_all",
),
pytest.param(
upsert,
[(1, "one"), (2, "TWO")],
id="upsert",
),
pytest.param(
upsert_all,
[(1, "one"), (2, "TWO"), (3, "three")],
id="upsert_all",
),
pytest.param(
update,
[(1, "one"), (2, "TWO")],
id="update",
),
pytest.param(delete, [(1, "one")], id="delete"),
pytest.param(delete_where, [(1, "one")], id="delete_where"),
)
class RollbackTest(Exception):
pass
def seed_database(path):
conn = sqlite3.connect(str(path))
try:
conn.execute("create table items (id integer primary key, value text)")
conn.executemany("insert into items values (?, ?)", BASELINE_ROWS)
conn.commit()
finally:
conn.close()
return Database(path)
def current_rows(db):
return db.conn.execute("select id, value from items order by id").fetchall()
def persisted_rows(path):
conn = sqlite3.connect(str(path))
try:
return conn.execute("select id, value from items order by id").fetchall()
finally:
conn.close()
@pytest.mark.parametrize("mutate,expected_rows", MUTATOR_CASES)
def test_mutator_commits_by_default(tmp_path, mutate, expected_rows):
path = tmp_path / "default.db"
db = seed_database(path)
assert not db.conn.in_transaction
mutate(db.table("items"))
assert current_rows(db) == expected_rows
assert not db.conn.in_transaction
db.close()
assert persisted_rows(path) == expected_rows
@pytest.mark.parametrize("mutate,expected_rows", MUTATOR_CASES)
def test_mutator_commits_with_outer_atomic(tmp_path, mutate, expected_rows):
path = tmp_path / "atomic.db"
db = seed_database(path)
with db.atomic():
assert db.conn.in_transaction
mutate(db.table("items"))
assert current_rows(db) == expected_rows
assert db.conn.in_transaction
assert current_rows(db) == expected_rows
assert not db.conn.in_transaction
db.close()
assert persisted_rows(path) == expected_rows
@pytest.mark.parametrize("mutate,expected_rows", MUTATOR_CASES)
def test_mutator_rolls_back_outer_atomic(tmp_path, mutate, expected_rows):
path = tmp_path / "rollback.db"
db = seed_database(path)
with pytest.raises(RollbackTest), db.atomic():
mutate(db.table("items"))
assert current_rows(db) == expected_rows
assert db.conn.in_transaction
raise RollbackTest
assert current_rows(db) == BASELINE_ROWS
assert not db.conn.in_transaction
db.close()
assert persisted_rows(path) == BASELINE_ROWS

View file

@ -1,9 +1,12 @@
from click.testing import CliRunner
import click
import importlib import importlib
import pytest import sqlite3
import sys import sys
from sqlite_utils import cli, Database, hookimpl, plugins
import click
import pytest
from click.testing import CliRunner
from sqlite_utils import Database, cli, hookimpl, plugins
def _supports_pragma_function_list(): def _supports_pragma_function_list():
@ -11,7 +14,7 @@ def _supports_pragma_function_list():
try: try:
db.execute("select * from pragma_function_list()") db.execute("select * from pragma_function_list()")
return True return True
except Exception: except sqlite3.DatabaseError:
return False return False
finally: finally:
db.close() db.close()

View file

@ -1,11 +1,12 @@
import pytest
import types import types
import pytest
from sqlite_utils.utils import sqlite3 from sqlite_utils.utils import sqlite3
def test_query(fresh_db): def test_query(fresh_db):
fresh_db["dogs"].insert_all([{"name": "Cleo"}, {"name": "Pancakes"}]) fresh_db.table("dogs").insert_all([{"name": "Cleo"}, {"name": "Pancakes"}])
results = fresh_db.query("select * from dogs order by name desc") results = fresh_db.query("select * from dogs order by name desc")
assert isinstance(results, types.GeneratorType) assert isinstance(results, types.GeneratorType)
assert list(results) == [{"name": "Pancakes"}, {"name": "Cleo"}] assert list(results) == [{"name": "Pancakes"}, {"name": "Cleo"}]
@ -19,13 +20,13 @@ def test_query_executes_eagerly(fresh_db):
def test_query_rejects_statements_that_return_no_rows(fresh_db): def test_query_rejects_statements_that_return_no_rows(fresh_db):
fresh_db["dogs"].insert({"name": "Cleo"}) fresh_db.table("dogs").insert({"name": "Cleo"})
with pytest.raises(ValueError) as ex: with pytest.raises(ValueError) as ex:
fresh_db.query("update dogs set name = 'Cleopaws'") fresh_db.query("update dogs set name = 'Cleopaws'")
assert "execute()" in str(ex.value) assert "execute()" in str(ex.value)
# The rejected update was rolled back, and no transaction is left open # The rejected update was rolled back, and no transaction is left open
assert not fresh_db.conn.in_transaction assert not fresh_db.conn.in_transaction
assert [row["name"] for row in fresh_db["dogs"].rows] == ["Cleo"] assert [row["name"] for row in fresh_db.table("dogs").rows] == ["Cleo"]
def test_query_rejected_ddl_is_rolled_back(fresh_db): def test_query_rejected_ddl_is_rolled_back(fresh_db):
@ -36,7 +37,7 @@ def test_query_rejected_ddl_is_rolled_back(fresh_db):
def test_query_rejected_write_inside_transaction_is_rolled_back(fresh_db): def test_query_rejected_write_inside_transaction_is_rolled_back(fresh_db):
fresh_db["dogs"].insert({"name": "Cleo"}) fresh_db.table("dogs").insert({"name": "Cleo"})
fresh_db.begin() fresh_db.begin()
fresh_db.execute("insert into dogs (name) values ('Pancakes')") fresh_db.execute("insert into dogs (name) values ('Pancakes')")
with pytest.raises(ValueError): with pytest.raises(ValueError):
@ -44,7 +45,7 @@ def test_query_rejected_write_inside_transaction_is_rolled_back(fresh_db):
# The transaction is still open and the earlier insert is intact # The transaction is still open and the earlier insert is intact
assert fresh_db.conn.in_transaction assert fresh_db.conn.in_transaction
fresh_db.commit() fresh_db.commit()
assert [row["name"] for row in fresh_db["dogs"].rows] == ["Cleo", "Pancakes"] assert [row["name"] for row in fresh_db.table("dogs").rows] == ["Cleo", "Pancakes"]
@pytest.mark.parametrize( @pytest.mark.parametrize(
@ -76,7 +77,7 @@ def test_query_comment_prefixed_commit_does_not_commit_transaction(fresh_db):
# A COMMIT hidden behind a leading comment must not slip past the # A COMMIT hidden behind a leading comment must not slip past the
# keyword check - previously it committed the caller's open # keyword check - previously it committed the caller's open
# transaction before the ValueError was raised # transaction before the ValueError was raised
fresh_db["dogs"].insert({"name": "Cleo"}) fresh_db.table("dogs").insert({"name": "Cleo"})
fresh_db.begin() fresh_db.begin()
fresh_db.execute("insert into dogs (name) values ('Pancakes')") fresh_db.execute("insert into dogs (name) values ('Pancakes')")
with pytest.raises(ValueError): with pytest.raises(ValueError):
@ -84,7 +85,7 @@ def test_query_comment_prefixed_commit_does_not_commit_transaction(fresh_db):
# The explicit transaction is still open and can still be rolled back # The explicit transaction is still open and can still be rolled back
assert fresh_db.conn.in_transaction assert fresh_db.conn.in_transaction
fresh_db.rollback() fresh_db.rollback()
assert [row["name"] for row in fresh_db["dogs"].rows] == ["Cleo"] assert [row["name"] for row in fresh_db.table("dogs").rows] == ["Cleo"]
@pytest.mark.parametrize("sql", ["; COMMIT", "\ufeffCOMMIT"]) @pytest.mark.parametrize("sql", ["; COMMIT", "\ufeffCOMMIT"])
@ -93,7 +94,7 @@ def test_query_prefixed_commit_does_not_commit_transaction(fresh_db, sql):
# real token, so the keyword scanner must skip them too - previously # real token, so the keyword scanner must skip them too - previously
# '; COMMIT' slipped past the check and committed the caller's open # '; COMMIT' slipped past the check and committed the caller's open
# transaction before raising OperationalError # transaction before raising OperationalError
fresh_db["dogs"].insert({"name": "Cleo"}) fresh_db.table("dogs").insert({"name": "Cleo"})
fresh_db.begin() fresh_db.begin()
fresh_db.execute("insert into dogs (name) values ('Pancakes')") fresh_db.execute("insert into dogs (name) values ('Pancakes')")
with pytest.raises(ValueError): with pytest.raises(ValueError):
@ -101,7 +102,7 @@ def test_query_prefixed_commit_does_not_commit_transaction(fresh_db, sql):
# The explicit transaction is still open and can still be rolled back # The explicit transaction is still open and can still be rolled back
assert fresh_db.conn.in_transaction assert fresh_db.conn.in_transaction
fresh_db.rollback() fresh_db.rollback()
assert [row["name"] for row in fresh_db["dogs"].rows] == ["Cleo"] assert [row["name"] for row in fresh_db.table("dogs").rows] == ["Cleo"]
def test_query_error_leaves_no_transaction_open(fresh_db): def test_query_error_leaves_no_transaction_open(fresh_db):
@ -189,12 +190,12 @@ def test_first_keyword(sql, expected):
reason="RETURNING requires SQLite 3.35.0 or higher", reason="RETURNING requires SQLite 3.35.0 or higher",
) )
def test_query_insert_returning(fresh_db): def test_query_insert_returning(fresh_db):
fresh_db["dogs"].insert({"name": "Cleo"}) fresh_db.table("dogs").insert({"name": "Cleo"})
rows = list( rows = list(
fresh_db.query("insert into dogs (name) values ('Pancakes') returning name") fresh_db.query("insert into dogs (name) values ('Pancakes') returning name")
) )
assert rows == [{"name": "Pancakes"}] assert rows == [{"name": "Pancakes"}]
assert fresh_db["dogs"].count == 2 assert fresh_db.table("dogs").count == 2
@pytest.mark.skipif( @pytest.mark.skipif(
@ -206,7 +207,7 @@ def test_query_insert_returning_commits_without_iteration(tmpdir):
path = str(tmpdir / "test.db") path = str(tmpdir / "test.db")
db = Database(path) db = Database(path)
db["dogs"].insert({"name": "Cleo"}) db.table("dogs").insert({"name": "Cleo"})
# Never iterate over the results # Never iterate over the results
db.query("insert into dogs (name) values ('Pancakes') returning name") db.query("insert into dogs (name) values ('Pancakes') returning name")
assert not db.conn.in_transaction assert not db.conn.in_transaction
@ -226,7 +227,7 @@ def test_query_insert_returning_partial_iteration_still_commits(tmpdir):
path = str(tmpdir / "test.db") path = str(tmpdir / "test.db")
db = Database(path) db = Database(path)
db["dogs"].insert({"name": "Cleo"}) db.table("dogs").insert({"name": "Cleo"})
row = next( row = next(
db.query( db.query(
"insert into dogs (name) values ('Pancakes'), ('Marnie') returning name" "insert into dogs (name) values ('Pancakes'), ('Marnie') returning name"
@ -245,7 +246,7 @@ def test_query_insert_returning_partial_iteration_still_commits(tmpdir):
reason="RETURNING requires SQLite 3.35.0 or higher", reason="RETURNING requires SQLite 3.35.0 or higher",
) )
def test_query_insert_returning_respects_explicit_transaction(fresh_db): def test_query_insert_returning_respects_explicit_transaction(fresh_db):
fresh_db["dogs"].insert({"name": "Cleo"}) fresh_db.table("dogs").insert({"name": "Cleo"})
fresh_db.begin() fresh_db.begin()
rows = list( rows = list(
fresh_db.query("insert into dogs (name) values ('Pancakes') returning name") fresh_db.query("insert into dogs (name) values ('Pancakes') returning name")
@ -254,13 +255,13 @@ def test_query_insert_returning_respects_explicit_transaction(fresh_db):
# Still inside the explicit transaction - not committed # Still inside the explicit transaction - not committed
assert fresh_db.conn.in_transaction assert fresh_db.conn.in_transaction
fresh_db.rollback() fresh_db.rollback()
assert [row["name"] for row in fresh_db["dogs"].rows] == ["Cleo"] assert [row["name"] for row in fresh_db.table("dogs").rows] == ["Cleo"]
def test_query_duplicate_column_names_are_deduped(fresh_db): def test_query_duplicate_column_names_are_deduped(fresh_db):
# https://github.com/simonw/sqlite-utils/issues/624 # https://github.com/simonw/sqlite-utils/issues/624
fresh_db["one"].insert({"id": 1, "value": "left"}) fresh_db.table("one").insert({"id": 1, "value": "left"})
fresh_db["two"].insert({"id": 2, "value": "right"}) fresh_db.table("two").insert({"id": 2, "value": "right"})
rows = list( rows = list(
fresh_db.query("select one.id, two.id, one.value, two.value from one, two") fresh_db.query("select one.id, two.id, one.value, two.value from one, two")
) )
@ -276,7 +277,7 @@ def test_query_deduped_column_avoids_existing_names(fresh_db):
def test_execute_returning_dicts(fresh_db): def test_execute_returning_dicts(fresh_db):
# Like db.query() but returns a list, included for backwards compatibility # Like db.query() but returns a list, included for backwards compatibility
# see https://github.com/simonw/sqlite-utils/issues/290 # see https://github.com/simonw/sqlite-utils/issues/290
fresh_db["test"].insert({"id": 1, "bar": 2}, pk="id") fresh_db.table("test").insert({"id": 1, "bar": 2}, pk="id")
assert fresh_db.execute_returning_dicts("select * from test") == [ assert fresh_db.execute_returning_dicts("select * from test") == [
{"id": 1, "bar": 2} {"id": 1, "bar": 2}
] ]

View file

@ -1,12 +1,14 @@
import json
import pytest
from sqlite_utils import recipes from sqlite_utils import recipes
from sqlite_utils.utils import sqlite3 from sqlite_utils.utils import sqlite3
import json
import pytest
@pytest.fixture @pytest.fixture
def dates_db(fresh_db): def dates_db(fresh_db):
fresh_db["example"].insert_all( fresh_db.table("example").insert_all(
[ [
{"id": 1, "dt": "5th October 2019 12:04"}, {"id": 1, "dt": "5th October 2019 12:04"},
{"id": 2, "dt": "6th October 2019 00:05:06"}, {"id": 2, "dt": "6th October 2019 00:05:06"},
@ -19,8 +21,8 @@ def dates_db(fresh_db):
def test_parsedate(dates_db): def test_parsedate(dates_db):
dates_db["example"].convert("dt", recipes.parsedate) dates_db.table("example").convert("dt", recipes.parsedate)
assert list(dates_db["example"].rows) == [ assert list(dates_db.table("example").rows) == [
{"id": 1, "dt": "2019-10-05"}, {"id": 1, "dt": "2019-10-05"},
{"id": 2, "dt": "2019-10-06"}, {"id": 2, "dt": "2019-10-06"},
{"id": 3, "dt": ""}, {"id": 3, "dt": ""},
@ -29,8 +31,8 @@ def test_parsedate(dates_db):
def test_parsedatetime(dates_db): def test_parsedatetime(dates_db):
dates_db["example"].convert("dt", recipes.parsedatetime) dates_db.table("example").convert("dt", recipes.parsedatetime)
assert list(dates_db["example"].rows) == [ assert list(dates_db.table("example").rows) == [
{"id": 1, "dt": "2019-10-05T12:04:00"}, {"id": 1, "dt": "2019-10-05T12:04:00"},
{"id": 2, "dt": "2019-10-06T00:05:06"}, {"id": 2, "dt": "2019-10-06T00:05:06"},
{"id": 3, "dt": ""}, {"id": 3, "dt": ""},
@ -48,16 +50,16 @@ def test_parsedatetime(dates_db):
), ),
) )
def test_dayfirst_yearfirst(fresh_db, recipe, kwargs, expected): def test_dayfirst_yearfirst(fresh_db, recipe, kwargs, expected):
fresh_db["example"].insert_all( fresh_db.table("example").insert_all(
[ [
{"id": 1, "dt": "03/04/05"}, {"id": 1, "dt": "03/04/05"},
], ],
pk="id", pk="id",
) )
fresh_db["example"].convert( fresh_db.table("example").convert(
"dt", lambda value: getattr(recipes, recipe)(value, **kwargs) "dt", lambda value: getattr(recipes, recipe)(value, **kwargs)
) )
assert list(fresh_db["example"].rows) == [ assert list(fresh_db.table("example").rows) == [
{"id": 1, "dt": expected}, {"id": 1, "dt": expected},
] ]
@ -66,7 +68,7 @@ def test_dayfirst_yearfirst(fresh_db, recipe, kwargs, expected):
@pytest.mark.parametrize("fn", ("parsedate", "parsedatetime")) @pytest.mark.parametrize("fn", ("parsedate", "parsedatetime"))
def test_dateparse_errors_raises(fresh_db, fn): def test_dateparse_errors_raises(fresh_db, fn):
"""Test that invalid dates raise errors when errors=None""" """Test that invalid dates raise errors when errors=None"""
fresh_db["example"].insert_all( fresh_db.table("example").insert_all(
[ [
{"id": 1, "dt": "invalid"}, {"id": 1, "dt": "invalid"},
], ],
@ -74,30 +76,32 @@ def test_dateparse_errors_raises(fresh_db, fn):
) )
# Exception in SQLite callback surfaces as OperationalError # Exception in SQLite callback surfaces as OperationalError
with pytest.raises(sqlite3.OperationalError): with pytest.raises(sqlite3.OperationalError):
fresh_db["example"].convert("dt", lambda value: getattr(recipes, fn)(value)) fresh_db.table("example").convert(
"dt", lambda value: getattr(recipes, fn)(value)
)
@pytest.mark.parametrize("fn", ("parsedate", "parsedatetime")) @pytest.mark.parametrize("fn", ("parsedate", "parsedatetime"))
@pytest.mark.parametrize("errors", (recipes.SET_NULL, recipes.IGNORE)) @pytest.mark.parametrize("errors", (recipes.SET_NULL, recipes.IGNORE))
def test_dateparse_errors_handled(fresh_db, fn, errors): def test_dateparse_errors_handled(fresh_db, fn, errors):
"""Test error handling modes for invalid dates""" """Test error handling modes for invalid dates"""
fresh_db["example"].insert_all( fresh_db.table("example").insert_all(
[ [
{"id": 1, "dt": "invalid"}, {"id": 1, "dt": "invalid"},
], ],
pk="id", pk="id",
) )
fresh_db["example"].convert( fresh_db.table("example").convert(
"dt", lambda value: getattr(recipes, fn)(value, errors=errors) "dt", lambda value: getattr(recipes, fn)(value, errors=errors)
) )
rows = list(fresh_db["example"].rows) rows = list(fresh_db.table("example").rows)
expected = [{"id": 1, "dt": None if errors is recipes.SET_NULL else "invalid"}] expected = [{"id": 1, "dt": None if errors is recipes.SET_NULL else "invalid"}]
assert rows == expected assert rows == expected
@pytest.mark.parametrize("delimiter", [None, ";", "-"]) @pytest.mark.parametrize("delimiter", [None, ";", "-"])
def test_jsonsplit(fresh_db, delimiter): def test_jsonsplit(fresh_db, delimiter):
fresh_db["example"].insert_all( fresh_db.table("example").insert_all(
[ [
{"id": 1, "tags": (delimiter or ",").join(["foo", "bar"])}, {"id": 1, "tags": (delimiter or ",").join(["foo", "bar"])},
{"id": 2, "tags": (delimiter or ",").join(["bar", "baz"])}, {"id": 2, "tags": (delimiter or ",").join(["bar", "baz"])},
@ -112,8 +116,8 @@ def test_jsonsplit(fresh_db, delimiter):
else: else:
fn = recipes.jsonsplit fn = recipes.jsonsplit
fresh_db["example"].convert("tags", fn) fresh_db.table("example").convert("tags", fn)
assert list(fresh_db["example"].rows) == [ assert list(fresh_db.table("example").rows) == [
{"id": 1, "tags": '["foo", "bar"]'}, {"id": 1, "tags": '["foo", "bar"]'},
{"id": 2, "tags": '["bar", "baz"]'}, {"id": 2, "tags": '["bar", "baz"]'},
] ]
@ -128,7 +132,7 @@ def test_jsonsplit(fresh_db, delimiter):
), ),
) )
def test_jsonsplit_type(fresh_db, type, expected): def test_jsonsplit_type(fresh_db, type, expected):
fresh_db["example"].insert_all( fresh_db.table("example").insert_all(
[ [
{"id": 1, "records": "1,2,3"}, {"id": 1, "records": "1,2,3"},
], ],
@ -142,5 +146,5 @@ def test_jsonsplit_type(fresh_db, type, expected):
else: else:
fn = recipes.jsonsplit fn = recipes.jsonsplit
fresh_db["example"].convert("records", fn) fresh_db.table("example").convert("records", fn)
assert json.loads(fresh_db["example"].get(1)["records"]) == expected assert json.loads(fresh_db.table("example").get(1)["records"]) == expected

View file

@ -1,8 +1,10 @@
from sqlite_utils import Database
import sqlite3
import pathlib import pathlib
import sqlite3
import pytest import pytest
from sqlite_utils import Database
def test_recreate_ignored_for_in_memory(): def test_recreate_ignored_for_in_memory():
# None of these should raise an exception: # None of these should raise an exception:
@ -31,8 +33,8 @@ def test_recreate(tmp_path, use_path, create_file_first):
filepath = pathlib.Path(filepath) filepath = pathlib.Path(filepath)
if create_file_first: if create_file_first:
db = Database(filepath) db = Database(filepath)
db["t1"].insert({"foo": "bar"}) db.table("t1").insert({"foo": "bar"})
assert ["t1"] == db.table_names() assert ["t1"] == db.table_names()
db.close() db.close()
Database(filepath, recreate=True)["t2"].insert({"foo": "bar"}) Database(filepath, recreate=True).table("t2").insert({"foo": "bar"})
assert ["t2"] == Database(filepath).table_names() assert ["t2"] == Database(filepath).table_names()

View file

@ -86,21 +86,21 @@ def test_register_function_deterministic_tries_again_if_exception_raised(fresh_d
def test_register_function_replace(fresh_db): def test_register_function_replace(fresh_db):
@fresh_db.register_function() @fresh_db.register_function()
def one(): def one(): # pyright: ignore[reportRedeclaration]
return "one" return "one"
assert "one" == fresh_db.execute("select one()").fetchone()[0] assert "one" == fresh_db.execute("select one()").fetchone()[0]
# This will silently fail to replaec the function # This will silently fail to replaec the function
@fresh_db.register_function() @fresh_db.register_function()
def one(): # noqa def one(): # pyright: ignore[reportRedeclaration]
return "two" return "two"
assert "one" == fresh_db.execute("select one()").fetchone()[0] assert "one" == fresh_db.execute("select one()").fetchone()[0]
# This will replace it # This will replace it
@fresh_db.register_function(replace=True) @fresh_db.register_function(replace=True)
def one(): # noqa def one(): # pyright: ignore[reportRedeclaration]
return "two" return "two"
assert "two" == fresh_db.execute("select one()").fetchone()[0] assert "two" == fresh_db.execute("select one()").fetchone()[0]

View file

@ -3,7 +3,7 @@ import pytest
def test_rows(existing_db): def test_rows(existing_db):
assert [{"text": "one"}, {"text": "two"}, {"text": "three"}] == list( assert [{"text": "one"}, {"text": "two"}, {"text": "three"}] == list(
existing_db["foo"].rows existing_db.table("foo").rows
) )
@ -18,7 +18,7 @@ def test_rows(existing_db):
], ],
) )
def test_rows_where(where, where_args, expected_ids, fresh_db): def test_rows_where(where, where_args, expected_ids, fresh_db):
table = fresh_db["dogs"] table = fresh_db.table("dogs")
table.insert_all( table.insert_all(
[ [
{"id": 1, "name": "Cleo", "age": 4, "is_good": True}, {"id": 1, "name": "Cleo", "age": 4, "is_good": True},
@ -41,7 +41,7 @@ def test_rows_where(where, where_args, expected_ids, fresh_db):
], ],
) )
def test_rows_where_order_by(where, order_by, expected_ids, fresh_db): def test_rows_where_order_by(where, order_by, expected_ids, fresh_db):
table = fresh_db["dogs"] table = fresh_db.table("dogs")
table.insert_all( table.insert_all(
[ [
{"id": 1, "name": "Cleo", "age": 4}, {"id": 1, "name": "Cleo", "age": 4},
@ -59,10 +59,13 @@ def test_rows_where_order_by(where, order_by, expected_ids, fresh_db):
(None, 3, [1, 2, 3]), (None, 3, [1, 2, 3]),
(0, 3, [1, 2, 3]), (0, 3, [1, 2, 3]),
(3, 3, [4, 5, 6]), (3, 3, [4, 5, 6]),
# offset without limit should return every remaining row
(97, None, [98, 99, 100]),
(0, None, list(range(1, 101))),
], ],
) )
def test_rows_where_offset_limit(fresh_db, offset, limit, expected): def test_rows_where_offset_limit(fresh_db, offset, limit, expected):
table = fresh_db["rows"] table = fresh_db.table("rows")
table.insert_all([{"id": id} for id in range(1, 101)], pk="id") table.insert_all([{"id": id} for id in range(1, 101)], pk="id")
assert table.count == 100 assert table.count == 100
assert expected == [ assert expected == [
@ -70,8 +73,14 @@ def test_rows_where_offset_limit(fresh_db, offset, limit, expected):
] ]
def test_pks_and_rows_where_offset_without_limit(fresh_db):
table = fresh_db.table("rows")
table.insert_all([{"id": id} for id in range(1, 6)], pk="id")
assert [pk for pk, _ in table.pks_and_rows_where(offset=3, order_by="id")] == [4, 5]
def test_pks_and_rows_where_rowid(fresh_db): def test_pks_and_rows_where_rowid(fresh_db):
table = fresh_db["rowid_table"] table = fresh_db.table("rowid_table")
table.insert_all({"number": i + 10} for i in range(3)) table.insert_all({"number": i + 10} for i in range(3))
pks_and_rows = list(table.pks_and_rows_where()) pks_and_rows = list(table.pks_and_rows_where())
assert pks_and_rows == [ assert pks_and_rows == [
@ -82,7 +91,7 @@ def test_pks_and_rows_where_rowid(fresh_db):
def test_pks_and_rows_where_simple_pk(fresh_db): def test_pks_and_rows_where_simple_pk(fresh_db):
table = fresh_db["simple_pk_table"] table = fresh_db.table("simple_pk_table")
table.insert_all(({"id": i + 10} for i in range(3)), pk="id") table.insert_all(({"id": i + 10} for i in range(3)), pk="id")
pks_and_rows = list(table.pks_and_rows_where()) pks_and_rows = list(table.pks_and_rows_where())
assert pks_and_rows == [ assert pks_and_rows == [
@ -93,7 +102,7 @@ def test_pks_and_rows_where_simple_pk(fresh_db):
def test_pks_and_rows_where_compound_pk(fresh_db): def test_pks_and_rows_where_compound_pk(fresh_db):
table = fresh_db["compound_pk_table"] table = fresh_db.table("compound_pk_table")
table.insert_all( table.insert_all(
({"type": "number", "number": i, "plusone": i + 1} for i in range(3)), ({"type": "number", "number": i, "plusone": i + 1} for i in range(3)),
pk=("type", "number"), pk=("type", "number"),
@ -108,8 +117,8 @@ def test_pks_and_rows_where_compound_pk(fresh_db):
def test_rows_where_duplicate_select_columns_are_deduped(fresh_db): def test_rows_where_duplicate_select_columns_are_deduped(fresh_db):
# https://github.com/simonw/sqlite-utils/issues/624 # https://github.com/simonw/sqlite-utils/issues/624
fresh_db["t"].insert({"id": 1, "name": "Cleo"}) fresh_db.table("t").insert({"id": 1, "name": "Cleo"})
rows = list(fresh_db["t"].rows_where(select="id, id, name")) rows = list(fresh_db.table("t").rows_where(select="id, id, name"))
assert rows == [{"id": 1, "id_2": 1, "name": "Cleo"}] assert rows == [{"id": 1, "id_2": 1, "name": "Cleo"}]
@ -121,10 +130,10 @@ def test_pks_and_rows_where_view(fresh_db):
# an AttributeError from View lacking Table-only properties # an AttributeError from View lacking Table-only properties
from sqlite_utils.utils import sqlite3 from sqlite_utils.utils import sqlite3
fresh_db["dogs"].insert({"id": 1, "name": "Cleo"}, pk="id") fresh_db.table("dogs").insert({"id": 1, "name": "Cleo"}, pk="id")
fresh_db.create_view("dog_names", "select name from dogs") fresh_db.create_view("dog_names", "select name from dogs")
try: try:
result = list(fresh_db["dog_names"].pks_and_rows_where()) result = list(fresh_db.view("dog_names").pks_and_rows_where())
except sqlite3.OperationalError: except sqlite3.OperationalError:
pass # SQLite 3.36+: no such column: rowid pass # SQLite 3.36+: no such column: rowid
else: else:
@ -135,6 +144,6 @@ def test_pks_and_rows_where_view(fresh_db):
def test_pks_and_rows_where_compound_pk_declaration_order(fresh_db): def test_pks_and_rows_where_compound_pk_declaration_order(fresh_db):
# Compound pks are returned in PRIMARY KEY declaration order # Compound pks are returned in PRIMARY KEY declaration order
fresh_db.execute("create table t (b text, a text, primary key (a, b))") fresh_db.execute("create table t (b text, a text, primary key (a, b))")
fresh_db["t"].insert({"a": "A", "b": "B"}) fresh_db.table("t").insert({"a": "A", "b": "B"})
pks_and_rows = list(fresh_db["t"].pks_and_rows_where()) pks_and_rows = list(fresh_db.table("t").pks_and_rows_where())
assert pks_and_rows == [(("A", "B"), {"b": "B", "a": "A"})] assert pks_and_rows == [(("A", "B"), {"b": "B", "a": "A"})]

View file

@ -1,7 +1,9 @@
from sqlite_utils.utils import rows_from_file, Format, RowError
from io import BytesIO, StringIO from io import BytesIO, StringIO
import pytest import pytest
from sqlite_utils.utils import Format, RowError, rows_from_file
@pytest.mark.parametrize( @pytest.mark.parametrize(
"input,expected_format", "input,expected_format",
@ -18,6 +20,13 @@ def test_rows_from_file_detect_format(input, expected_format):
assert rows_list == [{"id": "1", "name": "Cleo"}] assert rows_list == [{"id": "1", "name": "Cleo"}]
@pytest.mark.parametrize("input", (b"", b" \n\t"))
def test_rows_from_file_empty_input(input):
rows, format = rows_from_file(BytesIO(input))
assert format == Format.CSV
assert list(rows) == []
@pytest.mark.parametrize( @pytest.mark.parametrize(
"ignore_extras,extras_key,expected", "ignore_extras,extras_key,expected",
( (
@ -29,7 +38,7 @@ def test_rows_from_file_detect_format(input, expected_format):
) )
def test_rows_from_file_extra_fields_strategies(ignore_extras, extras_key, expected): def test_rows_from_file_extra_fields_strategies(ignore_extras, extras_key, expected):
try: try:
rows, format = rows_from_file( rows, _format = rows_from_file(
BytesIO(b"id,name\r\n1,Cleo,oops"), BytesIO(b"id,name\r\n1,Cleo,oops"),
format=Format.CSV, format=Format.CSV,
ignore_extras=ignore_extras, ignore_extras=ignore_extras,

View file

@ -1,7 +1,9 @@
from sqlite_utils import cli, Database
from click.testing import CliRunner
import pathlib import pathlib
import pytest import pytest
from click.testing import CliRunner
from sqlite_utils import Database, cli
sniff_dir = pathlib.Path(__file__).parent / "sniff" sniff_dir = pathlib.Path(__file__).parent / "sniff"
@ -17,7 +19,7 @@ def test_sniff(tmpdir, filepath):
) )
assert result.exit_code == 0, result.stdout assert result.exit_code == 0, result.stdout
db = Database(db_path) db = Database(db_path)
assert list(db["creatures"].rows) == [ assert list(db.table("creatures").rows) == [
{"id": "1", "species": "dog", "name": "Cleo", "age": "5"}, {"id": "1", "species": "dog", "name": "Cleo", "age": "5"},
{"id": "2", "species": "dog", "name": "Pancakes", "age": "4"}, {"id": "2", "species": "dog", "name": "Pancakes", "age": "4"},
{"id": "3", "species": "cat", "name": "Mozie", "age": "8"}, {"id": "3", "species": "cat", "name": "Mozie", "age": "8"},

View file

@ -1,5 +1,7 @@
import pytest
from collections import OrderedDict from collections import OrderedDict
import pytest
from sqlite_utils.utils import suggest_column_types from sqlite_utils.utils import suggest_column_types

View file

@ -52,6 +52,7 @@ def test_with_tracer():
assert len(collected) == 4 assert len(collected) == 4
assert collected == [ assert collected == [
(
( (
"SELECT name FROM sqlite_master\n" "SELECT name FROM sqlite_master\n"
" WHERE rootpage = 0\n" " WHERE rootpage = 0\n"
@ -62,7 +63,8 @@ def test_with_tracer():
" tbl_name = :table\n" " tbl_name = :table\n"
" AND sql LIKE '%VIRTUAL TABLE%USING FTS%'\n" " AND sql LIKE '%VIRTUAL TABLE%USING FTS%'\n"
" )\n" " )\n"
" )", " )"
),
{ {
"like": "%VIRTUAL TABLE%USING FTS%content=[dogs]%", "like": "%VIRTUAL TABLE%USING FTS%content=[dogs]%",
"like2": '%VIRTUAL TABLE%USING FTS%content="dogs"%', "like2": '%VIRTUAL TABLE%USING FTS%content="dogs"%',
@ -71,6 +73,7 @@ def test_with_tracer():
), ),
("select name from sqlite_master where type = 'view'", None), ("select name from sqlite_master where type = 'view'", None),
("select sql from sqlite_master where name = ?", ("dogs_fts",)), ("select sql from sqlite_master where name = ?", ("dogs_fts",)),
(
( (
'with "original" as (\n' 'with "original" as (\n'
" select\n" " select\n"
@ -86,7 +89,8 @@ def test_with_tracer():
"where\n" "where\n"
' "dogs_fts" match :query\n' ' "dogs_fts" match :query\n'
"order by\n" "order by\n"
' "dogs_fts".rank', ' "dogs_fts".rank'
),
{"query": "Cleopaws"}, {"query": "Cleopaws"},
), ),
] ]

File diff suppressed because it is too large Load diff

View file

@ -7,14 +7,14 @@ from sqlite_utils.db import NotFoundError
def test_update_rowid_table(fresh_db): def test_update_rowid_table(fresh_db):
table = fresh_db["table"] table = fresh_db.table("table")
rowid = table.insert({"foo": "bar"}).last_pk rowid = table.insert({"foo": "bar"}).last_pk
table.update(rowid, {"foo": "baz"}) table.update(rowid, {"foo": "baz"})
assert [{"foo": "baz"}] == list(table.rows) assert [{"foo": "baz"}] == list(table.rows)
def test_update_pk_table(fresh_db): def test_update_pk_table(fresh_db):
table = fresh_db["table"] table = fresh_db.table("table")
pk = table.insert({"foo": "bar", "id": 5}, pk="id").last_pk pk = table.insert({"foo": "bar", "id": 5}, pk="id").last_pk
assert 5 == pk assert 5 == pk
table.update(pk, {"foo": "baz"}) table.update(pk, {"foo": "baz"})
@ -22,7 +22,7 @@ def test_update_pk_table(fresh_db):
def test_update_compound_pk_table(fresh_db): def test_update_compound_pk_table(fresh_db):
table = fresh_db["table"] table = fresh_db.table("table")
pk = table.insert({"id1": 5, "id2": 3, "v": 1}, pk=("id1", "id2")).last_pk pk = table.insert({"id1": 5, "id2": 3, "v": 1}, pk=("id1", "id2")).last_pk
assert (5, 3) == pk assert (5, 3) == pk
table.update(pk, {"v": 2}) table.update(pk, {"v": 2})
@ -42,14 +42,14 @@ def test_update_compound_pk_table(fresh_db):
), ),
) )
def test_update_invalid_pk(fresh_db, pk, update_pk): def test_update_invalid_pk(fresh_db, pk, update_pk):
table = fresh_db["table"] table = fresh_db.table("table")
table.insert({"id1": 5, "id2": 3, "v": 1}, pk=pk).last_pk table.insert({"id1": 5, "id2": 3, "v": 1}, pk=pk)
with pytest.raises(NotFoundError): with pytest.raises(NotFoundError):
table.update(update_pk, {"v": 2}) table.update(update_pk, {"v": 2})
def test_update_alter(fresh_db): def test_update_alter(fresh_db):
table = fresh_db["table"] table = fresh_db.table("table")
rowid = table.insert({"foo": "bar"}).last_pk rowid = table.insert({"foo": "bar"}).last_pk
table.update(rowid, {"new_col": 1.2}, alter=True) table.update(rowid, {"new_col": 1.2}, alter=True)
assert [{"foo": "bar", "new_col": 1.2}] == list(table.rows) assert [{"foo": "bar", "new_col": 1.2}] == list(table.rows)
@ -72,7 +72,7 @@ def test_update_alter(fresh_db):
def test_update_alter_with_special_column_characters(fresh_db): def test_update_alter_with_special_column_characters(fresh_db):
# With double-quote escaping, columns with special characters are now valid # With double-quote escaping, columns with special characters are now valid
table = fresh_db["table"] table = fresh_db.table("table")
rowid = table.insert({"foo": "bar"}).last_pk rowid = table.insert({"foo": "bar"}).last_pk
table.update(rowid, {"new_col[abc]": 1.2}, alter=True) table.update(rowid, {"new_col[abc]": 1.2}, alter=True)
assert list(table.rows) == [{"foo": "bar", "new_col[abc]": 1.2}] assert list(table.rows) == [{"foo": "bar", "new_col[abc]": 1.2}]
@ -106,8 +106,8 @@ def test_update_with_no_values_sets_last_pk(fresh_db):
), ),
) )
def test_update_dictionaries_and_lists_as_json(fresh_db, data_structure): def test_update_dictionaries_and_lists_as_json(fresh_db, data_structure):
fresh_db["test"].insert({"id": 1, "data": ""}, pk="id") fresh_db.table("test").insert({"id": 1, "data": ""}, pk="id")
fresh_db["test"].update(1, {"data": data_structure}) fresh_db.table("test").update(1, {"data": data_structure})
row = fresh_db.execute("select id, data from test").fetchone() row = fresh_db.execute("select id, data from test").fetchone()
assert row[0] == 1 assert row[0] == 1
assert data_structure == json.loads(row[1]) assert data_structure == json.loads(row[1])

View file

@ -1,20 +1,21 @@
from sqlite_utils.db import PrimaryKeyRequired
from sqlite_utils import Database
import pytest import pytest
from sqlite_utils import Database
from sqlite_utils.db import PrimaryKeyRequired
@pytest.mark.parametrize("use_old_upsert", (False, True)) @pytest.mark.parametrize("use_old_upsert", (False, True))
def test_upsert(use_old_upsert): def test_upsert(use_old_upsert):
db = Database(memory=True, use_old_upsert=use_old_upsert) db = Database(memory=True, use_old_upsert=use_old_upsert)
table = db["table"] table = db.table("table")
table.insert({"id": 1, "name": "Cleo"}, pk="id") table.insert_all([{"id": 1, "name": "Cleo"}], pk="id", replace=True)
table.upsert({"id": 1, "age": 5}, pk="id", alter=True) table.upsert({"id": 1, "age": 5}, pk="id", alter=True)
assert list(table.rows) == [{"id": 1, "name": "Cleo", "age": 5}] assert list(table.rows) == [{"id": 1, "name": "Cleo", "age": 5}]
assert table.last_pk == 1 assert table.last_pk == 1
def test_upsert_all(fresh_db): def test_upsert_all(fresh_db):
table = fresh_db["table"] table = fresh_db.table("table")
table.upsert_all([{"id": 1, "name": "Cleo"}, {"id": 2, "name": "Nixie"}], pk="id") table.upsert_all([{"id": 1, "name": "Cleo"}, {"id": 2, "name": "Nixie"}], pk="id")
table.upsert_all([{"id": 1, "age": 5}, {"id": 2, "age": 5}], pk="id", alter=True) table.upsert_all([{"id": 1, "age": 5}, {"id": 2, "age": 5}], pk="id", alter=True)
assert list(table.rows) == [ assert list(table.rows) == [
@ -25,7 +26,7 @@ def test_upsert_all(fresh_db):
def test_upsert_all_single_column(fresh_db): def test_upsert_all_single_column(fresh_db):
table = fresh_db["table"] table = fresh_db.table("table")
table.upsert_all([{"name": "Cleo"}], pk="name") table.upsert_all([{"name": "Cleo"}], pk="name")
assert list(table.rows) == [{"name": "Cleo"}] assert list(table.rows) == [{"name": "Cleo"}]
assert table.pks == ["name"] assert table.pks == ["name"]
@ -33,16 +34,16 @@ def test_upsert_all_single_column(fresh_db):
def test_upsert_all_not_null(fresh_db): def test_upsert_all_not_null(fresh_db):
# https://github.com/simonw/sqlite-utils/issues/538 # https://github.com/simonw/sqlite-utils/issues/538
fresh_db["comments"].upsert_all( fresh_db.table("comments").upsert_all(
[{"id": 1, "name": "Cleo"}], [{"id": 1, "name": "Cleo"}],
pk="id", pk="id",
not_null=["name"], not_null=["name"],
) )
assert list(fresh_db["comments"].rows) == [{"id": 1, "name": "Cleo"}] assert list(fresh_db.table("comments").rows) == [{"id": 1, "name": "Cleo"}]
def test_upsert_error_if_no_pk(fresh_db): def test_upsert_error_if_no_pk(fresh_db):
table = fresh_db["table"] table = fresh_db.table("table")
with pytest.raises(PrimaryKeyRequired): with pytest.raises(PrimaryKeyRequired):
table.upsert_all([{"id": 1, "name": "Cleo"}]) table.upsert_all([{"id": 1, "name": "Cleo"}])
with pytest.raises(PrimaryKeyRequired): with pytest.raises(PrimaryKeyRequired):
@ -52,7 +53,7 @@ def test_upsert_error_if_no_pk(fresh_db):
@pytest.mark.parametrize("use_old_upsert", (False, True)) @pytest.mark.parametrize("use_old_upsert", (False, True))
def test_upsert_empty_record_errors(use_old_upsert): def test_upsert_empty_record_errors(use_old_upsert):
db = Database(memory=True, use_old_upsert=use_old_upsert) db = Database(memory=True, use_old_upsert=use_old_upsert)
table = db["table"] table = db.table("table")
table.insert({"id": 1, "name": "Cleo"}, pk="id") table.insert({"id": 1, "name": "Cleo"}, pk="id")
with pytest.raises(PrimaryKeyRequired): with pytest.raises(PrimaryKeyRequired):
table.upsert({}, pk="id") table.upsert({}, pk="id")
@ -65,7 +66,7 @@ def test_upsert_empty_record_errors(use_old_upsert):
@pytest.mark.parametrize("use_old_upsert", (False, True)) @pytest.mark.parametrize("use_old_upsert", (False, True))
def test_upsert_missing_pk_value_errors(use_old_upsert): def test_upsert_missing_pk_value_errors(use_old_upsert):
db = Database(memory=True, use_old_upsert=use_old_upsert) db = Database(memory=True, use_old_upsert=use_old_upsert)
table = db["table"] table = db.table("table")
table.insert({"id": 1, "name": "Cleo"}, pk="id") table.insert({"id": 1, "name": "Cleo"}, pk="id")
# Records that omit the pk column entirely # Records that omit the pk column entirely
with pytest.raises(PrimaryKeyRequired): with pytest.raises(PrimaryKeyRequired):
@ -77,7 +78,7 @@ def test_upsert_missing_pk_value_errors(use_old_upsert):
def test_upsert_missing_compound_pk_value_errors(fresh_db): def test_upsert_missing_compound_pk_value_errors(fresh_db):
table = fresh_db["table"] table = fresh_db.table("table")
table.insert({"a": "x", "b": "y", "v": 1}, pk=("a", "b")) table.insert({"a": "x", "b": "y", "v": 1}, pk=("a", "b"))
# Missing one component of the detected compound primary key # Missing one component of the detected compound primary key
with pytest.raises(PrimaryKeyRequired): with pytest.raises(PrimaryKeyRequired):
@ -104,7 +105,7 @@ def test_upsert_uses_compound_pk_from_existing_table(use_old_upsert):
primary key (Source, Object, Category) primary key (Source, Object, Category)
) )
""") """)
table = db["summary"] table = db.table("summary")
table.upsert( table.upsert(
{ {
"Source": "Client A", "Source": "Client A",
@ -133,7 +134,7 @@ def test_upsert_uses_compound_pk_from_existing_table(use_old_upsert):
def test_upsert_with_hash_id(fresh_db): def test_upsert_with_hash_id(fresh_db):
table = fresh_db["table"] table = fresh_db.table("table")
table.upsert({"foo": "bar"}, hash_id="pk") table.upsert({"foo": "bar"}, hash_id="pk")
assert [{"pk": "a5e744d0164540d33b1d7ea616c28f2fa97e754a", "foo": "bar"}] == list( assert [{"pk": "a5e744d0164540d33b1d7ea616c28f2fa97e754a", "foo": "bar"}] == list(
table.rows table.rows
@ -143,7 +144,7 @@ def test_upsert_with_hash_id(fresh_db):
@pytest.mark.parametrize("hash_id", (None, "custom_id")) @pytest.mark.parametrize("hash_id", (None, "custom_id"))
def test_upsert_with_hash_id_columns(fresh_db, hash_id): def test_upsert_with_hash_id_columns(fresh_db, hash_id):
table = fresh_db["table"] table = fresh_db.table("table")
table.upsert({"a": 1, "b": 2, "c": 3}, hash_id=hash_id, hash_id_columns=("a", "b")) table.upsert({"a": 1, "b": 2, "c": 3}, hash_id=hash_id, hash_id_columns=("a", "b"))
assert list(table.rows) == [ assert list(table.rows) == [
{ {
@ -166,7 +167,7 @@ def test_upsert_with_hash_id_columns(fresh_db, hash_id):
def test_upsert_compound_primary_key(fresh_db): def test_upsert_compound_primary_key(fresh_db):
table = fresh_db["table"] table = fresh_db.table("table")
table.upsert_all( table.upsert_all(
[ [
{"species": "dog", "id": 1, "name": "Cleo", "age": 4}, {"species": "dog", "id": 1, "name": "Cleo", "age": 4},

View file

@ -1,8 +1,10 @@
from sqlite_utils import utils
import csv import csv
import io import io
import pytest import pytest
from sqlite_utils import utils
@pytest.mark.parametrize( @pytest.mark.parametrize(
"input,expected,should_be_is", "input,expected,should_be_is",
@ -57,7 +59,7 @@ def test_maximize_csv_field_size_limit():
# Reset to default in case other tests have changed it # Reset to default in case other tests have changed it
csv.field_size_limit(utils.ORIGINAL_CSV_FIELD_SIZE_LIMIT) csv.field_size_limit(utils.ORIGINAL_CSV_FIELD_SIZE_LIMIT)
long_value = "a" * 131073 long_value = "a" * 131073
long_csv = "id,text\n1,{}".format(long_value) long_csv = f"id,text\n1,{long_value}"
fp = io.BytesIO(long_csv.encode("utf-8")) fp = io.BytesIO(long_csv.encode("utf-8"))
# Using rows_from_file should error # Using rows_from_file should error
with pytest.raises(csv.Error): with pytest.raises(csv.Error):

View file

@ -1,4 +1,5 @@
import pytest import pytest
from sqlite_utils import Database from sqlite_utils import Database
from sqlite_utils.db import TransactionError from sqlite_utils.db import TransactionError
@ -11,13 +12,13 @@ def db_path_tmpdir(tmpdir):
def test_enable_disable_wal(db_path_tmpdir): def test_enable_disable_wal(db_path_tmpdir):
db, path, tmpdir = db_path_tmpdir db, _path, tmpdir = db_path_tmpdir
assert len(tmpdir.listdir()) == 1 assert len(tmpdir.listdir()) == 1
assert "delete" == db.journal_mode assert "delete" == db.journal_mode
assert "test.db-wal" not in [f.basename for f in tmpdir.listdir()] assert "test.db-wal" not in [f.basename for f in tmpdir.listdir()]
db.enable_wal() db.enable_wal()
assert "wal" == db.journal_mode assert "wal" == db.journal_mode
db["test"].insert({"foo": "bar"}) db.table("test").insert({"foo": "bar"})
assert "test.db-wal" in [f.basename for f in tmpdir.listdir()] assert "test.db-wal" in [f.basename for f in tmpdir.listdir()]
db.disable_wal() db.disable_wal()
assert "delete" == db.journal_mode assert "delete" == db.journal_mode
@ -25,32 +26,30 @@ def test_enable_disable_wal(db_path_tmpdir):
def test_enable_wal_inside_transaction_raises(db_path_tmpdir): def test_enable_wal_inside_transaction_raises(db_path_tmpdir):
db, path, tmpdir = db_path_tmpdir db, _path, _tmpdir = db_path_tmpdir
db["test"].insert({"id": 1}, pk="id") db.table("test").insert({"id": 1}, pk="id")
with pytest.raises(TransactionError): with pytest.raises(TransactionError), db.atomic():
with db.atomic(): db.table("test").insert({"id": 2}, pk="id")
db["test"].insert({"id": 2}, pk="id")
db.enable_wal() db.enable_wal()
# The atomic() block must have rolled back cleanly and the # The atomic() block must have rolled back cleanly and the
# journal mode must be unchanged # journal mode must be unchanged
assert db.journal_mode == "delete" assert db.journal_mode == "delete"
assert [r["id"] for r in db["test"].rows] == [1] assert [r["id"] for r in db.table("test").rows] == [1]
def test_disable_wal_inside_transaction_raises(db_path_tmpdir): def test_disable_wal_inside_transaction_raises(db_path_tmpdir):
db, path, tmpdir = db_path_tmpdir db, _path, _tmpdir = db_path_tmpdir
db.enable_wal() db.enable_wal()
db["test"].insert({"id": 1}, pk="id") db.table("test").insert({"id": 1}, pk="id")
with pytest.raises(TransactionError): with pytest.raises(TransactionError), db.atomic():
with db.atomic(): db.table("test").insert({"id": 2}, pk="id")
db["test"].insert({"id": 2}, pk="id")
db.disable_wal() db.disable_wal()
assert db.journal_mode == "wal" assert db.journal_mode == "wal"
assert [r["id"] for r in db["test"].rows] == [1] assert [r["id"] for r in db.table("test").rows] == [1]
def test_ensure_autocommit_on(db_path_tmpdir): def test_ensure_autocommit_on(db_path_tmpdir):
db, path, tmpdir = db_path_tmpdir db, _path, _tmpdir = db_path_tmpdir
previous_isolation_level = db.conn.isolation_level previous_isolation_level = db.conn.isolation_level
assert previous_isolation_level is not None assert previous_isolation_level is not None
with db.ensure_autocommit_on(): with db.ensure_autocommit_on():
@ -63,26 +62,25 @@ def test_ensure_autocommit_on(db_path_tmpdir):
def test_enable_wal_noop_inside_transaction_is_allowed(db_path_tmpdir): def test_enable_wal_noop_inside_transaction_is_allowed(db_path_tmpdir):
# Calling enable_wal() when WAL is already enabled is a no-op, # Calling enable_wal() when WAL is already enabled is a no-op,
# so it is fine inside a transaction # so it is fine inside a transaction
db, path, tmpdir = db_path_tmpdir db, _path, _tmpdir = db_path_tmpdir
db.enable_wal() db.enable_wal()
with db.atomic(): with db.atomic():
db["test"].insert({"id": 1}, pk="id") db.table("test").insert({"id": 1}, pk="id")
db.enable_wal() db.enable_wal()
assert [r["id"] for r in db["test"].rows] == [1] assert [r["id"] for r in db.table("test").rows] == [1]
def test_ensure_autocommit_on_inside_transaction_raises(db_path_tmpdir): def test_ensure_autocommit_on_inside_transaction_raises(db_path_tmpdir):
# Setting isolation_level commits any pending transaction as a side # Setting isolation_level commits any pending transaction as a side
# effect, silently breaking the caller's rollback guarantee - so # effect, silently breaking the caller's rollback guarantee - so
# entering autocommit mode with a transaction open is an error # entering autocommit mode with a transaction open is an error
db, path, tmpdir = db_path_tmpdir db, _path, _tmpdir = db_path_tmpdir
db["test"].insert({"id": 1}, pk="id") db.table("test").insert({"id": 1}, pk="id")
db.begin() db.begin()
db.execute("insert into test (id) values (2)") db.execute("insert into test (id) values (2)")
with pytest.raises(TransactionError): with pytest.raises(TransactionError), db.ensure_autocommit_on():
with db.ensure_autocommit_on():
pass pass
# The transaction is still open and can still be rolled back # The transaction is still open and can still be rolled back
assert db.conn.in_transaction assert db.conn.in_transaction
db.rollback() db.rollback()
assert [r["id"] for r in db["test"].rows] == [1] assert [r["id"] for r in db.table("test").rows] == [1]