mirror of
https://github.com/simonw/sqlite-utils.git
synced 2026-08-17 06:44:19 +02:00
Compare commits
54 commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
56dd09702f | ||
|
|
28dc6278cc | ||
|
|
f6d73112c8 |
||
|
|
1d98613f28 | ||
|
|
e4935e0644 |
||
|
|
75ba588462 | ||
|
|
2b52b5ed6f | ||
|
|
fcfccea813 | ||
|
|
57192ef4e3 | ||
|
|
e4784ec120 | ||
|
|
88b48fa167 | ||
|
|
e6be6267a4 |
||
|
|
c5063f67b1 | ||
|
|
25c632fbbc |
||
|
|
ebb04a97de | ||
|
|
38fe466700 | ||
|
|
43d5d3331f |
||
|
|
2d3c6b9a1e |
||
|
|
b37b8cf8c8 | ||
|
|
b432e686ca | ||
|
|
2303b80aef | ||
|
|
3db0c57a3b | ||
|
|
f726ea4a65 |
||
|
|
6a456830ca |
||
|
|
a7b734946f | ||
|
|
c621499ed1 | ||
|
|
69a1c0d960 |
||
|
|
a947dc6739 | ||
|
|
458b3ab5b1 |
||
|
|
f66ddcb215 |
||
|
|
d714200659 |
||
|
|
3f0471701b |
||
|
|
5e8822efd2 |
||
|
|
57c1617391 | ||
|
|
b74b727035 |
||
|
|
dc61f75a0b | ||
|
|
6531a57863 | ||
|
|
0f2d525d06 | ||
|
|
7a52214624 | ||
|
|
d302835d57 | ||
|
|
d2ac3765ed | ||
|
|
092f0919c3 | ||
|
|
569608e40f | ||
|
|
23a21c1d6b | ||
|
|
ebafb84c93 | ||
|
|
aa300942bf | ||
|
|
cf3373e7b7 | ||
|
|
8ee0b7c65c | ||
|
|
fa5d66bf53 | ||
|
|
619770bf42 | ||
|
|
353baf280d | ||
|
|
8bc9213a8e | ||
|
|
60811e7305 |
||
|
|
d314d04215 |
74 changed files with 6635 additions and 2440 deletions
8
.github/workflows/publish.yml
vendored
8
.github/workflows/publish.yml
vendored
|
|
@ -12,9 +12,9 @@ jobs:
|
|||
python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"]
|
||||
os: [ubuntu-latest, windows-latest, macos-latest]
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v7
|
||||
- name: Set up Python ${{ matrix.python-version }}
|
||||
uses: actions/setup-python@v5
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
cache: pip
|
||||
|
|
@ -29,9 +29,9 @@ jobs:
|
|||
runs-on: ubuntu-latest
|
||||
needs: [test]
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v7
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: '3.14'
|
||||
cache: pip
|
||||
|
|
|
|||
4
.github/workflows/test-coverage.yml
vendored
4
.github/workflows/test-coverage.yml
vendored
|
|
@ -12,9 +12,9 @@ jobs:
|
|||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out repo
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v7
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: "3.11"
|
||||
cache: pip
|
||||
|
|
|
|||
12
.github/workflows/test.yml
vendored
12
.github/workflows/test.yml
vendored
|
|
@ -14,9 +14,9 @@ jobs:
|
|||
numpy: [0, 1]
|
||||
os: [ubuntu-latest, macos-latest, windows-latest, macos-14]
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v7
|
||||
- name: Set up Python ${{ matrix.python-version }}
|
||||
uses: actions/setup-python@v5
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
allow-prereleases: true
|
||||
|
|
@ -43,6 +43,9 @@ jobs:
|
|||
run: pytest --sqlite-autocommit
|
||||
- name: run mypy
|
||||
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
|
||||
run: flake8
|
||||
- name: run ty
|
||||
|
|
@ -50,6 +53,11 @@ jobs:
|
|||
run: |
|
||||
pip install uv
|
||||
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
|
||||
run: black . --check
|
||||
- name: Check if cog needs to be run
|
||||
|
|
|
|||
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -15,6 +15,7 @@ venv
|
|||
.schema
|
||||
.vscode
|
||||
.hypothesis
|
||||
.claude/
|
||||
Pipfile
|
||||
Pipfile.lock
|
||||
uv.lock
|
||||
|
|
|
|||
9
Justfile
9
Justfile
|
|
@ -2,20 +2,25 @@
|
|||
@default: test lint
|
||||
|
||||
# Run pytest with supplied options
|
||||
@test *options:
|
||||
@test *options: test-no-dev-dependencies
|
||||
uv run pytest {{options}}
|
||||
|
||||
@test-no-dev-dependencies:
|
||||
uv run --isolated --no-default-groups sqlite-utils --help > /dev/null
|
||||
|
||||
@run *options:
|
||||
uv run -- {{options}}
|
||||
|
||||
# Run linters: black, flake8, mypy, ty, cog
|
||||
# Run linters: black, flake8, mypy, pyright, ty, cog
|
||||
@lint:
|
||||
just run black . --check
|
||||
uv run flake8
|
||||
uv run mypy sqlite_utils tests
|
||||
uv run pyright sqlite_utils tests
|
||||
uv run ty check sqlite_utils
|
||||
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 sqlite_utils --ignore-words docs/codespell-ignore-words.txt
|
||||
|
||||
# Rebuild docs with cog
|
||||
@cog:
|
||||
|
|
|
|||
|
|
@ -4,6 +4,99 @@
|
|||
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:
|
||||
|
||||
4.1 (2026-07-11)
|
||||
----------------
|
||||
|
||||
- ``sqlite-utils insert`` and ``sqlite-utils upsert`` now accept a ``--code`` option for :ref:`providing a block of Python code <cli_insert_code>` (or a path to a ``.py`` file) that defines a ``rows()`` function or ``rows`` iterable of rows to insert, as an alternative to importing from a file. (:issue:`684`)
|
||||
- ``sqlite-utils insert`` and ``sqlite-utils upsert`` now accept ``--type column-name type`` to :ref:`override the type automatically chosen when the table is created <cli_insert_csv_tsv_column_types>`. This is useful for CSV or TSV columns such as ZIP codes that look like integers but should be stored as ``TEXT`` to preserve leading zeros. (:issue:`131`)
|
||||
- New ``table.drop_index(name)`` method and ``sqlite-utils drop-index`` command for dropping an index by name. Both accept ``ignore=True``/``--ignore`` to ignore a missing index. (:issue:`626`)
|
||||
- ``sqlite-utils query`` can now read the SQL query from standard input by passing ``-`` in place of the query, for example ``echo "select * from dogs" | sqlite-utils query dogs.db -``. (:issue:`765`)
|
||||
- ``sqlite-utils upsert`` can now infer the primary key of an existing table, so ``--pk`` can be omitted when upserting into a table that already has a primary key.
|
||||
- ``table.transform()`` and ``table.transform_sql()`` now accept ``strict=True`` or ``strict=False`` to change a table's `SQLite strict mode <https://www.sqlite.org/stricttables.html>`__. Omitting the option preserves the existing mode. (:issue:`787`)
|
||||
- The ``sqlite-utils transform`` command now accepts ``--strict`` and ``--no-strict`` to change a table's strict mode. (:issue:`787`)
|
||||
|
||||
.. _v4_0:
|
||||
|
||||
4.0 (2026-07-07)
|
||||
----------------
|
||||
|
||||
The 4.0 release includes some minor backwards-incompatible fixes (hence the major version number bump) and introduces three major new features:
|
||||
|
||||
- :ref:`Database migrations <migrations>`, providing a structured mechanism for evolving a project's schema over time. (:issue:`752`)
|
||||
- :ref:`Nested transaction support <python_api_atomic>` via ``db.atomic()``, plus numerous improvements to how transactions work across the library. (:issue:`755`)
|
||||
- Support for :ref:`compound foreign keys <python_api_compound_foreign_keys>`, including creation, transformation and introspection through :ref:`table.foreign_keys <python_api_introspection_foreign_keys>`. (:issue:`594`)
|
||||
|
||||
Other notable changes include:
|
||||
|
||||
- Upserts now use SQLite's ``INSERT ... ON CONFLICT ... DO UPDATE SET`` syntax, detect existing table primary keys automatically and reject records that are missing required primary key values. (:issue:`652`)
|
||||
- ``db.query()`` now executes immediately and rejects statements that do not return rows; use ``db.execute()`` for writes and DDL.
|
||||
- CSV and TSV imports now detect column types by default, while inserts into existing tables preserve those tables' column types. (:issue:`679`)
|
||||
- Foreign key handling now preserves ``ON DELETE``/``ON UPDATE`` actions during transforms and resolves referenced primary keys more accurately. (:issue:`530`)
|
||||
- Column names passed to Python API methods are now matched case-insensitively, mirroring SQLite's own identifier behavior. (:issue:`760`)
|
||||
- The command-line tool now emits UTF-8 JSON output by default, with ``--ascii`` available to restore escaped output. (:issue:`625`)
|
||||
- ``table.extract()`` and ``extracts=`` no longer create lookup table records for all-``null`` values. (:issue:`186`)
|
||||
|
||||
See :ref:`upgrading_3_to_4` for details on backwards-incompatible changes.
|
||||
|
||||
The detailed release notes for the features and fixes shipped during the 4.0 pre-release cycle are available in :ref:`4.0a0 <v4_0a0>`, :ref:`4.0a1 <v4_0a1>`, :ref:`4.0rc1 <v4_0rc1>`, :ref:`4.0rc2 <v4_0rc2>`, :ref:`4.0rc3 <v4_0rc3>` and :ref:`4.0rc4 <v4_0rc4>`.
|
||||
|
||||
Bug fixes since 4.0rc4
|
||||
~~~~~~~~~~~~~~~~~~~~~~
|
||||
|
||||
- Fixed 4.0 regressions in ``insert``/``upsert`` against tables that use SQLite's implicit ``rowid`` primary key. Passing ``pk="rowid"``, ``pk="_rowid_"`` or ``pk="oid"`` now works again for rowid tables, and ``last_pk`` is set correctly. (:issue:`781`)
|
||||
- Fixed ``insert(..., ignore=True)`` and ``insert_all(..., ignore=True)`` so an ignored insert that conflicts with an existing primary key row now reports that existing row in ``last_rowid`` and ``last_pk`` where possible. This also works for compound primary keys and list-mode inserts. (:issue:`783`)
|
||||
|
||||
.. _v4_0rc4:
|
||||
|
||||
4.0rc4 (2026-07-06)
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@ This page lists the ``--help`` for every ``sqlite-utils`` CLI sub-command.
|
|||
go_first = [
|
||||
"query", "memory", "insert", "upsert", "bulk", "search", "transform", "extract",
|
||||
"schema", "insert-files", "analyze-tables", "convert", "tables", "views", "rows",
|
||||
"triggers", "indexes", "create-database", "create-table", "create-index",
|
||||
"triggers", "indexes", "create-database", "create-table", "create-index", "drop-index",
|
||||
"migrate", "enable-fts", "populate-fts", "rebuild-fts", "disable-fts"
|
||||
]
|
||||
refs = {
|
||||
|
|
@ -46,6 +46,7 @@ This page lists the ``--help`` for every ``sqlite-utils`` CLI sub-command.
|
|||
"add-foreign-keys": "cli_add_foreign_keys",
|
||||
"index-foreign-keys": "cli_index_foreign_keys",
|
||||
"create-index": "cli_create_index",
|
||||
"drop-index": "cli_drop_index",
|
||||
"enable-wal": "cli_wal",
|
||||
"enable-counts": "cli_enable_counts",
|
||||
"bulk": "cli_bulk",
|
||||
|
|
@ -109,6 +110,10 @@ See :ref:`cli_query`.
|
|||
"select * from chickens where age > :age" \
|
||||
-p age 1
|
||||
|
||||
Pass "-" as the SQL to read the query from standard input:
|
||||
|
||||
echo "select * from chickens" | sqlite-utils data.db -
|
||||
|
||||
Options:
|
||||
--attach <TEXT FILE>... Additional databases to attach - specify alias and
|
||||
filepath
|
||||
|
|
@ -134,8 +139,8 @@ See :ref:`cli_query`.
|
|||
-r, --raw Raw output, first column of first row
|
||||
--raw-lines Raw output, first column of each row
|
||||
-p, --param <TEXT TEXT>... Named :parameters for SQL query
|
||||
--functions TEXT Python code or file path defining custom SQL
|
||||
functions
|
||||
--functions TEXT Python code or a file path defining custom SQL
|
||||
functions; can be used multiple times
|
||||
--load-extension TEXT Path to SQLite extension, with optional
|
||||
:entrypoint
|
||||
-h, --help Show this message and exit.
|
||||
|
|
@ -177,8 +182,8 @@ See :ref:`cli_memory`.
|
|||
sqlite-utils memory animals.csv --schema
|
||||
|
||||
Options:
|
||||
--functions TEXT Python code or file path defining custom SQL
|
||||
functions
|
||||
--functions TEXT Python code or a file path defining custom SQL
|
||||
functions; can be used multiple times
|
||||
--attach <TEXT FILE>... Additional databases to attach - specify alias and
|
||||
filepath
|
||||
--flatten Flatten nested JSON objects, so {"foo": {"bar":
|
||||
|
|
@ -226,7 +231,7 @@ See :ref:`cli_inserting_data`, :ref:`cli_insert_csv_tsv`, :ref:`cli_insert_unstr
|
|||
|
||||
::
|
||||
|
||||
Usage: sqlite-utils insert [OPTIONS] PATH TABLE FILE
|
||||
Usage: sqlite-utils insert [OPTIONS] PATH TABLE [FILE]
|
||||
|
||||
Insert records from FILE into a table, creating the table if it does not
|
||||
already exist.
|
||||
|
|
@ -242,6 +247,9 @@ See :ref:`cli_inserting_data`, :ref:`cli_insert_csv_tsv`, :ref:`cli_insert_unstr
|
|||
- Use --lines to write each incoming line to a column called "line"
|
||||
- Use --text to write the entire input to a column called "text"
|
||||
|
||||
Use --type column-name type to override the type automatically chosen when the
|
||||
table is created.
|
||||
|
||||
You can also use --convert to pass a fragment of Python code that will be used
|
||||
to convert each input.
|
||||
|
||||
|
|
@ -268,8 +276,20 @@ See :ref:`cli_inserting_data`, :ref:`cli_insert_csv_tsv`, :ref:`cli_insert_unstr
|
|||
echo 'A bunch of words' | sqlite-utils insert words.db words - \
|
||||
--text --convert '({"word": w} for w in text.split())'
|
||||
|
||||
Instead of a FILE you can use --code to provide a block of Python code that
|
||||
defines the rows to insert, as either a rows() function that yields
|
||||
dictionaries or a "rows" iterable. --code can also be a path to a .py file:
|
||||
|
||||
sqlite-utils insert data.db creatures --code '
|
||||
def rows():
|
||||
yield {"id": 1, "name": "Cleo"}
|
||||
yield {"id": 2, "name": "Suna"}
|
||||
' --pk id
|
||||
|
||||
Options:
|
||||
--pk TEXT Columns to use as the primary key, e.g. id
|
||||
--code TEXT Python code defining a rows() function or iterable
|
||||
of rows to insert
|
||||
--flatten Flatten nested JSON objects, so {"a": {"b": 1}}
|
||||
becomes {"a_b": 1}
|
||||
--nl Expect newline-delimited JSON
|
||||
|
|
@ -290,6 +310,7 @@ See :ref:`cli_inserting_data`, :ref:`cli_insert_csv_tsv`, :ref:`cli_insert_unstr
|
|||
--alter Alter existing table to add any missing columns
|
||||
--not-null TEXT Columns that should be created as NOT NULL
|
||||
--default <TEXT TEXT>... Default value that should be set for a column
|
||||
--type <TEXT CHOICE>... Column types to use when creating the table
|
||||
--no-detect-types Treat all CSV/TSV columns as TEXT
|
||||
--analyze Run ANALYZE at the end of this operation
|
||||
--load-extension TEXT Path to SQLite extension, with optional :entrypoint
|
||||
|
|
@ -311,12 +332,17 @@ See :ref:`cli_upsert`.
|
|||
|
||||
::
|
||||
|
||||
Usage: sqlite-utils upsert [OPTIONS] PATH TABLE FILE
|
||||
Usage: sqlite-utils upsert [OPTIONS] PATH TABLE [FILE]
|
||||
|
||||
Upsert records based on their primary key. Works like 'insert' but if an
|
||||
incoming record has a primary key that matches an existing record the existing
|
||||
record will be updated.
|
||||
|
||||
If the table already exists and has a primary key, --pk can be omitted.
|
||||
|
||||
Use --type column-name type to override the type automatically chosen when the
|
||||
table is created.
|
||||
|
||||
Example:
|
||||
|
||||
echo '[
|
||||
|
|
@ -326,7 +352,8 @@ See :ref:`cli_upsert`.
|
|||
|
||||
Options:
|
||||
--pk TEXT Columns to use as the primary key, e.g. id
|
||||
[required]
|
||||
--code TEXT Python code defining a rows() function or iterable
|
||||
of rows to insert
|
||||
--flatten Flatten nested JSON objects, so {"a": {"b": 1}}
|
||||
becomes {"a_b": 1}
|
||||
--nl Expect newline-delimited JSON
|
||||
|
|
@ -347,6 +374,7 @@ See :ref:`cli_upsert`.
|
|||
--alter Alter existing table to add any missing columns
|
||||
--not-null TEXT Columns that should be created as NOT NULL
|
||||
--default <TEXT TEXT>... Default value that should be set for a column
|
||||
--type <TEXT CHOICE>... Column types to use when creating the table
|
||||
--no-detect-types Treat all CSV/TSV columns as TEXT
|
||||
--analyze Run ANALYZE at the end of this operation
|
||||
--load-extension TEXT Path to SQLite extension, with optional :entrypoint
|
||||
|
|
@ -379,7 +407,8 @@ See :ref:`cli_bulk`.
|
|||
|
||||
Options:
|
||||
--batch-size INTEGER Commit every X records
|
||||
--functions TEXT Python code or file path defining custom SQL functions
|
||||
--functions TEXT Python code or a file path defining custom SQL
|
||||
functions; can be used multiple times
|
||||
--flatten Flatten nested JSON objects, so {"a": {"b": 1}} becomes
|
||||
{"a_b": 1}
|
||||
--nl Expect newline-delimited JSON
|
||||
|
|
@ -465,7 +494,7 @@ See :ref:`cli_transform_table`.
|
|||
|
||||
Options:
|
||||
--type <TEXT CHOICE>... Change column type to INTEGER, TEXT, FLOAT,
|
||||
REAL or BLOB
|
||||
REAL, BLOB or ANY
|
||||
--drop TEXT Drop this column
|
||||
--rename <TEXT TEXT>... Rename this column to X
|
||||
-o, --column-order TEXT Reorder columns
|
||||
|
|
@ -479,6 +508,8 @@ See :ref:`cli_transform_table`.
|
|||
Add a foreign key constraint from a column to
|
||||
another table with another column
|
||||
--drop-foreign-key TEXT Drop foreign key constraint for this column
|
||||
--strict / --no-strict Enable or disable STRICT mode (default:
|
||||
preserve current mode)
|
||||
--sql Output SQL without executing it
|
||||
--load-extension TEXT Path to SQLite extension, with optional
|
||||
:entrypoint
|
||||
|
|
@ -616,6 +647,11 @@ See :ref:`cli_convert`.
|
|||
|
||||
"value" is a variable with the column value to be converted.
|
||||
|
||||
CODE can also be a reference to a callable that takes the value, for example:
|
||||
|
||||
sqlite-utils convert my.db mytable date r.parsedate
|
||||
sqlite-utils convert my.db mytable data json.loads --import json
|
||||
|
||||
Use "-" for CODE to read Python code from standard input.
|
||||
|
||||
The following common operations are available as recipe functions:
|
||||
|
|
@ -626,20 +662,18 @@ See :ref:`cli_convert`.
|
|||
Convert a string like a,b,c into a JSON array ["a", "b", "c"]
|
||||
|
||||
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
|
||||
|
||||
- dayfirst=True: treat xx as the day in xx/yy/zz
|
||||
- yearfirst=True: treat xx as the year in xx/yy/zz
|
||||
- errors=r.IGNORE to ignore values that cannot be parsed
|
||||
- errors=r.SET_NULL to set values that cannot be parsed to null
|
||||
|
||||
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
|
||||
|
||||
- dayfirst=True: treat xx as the day in xx/yy/zz
|
||||
- yearfirst=True: treat xx as the year in xx/yy/zz
|
||||
- errors=r.IGNORE to ignore values that cannot be parsed
|
||||
|
|
@ -926,10 +960,10 @@ See :ref:`cli_create_table`.
|
|||
sqlite-utils create-table my.db people \
|
||||
id integer \
|
||||
name text \
|
||||
height float \
|
||||
height real \
|
||||
photo blob --pk id
|
||||
|
||||
Valid column types are text, integer, float and blob.
|
||||
Valid column types are text, integer, real, float, blob and any.
|
||||
|
||||
Options:
|
||||
--pk TEXT Column to use as primary key
|
||||
|
|
@ -975,6 +1009,29 @@ See :ref:`cli_create_index`.
|
|||
-h, --help Show this message and exit.
|
||||
|
||||
|
||||
.. _cli_ref_drop_index:
|
||||
|
||||
drop-index
|
||||
==========
|
||||
|
||||
See :ref:`cli_drop_index`.
|
||||
|
||||
::
|
||||
|
||||
Usage: sqlite-utils drop-index [OPTIONS] PATH TABLE INDEX
|
||||
|
||||
Drop an index by index name from the specified table
|
||||
|
||||
Example:
|
||||
|
||||
sqlite-utils drop-index chickens.db chickens idx_chickens_name
|
||||
|
||||
Options:
|
||||
--ignore Ignore if index does not exist
|
||||
--load-extension TEXT Path to SQLite extension, with optional :entrypoint
|
||||
-h, --help Show this message and exit.
|
||||
|
||||
|
||||
.. _cli_ref_migrate:
|
||||
|
||||
migrate
|
||||
|
|
@ -1024,7 +1081,7 @@ See :ref:`cli_fts`.
|
|||
|
||||
Usage: sqlite-utils enable-fts [OPTIONS] PATH TABLE COLUMN...
|
||||
|
||||
Enable full-text search for specific table and columns"
|
||||
Enable full-text search for specific table and columns
|
||||
|
||||
Example:
|
||||
|
||||
|
|
@ -1200,7 +1257,7 @@ See :ref:`cli_add_column`.
|
|||
::
|
||||
|
||||
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
|
||||
|
||||
|
|
|
|||
190
docs/cli.rst
190
docs/cli.rst
|
|
@ -29,6 +29,16 @@ The ``sqlite-utils query`` command lets you run queries directly against a SQLit
|
|||
.. note::
|
||||
In Python: :ref:`db.query() <python_api_query>` CLI reference: :ref:`sqlite-utils query <cli_ref_query>`
|
||||
|
||||
Pass ``-`` as the SQL query to read the query from standard input. This is useful for longer queries that would otherwise require careful shell escaping, or for piping in SQL generated by another tool:
|
||||
|
||||
.. code-block:: bash
|
||||
|
||||
echo "select * from dogs" | sqlite-utils query dogs.db -
|
||||
|
||||
.. code-block:: bash
|
||||
|
||||
sqlite-utils query dogs.db - < query.sql
|
||||
|
||||
.. _cli_query_json:
|
||||
|
||||
Returning JSON
|
||||
|
|
@ -351,7 +361,7 @@ To return the first column of each result as raw data, separated by newlines, us
|
|||
Using named parameters
|
||||
----------------------
|
||||
|
||||
You can pass named parameters to the query using ``-p``:
|
||||
You can pass named parameters to the query using ``-p name value``:
|
||||
|
||||
.. code-block:: bash
|
||||
|
||||
|
|
@ -414,6 +424,9 @@ The ``--functions`` option can be used multiple times to load functions from mul
|
|||
from urllib.parse import urlparse
|
||||
return urlparse(url).path'
|
||||
|
||||
.. note::
|
||||
In Python: :ref:`db.register_function() <python_api_register_function>`
|
||||
|
||||
.. _cli_query_extensions:
|
||||
|
||||
SQLite extensions
|
||||
|
|
@ -1014,6 +1027,9 @@ To show more than 10 common values, use ``--common-limit 20``. To skip the most
|
|||
|
||||
sqlite-utils analyze-tables github.db tags --common-limit 20 --no-least
|
||||
|
||||
.. note::
|
||||
In Python: :ref:`table.analyze_column() <python_api_analyze_column>` CLI reference: :ref:`sqlite-utils analyze-tables <cli_ref_analyze_tables>`
|
||||
|
||||
.. _cli_analyze_tables_save:
|
||||
|
||||
Saving the analyzed table details
|
||||
|
|
@ -1181,6 +1197,9 @@ You can delete all the existing rows in the table before inserting the new recor
|
|||
|
||||
You can add the ``--analyze`` option to run ``ANALYZE`` against the table after the rows have been inserted.
|
||||
|
||||
.. note::
|
||||
In Python: :ref:`table.insert_all() <python_api_bulk_inserts>` CLI reference: :ref:`sqlite-utils insert <cli_ref_insert>`
|
||||
|
||||
.. _cli_inserting_data_binary:
|
||||
|
||||
Inserting binary data
|
||||
|
|
@ -1356,6 +1375,32 @@ Will produce this schema with automatically detected types:
|
|||
"weight" REAL
|
||||
);
|
||||
|
||||
.. _cli_insert_csv_tsv_column_types:
|
||||
|
||||
Overriding column types
|
||||
-----------------------
|
||||
|
||||
Use ``--type column-name type`` to override the type automatically chosen when the table is created. This option can be used more than once, and works with both ``insert`` and ``upsert``:
|
||||
|
||||
.. code-block:: bash
|
||||
|
||||
sqlite-utils insert places.db places places.csv --csv \
|
||||
--type zipcode text \
|
||||
--type score real
|
||||
|
||||
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``, ``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.
|
||||
|
||||
To disable type detection and treat all columns as TEXT, use ``--no-detect-types``:
|
||||
|
||||
.. code-block:: bash
|
||||
|
|
@ -1551,6 +1596,27 @@ The result looks like this:
|
|||
COMMIT;
|
||||
|
||||
|
||||
.. _cli_insert_code:
|
||||
|
||||
Inserting rows generated by Python code
|
||||
=======================================
|
||||
|
||||
Instead of providing a ``FILE`` to import, you can use the ``--code`` option to pass a block of Python code that generates the rows to insert. This is the command-line equivalent of calling ``db["creatures"].insert_all(rows())`` from the :ref:`Python API <python_api>`.
|
||||
|
||||
Your code should define either a ``rows()`` function that returns or yields dictionaries, or a ``rows`` iterable such as a list of dictionaries:
|
||||
|
||||
.. code-block:: bash
|
||||
|
||||
sqlite-utils insert data.db creatures --code '
|
||||
def rows():
|
||||
yield {"id": 1, "name": "Cleo"}
|
||||
yield {"id": 2, "name": "Suna"}
|
||||
' --pk id
|
||||
|
||||
``--code`` can also be given a path to a Python ``.py`` file.
|
||||
|
||||
The ``--code`` option works with both ``sqlite-utils insert`` and ``sqlite-utils upsert``, and composes with table options such as ``--pk``, ``--replace``, ``--alter``, ``--not-null`` and ``--default``. It cannot be combined with a ``FILE`` argument or with input format options such as ``--csv`` or ``--convert``.
|
||||
|
||||
.. _cli_insert_replace:
|
||||
|
||||
Insert-replacing data
|
||||
|
|
@ -1565,6 +1631,9 @@ To replace a dog with in ID of 2 with a new record, run the following:
|
|||
echo '{"id": 2, "name": "Pancakes", "age": 3}' | \
|
||||
sqlite-utils insert dogs.db dogs - --pk=id --replace
|
||||
|
||||
.. note::
|
||||
In Python: :ref:`table.insert(..., replace=True) <python_api_insert_replace>` CLI reference: :ref:`sqlite-utils insert <cli_ref_insert>`
|
||||
|
||||
.. _cli_upsert:
|
||||
|
||||
Upserting data
|
||||
|
|
@ -1583,12 +1652,17 @@ For example:
|
|||
|
||||
This will update the dog with an ID of 2 to have an age of 4, creating a new record (with a null name) if one does not exist. If a row DOES exist the name will be left as-is.
|
||||
|
||||
If the table already exists and has a primary key, you can omit the ``--pk`` option and ``sqlite-utils`` will use that existing primary key.
|
||||
|
||||
The command will fail if you reference columns that do not exist on the table. To automatically create missing columns, use the ``--alter`` option.
|
||||
|
||||
.. note::
|
||||
``upsert`` in sqlite-utils 1.x worked like ``insert ... --replace`` does in 2.x. See `issue #66 <https://github.com/simonw/sqlite-utils/issues/66>`__ for details of this change.
|
||||
|
||||
|
||||
.. note::
|
||||
In Python: :ref:`table.upsert() <python_api_upsert>` CLI reference: :ref:`sqlite-utils upsert <cli_ref_upsert>`
|
||||
|
||||
.. _cli_bulk:
|
||||
|
||||
Executing SQL in bulk
|
||||
|
|
@ -1790,6 +1864,9 @@ You can include named parameters in your where clause and populate them using on
|
|||
|
||||
The ``--dry-run`` option will output a preview of the conversion against the first ten rows, without modifying the database.
|
||||
|
||||
.. note::
|
||||
In Python: :ref:`table.convert() <python_api_convert>` CLI reference: :ref:`sqlite-utils convert <cli_ref_convert>`
|
||||
|
||||
.. _cli_convert_import:
|
||||
|
||||
Importing additional modules
|
||||
|
|
@ -2071,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
|
||||
|
||||
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
|
||||
|
||||
sqlite-utils tables mydb.db --schema -t
|
||||
|
|
@ -2088,6 +2171,9 @@ If a table with the same name already exists, you will get an error. You can cho
|
|||
|
||||
You can also pass ``--transform`` to transform the existing table to match the new schema. See :ref:`python_api_explicit_create` in the Python library documentation for details of how this option works.
|
||||
|
||||
.. note::
|
||||
In Python: :ref:`table.create() <python_api_explicit_create>` CLI reference: :ref:`sqlite-utils create-table <cli_ref_create_table>`
|
||||
|
||||
.. _cli_renaming_tables:
|
||||
|
||||
Renaming a table
|
||||
|
|
@ -2101,6 +2187,9 @@ Yo ucan rename a table using the ``rename-table`` command:
|
|||
|
||||
Pass ``--ignore`` to ignore any errors caused by the table not existing, or the new name already being in use.
|
||||
|
||||
.. note::
|
||||
In Python: :ref:`db.rename_table() <python_api_rename_table>` CLI reference: :ref:`sqlite-utils rename-table <cli_ref_rename_table>`
|
||||
|
||||
.. _cli_duplicate_table:
|
||||
|
||||
Duplicating tables
|
||||
|
|
@ -2112,6 +2201,9 @@ The ``duplicate`` command duplicates a table - creating a new table with the sam
|
|||
|
||||
sqlite-utils duplicate books.db authors authors_copy
|
||||
|
||||
.. note::
|
||||
In Python: :ref:`table.duplicate() <python_api_duplicate>` CLI reference: :ref:`sqlite-utils duplicate <cli_ref_duplicate>`
|
||||
|
||||
.. _cli_drop_table:
|
||||
|
||||
Dropping tables
|
||||
|
|
@ -2125,12 +2217,15 @@ You can drop a table using the ``drop-table`` command:
|
|||
|
||||
Use ``--ignore`` to ignore the error if the table does not exist.
|
||||
|
||||
.. note::
|
||||
In Python: :ref:`table.drop() <python_api_drop>` CLI reference: :ref:`sqlite-utils drop-table <cli_ref_drop_table>`
|
||||
|
||||
.. _cli_transform_table:
|
||||
|
||||
Transforming tables
|
||||
===================
|
||||
|
||||
The ``transform`` command allows you to apply complex transformations to a table that cannot be implemented using a regular SQLite ``ALTER TABLE`` command. See :ref:`python_api_transform` for details of how this works. The ``transform`` command preserves a table's ``STRICT`` mode.
|
||||
The ``transform`` command allows you to apply complex transformations to a table that cannot be implemented using a regular SQLite ``ALTER TABLE`` command. See :ref:`python_api_transform` for details of how this works. By default, the ``transform`` command preserves a table's ``STRICT`` mode.
|
||||
|
||||
.. code-block:: bash
|
||||
|
||||
|
|
@ -2141,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:
|
||||
|
||||
``--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 the specified column.
|
||||
|
|
@ -2176,6 +2271,12 @@ Every option for this table (with the exception of ``--pk-none``) can be specifi
|
|||
``--add-foreign-key column other_table other_column``
|
||||
Add a foreign key constraint to ``column`` pointing to ``other_table.other_column``.
|
||||
|
||||
``--strict``
|
||||
Convert the table to a `SQLite STRICT table <https://www.sqlite.org/stricttables.html>`__. The command fails if the available SQLite version does not support strict tables. If existing rows contain values that are incompatible with their declared column types the transformation fails and the original table is left unchanged.
|
||||
|
||||
``--no-strict``
|
||||
Convert a strict table back to a regular non-strict table.
|
||||
|
||||
If you want to see the SQL that will be executed to make the change without actually executing it, add the ``--sql`` flag. For example:
|
||||
|
||||
.. code-block:: bash
|
||||
|
|
@ -2200,7 +2301,14 @@ 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")
|
||||
SELECT "longitude", "latitude", "pk", "name" FROM "roadside_attractions";
|
||||
DROP TABLE "roadside_attractions";
|
||||
PRAGMA legacy_alter_table=ON;
|
||||
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::
|
||||
In Python: :ref:`table.transform() <python_api_transform>` CLI reference: :ref:`sqlite-utils transform <cli_ref_transform>`
|
||||
|
||||
.. _cli_transform_table_add_primary_key_to_rowid:
|
||||
|
||||
|
|
@ -2379,6 +2487,9 @@ After running the above, the command ``sqlite-utils schema global.db`` reveals t
|
|||
CREATE UNIQUE INDEX "idx_countries_country_name"
|
||||
ON "countries" ("country", "name");
|
||||
|
||||
.. note::
|
||||
In Python: :ref:`table.extract() <python_api_extract>` CLI reference: :ref:`sqlite-utils extract <cli_ref_extract>`
|
||||
|
||||
.. _cli_create_view:
|
||||
|
||||
Creating views
|
||||
|
|
@ -2400,6 +2511,9 @@ You can create a view using the ``create-view`` command:
|
|||
|
||||
Use ``--replace`` to replace an existing view of the same name, and ``--ignore`` to do nothing if a view already exists.
|
||||
|
||||
.. note::
|
||||
In Python: :ref:`db.create_view() <python_api_create_view>` CLI reference: :ref:`sqlite-utils create-view <cli_ref_create_view>`
|
||||
|
||||
.. _cli_drop_view:
|
||||
|
||||
Dropping views
|
||||
|
|
@ -2413,6 +2527,9 @@ You can drop a view using the ``drop-view`` command:
|
|||
|
||||
Use ``--ignore`` to ignore the error if the view does not exist.
|
||||
|
||||
.. note::
|
||||
In Python: :ref:`view.drop() <python_api_drop>` CLI reference: :ref:`sqlite-utils drop-view <cli_ref_drop_view>`
|
||||
|
||||
.. _cli_add_column:
|
||||
|
||||
Adding columns
|
||||
|
|
@ -2453,6 +2570,9 @@ You can set a ``NOT NULL DEFAULT 'x'`` constraint on the new column using ``--no
|
|||
|
||||
sqlite-utils add-column mydb.db dogs friends_count integer --not-null-default 0
|
||||
|
||||
.. note::
|
||||
In Python: :ref:`table.add_column() <python_api_add_column>` CLI reference: :ref:`sqlite-utils add-column <cli_ref_add_column>`
|
||||
|
||||
.. _cli_add_column_alter:
|
||||
|
||||
Adding columns automatically on insert/update
|
||||
|
|
@ -2464,6 +2584,9 @@ You can use the ``--alter`` option to automatically add new columns if the data
|
|||
|
||||
sqlite-utils insert dogs.db dogs new-dogs.json --pk=id --alter
|
||||
|
||||
.. note::
|
||||
In Python: :ref:`table.insert(..., alter=True) <python_api_add_column_alter>`
|
||||
|
||||
.. _cli_add_foreign_key:
|
||||
|
||||
Adding foreign key constraints
|
||||
|
|
@ -2491,6 +2614,9 @@ Add ``--ignore`` to ignore an existing foreign key (as opposed to returning an e
|
|||
|
||||
See :ref:`python_api_add_foreign_key` in the Python API documentation for further details, including how the automatic table guessing mechanism works.
|
||||
|
||||
.. note::
|
||||
In Python: :ref:`table.add_foreign_key() <python_api_add_foreign_key>` CLI reference: :ref:`sqlite-utils add-foreign-key <cli_ref_add_foreign_key>`
|
||||
|
||||
.. _cli_add_foreign_keys:
|
||||
|
||||
Adding multiple foreign keys at once
|
||||
|
|
@ -2506,6 +2632,9 @@ Adding a foreign key requires a ``VACUUM``. On large databases this can be an ex
|
|||
|
||||
When you are using this command each foreign key needs to be defined in full, as four arguments - the table, column, other table and other column.
|
||||
|
||||
.. note::
|
||||
In Python: :ref:`db.add_foreign_keys() <python_api_add_foreign_keys>` CLI reference: :ref:`sqlite-utils add-foreign-keys <cli_ref_add_foreign_keys>`
|
||||
|
||||
.. _cli_index_foreign_keys:
|
||||
|
||||
Adding indexes for all foreign keys
|
||||
|
|
@ -2517,6 +2646,9 @@ If you want to ensure that every foreign key column in your database has a corre
|
|||
|
||||
sqlite-utils index-foreign-keys books.db
|
||||
|
||||
.. note::
|
||||
In Python: :ref:`db.index_foreign_keys() <python_api_index_foreign_keys>` CLI reference: :ref:`sqlite-utils index-foreign-keys <cli_ref_index_foreign_keys>`
|
||||
|
||||
.. _cli_defaults_not_null:
|
||||
|
||||
Setting defaults and not null constraints
|
||||
|
|
@ -2532,6 +2664,9 @@ You can use the ``--not-null`` and ``--default`` options (to both ``insert`` and
|
|||
--default age 2 \
|
||||
--default score 5
|
||||
|
||||
.. note::
|
||||
In Python: :ref:`not_null= and defaults= arguments <python_api_defaults_not_null>`
|
||||
|
||||
.. _cli_create_index:
|
||||
|
||||
Creating indexes
|
||||
|
|
@ -2563,6 +2698,25 @@ If your column names are already prefixed with a hyphen you'll need to manually
|
|||
|
||||
Add the ``--analyze`` option to run ``ANALYZE`` against the index after it has been created.
|
||||
|
||||
.. note::
|
||||
In Python: :ref:`table.create_index() <python_api_create_index>` CLI reference: :ref:`sqlite-utils create-index <cli_ref_create_index>`
|
||||
|
||||
.. _cli_drop_index:
|
||||
|
||||
Dropping indexes
|
||||
================
|
||||
|
||||
You can drop an index from an existing table using the ``drop-index`` command:
|
||||
|
||||
.. code-block:: bash
|
||||
|
||||
sqlite-utils drop-index mydb.db mytable idx_mytable_col1
|
||||
|
||||
Use ``--ignore`` to ignore the error if the index does not exist on that table.
|
||||
|
||||
.. note::
|
||||
In Python: :ref:`table.drop_index() <python_api_create_index>` CLI reference: :ref:`sqlite-utils drop-index <cli_ref_drop_index>`
|
||||
|
||||
.. _cli_fts:
|
||||
|
||||
Configuring full-text search
|
||||
|
|
@ -2616,6 +2770,9 @@ You can rebuild every FTS table by running ``rebuild-fts`` without passing any t
|
|||
|
||||
sqlite-utils rebuild-fts mydb.db
|
||||
|
||||
.. note::
|
||||
In Python: :ref:`table.enable_fts() <python_api_fts_enable>` CLI reference: :ref:`sqlite-utils enable-fts <cli_ref_enable_fts>`
|
||||
|
||||
.. _cli_search:
|
||||
|
||||
Executing searches
|
||||
|
|
@ -2672,6 +2829,9 @@ Use the ``--sql`` option to output the SQL that would be executed, rather than r
|
|||
order by
|
||||
"documents_fts".rank
|
||||
|
||||
.. note::
|
||||
In Python: :ref:`table.search() <python_api_fts_search>` CLI reference: :ref:`sqlite-utils search <cli_ref_search>`
|
||||
|
||||
.. _cli_enable_counts:
|
||||
|
||||
Enabling cached counts
|
||||
|
|
@ -2695,6 +2855,9 @@ If the ``_counts`` table ever becomes out-of-sync with the actual table counts y
|
|||
|
||||
sqlite-utils reset-counts mydb.db
|
||||
|
||||
.. note::
|
||||
In Python: :ref:`table.enable_counts() <python_api_cached_table_counts>` CLI reference: :ref:`sqlite-utils enable-counts <cli_ref_enable_counts>`
|
||||
|
||||
.. _cli_analyze:
|
||||
|
||||
Optimizing index usage with ANALYZE
|
||||
|
|
@ -2718,6 +2881,9 @@ You can run it against specific tables, or against specific named indexes, by pa
|
|||
|
||||
You can also run ``ANALYZE`` as part of another command using the ``--analyze`` option. This is supported by the ``create-index``, ``insert`` and ``upsert`` commands.
|
||||
|
||||
.. note::
|
||||
In Python: :ref:`db.analyze() <python_api_analyze>` CLI reference: :ref:`sqlite-utils analyze <cli_ref_analyze>`
|
||||
|
||||
.. _cli_vacuum:
|
||||
|
||||
Vacuum
|
||||
|
|
@ -2729,6 +2895,9 @@ You can run VACUUM to optimize your database like so:
|
|||
|
||||
sqlite-utils vacuum mydb.db
|
||||
|
||||
.. note::
|
||||
In Python: :ref:`db.vacuum() <python_api_vacuum>` CLI reference: :ref:`sqlite-utils vacuum <cli_ref_vacuum>`
|
||||
|
||||
.. _cli_optimize:
|
||||
|
||||
Optimize
|
||||
|
|
@ -2752,6 +2921,9 @@ To optimize specific tables rather than every FTS table, pass those tables as ex
|
|||
|
||||
sqlite-utils optimize mydb.db table_1 table_2
|
||||
|
||||
.. note::
|
||||
In Python: :ref:`table.optimize() <python_api_fts_optimize>` CLI reference: :ref:`sqlite-utils optimize <cli_ref_optimize>`
|
||||
|
||||
.. _cli_wal:
|
||||
|
||||
WAL mode
|
||||
|
|
@ -2771,6 +2943,9 @@ You can disable WAL mode using ``disable-wal``:
|
|||
|
||||
Both of these commands accept one or more database files as arguments.
|
||||
|
||||
.. note::
|
||||
In Python: :ref:`db.enable_wal() and db.disable_wal() <python_api_wal>` CLI reference: :ref:`sqlite-utils enable-wal <cli_ref_enable_wal>`
|
||||
|
||||
.. _cli_dump:
|
||||
|
||||
Dumping the database to SQL
|
||||
|
|
@ -2786,6 +2961,9 @@ The ``dump`` command outputs a SQL dump of the schema and full contents of the s
|
|||
...
|
||||
COMMIT;
|
||||
|
||||
.. note::
|
||||
In Python: :ref:`db.iterdump() <python_api_itedump>` CLI reference: :ref:`sqlite-utils dump <cli_ref_dump>`
|
||||
|
||||
.. _cli_load_extension:
|
||||
|
||||
Loading SQLite extensions
|
||||
|
|
@ -2833,6 +3011,9 @@ Eight (case-insensitive) types are allowed:
|
|||
* GEOMETRYCOLLECTION
|
||||
* GEOMETRY
|
||||
|
||||
.. note::
|
||||
In Python: :ref:`table.add_geometry_column() <python_api_gis_add_geometry_column>` CLI reference: :ref:`sqlite-utils add-geometry-column <cli_ref_add_geometry_column>`
|
||||
|
||||
.. _cli_spatialite_indexes:
|
||||
|
||||
Adding spatial indexes
|
||||
|
|
@ -2846,6 +3027,9 @@ Once you have a geometry column, you can speed up bounding box queries by adding
|
|||
|
||||
See this `SpatiaLite Cookbook recipe <http://www.gaia-gis.it/gaia-sins/spatialite-cookbook-5/cookbook_topics.03.html#topic_Wonderful_RTree_Spatial_Index>`__ for examples of how to use a spatial index.
|
||||
|
||||
.. note::
|
||||
In Python: :ref:`table.create_spatial_index() <python_api_gis_create_spatial_index>` CLI reference: :ref:`sqlite-utils create-spatial-index <cli_ref_create_spatial_index>`
|
||||
|
||||
.. _cli_install:
|
||||
|
||||
Installing packages
|
||||
|
|
|
|||
11
docs/conf.py
11
docs/conf.py
|
|
@ -1,10 +1,7 @@
|
|||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
import inspect
|
||||
from pathlib import Path
|
||||
from subprocess import Popen, PIPE, check_output
|
||||
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
|
||||
# containing dir.
|
||||
|
|
@ -50,7 +47,7 @@ extlinks = {
|
|||
def _linkcode_git_ref():
|
||||
try:
|
||||
return check_output(["git", "rev-parse", "HEAD"]).decode("utf8").strip()
|
||||
except Exception:
|
||||
except (CalledProcessError, OSError):
|
||||
return "main"
|
||||
|
||||
|
||||
|
|
@ -79,7 +76,7 @@ def linkcode_resolve(domain, info):
|
|||
obj = inspect.unwrap(obj)
|
||||
source_file = inspect.getsourcefile(obj)
|
||||
_, line_number = inspect.getsourcelines(obj)
|
||||
except Exception:
|
||||
except (OSError, TypeError, ValueError):
|
||||
return None
|
||||
|
||||
if source_file is None:
|
||||
|
|
|
|||
|
|
@ -27,7 +27,7 @@ Here is a simple example of a ``migrations.py`` file which creates a table, then
|
|||
|
||||
.. code-block:: python
|
||||
|
||||
from sqlite_utils import Database, Migrations
|
||||
from sqlite_utils import Migrations
|
||||
|
||||
migrations = Migrations("creatures")
|
||||
|
||||
|
|
@ -51,6 +51,8 @@ Once you have a ``Migrations(name)`` collection with one or more migrations regi
|
|||
|
||||
.. code-block:: python
|
||||
|
||||
from sqlite_utils import Database
|
||||
|
||||
db = Database("creatures.db")
|
||||
migrations.apply(db)
|
||||
|
||||
|
|
|
|||
|
|
@ -109,6 +109,14 @@ You can also create a named in-memory database. Unlike regular memory databases
|
|||
|
||||
db = Database(memory_name="my_shared_database")
|
||||
|
||||
After creating a ``Database`` you can use ``db.memory`` and ``db.memory_name`` to tell whether it is backed by an in-memory database and to read the shared cache name. ``db.memory`` is ``True`` for any in-memory database and ``db.memory_name`` holds the name passed to ``memory_name=``, or ``None`` otherwise.
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
db = Database(memory_name="shared")
|
||||
db.memory # True
|
||||
db.memory_name # "shared"
|
||||
|
||||
Connections use ``PRAGMA recursive_triggers=on`` by default. If you don't want to use `recursive triggers <https://www.sqlite.org/pragma.html#pragma_recursive_triggers>`__ you can turn them off using:
|
||||
|
||||
.. code-block:: python
|
||||
|
|
@ -176,6 +184,9 @@ You can attach an additional database using the ``.attach()`` method, providing
|
|||
|
||||
You can reference tables in the attached database using the alias value you passed to ``db.attach(alias, filepath)`` as a prefix, for example the ``second.table_in_second`` reference in the SQL query above.
|
||||
|
||||
.. note::
|
||||
In the CLI: :ref:`sqlite-utils --attach <cli_query_attach>`
|
||||
|
||||
.. _python_api_tracing:
|
||||
|
||||
Tracing queries
|
||||
|
|
@ -246,6 +257,9 @@ If a query returns more than one column with the same name - a join between two
|
|||
|
||||
A suffix that would collide with another column in the query is skipped - ``select 1 as id, 2 as id, 3 as id_2`` returns ``{'id': 1, 'id_3': 2, 'id_2': 3}``. The same renaming is applied by ``table.rows_where()`` and ``table.search()``.
|
||||
|
||||
.. note::
|
||||
In the CLI: :ref:`sqlite-utils query <cli_query>`
|
||||
|
||||
.. _python_api_execute:
|
||||
|
||||
db.execute(sql, params)
|
||||
|
|
@ -312,11 +326,15 @@ Every method in this library that writes to the database - ``insert()``, ``upser
|
|||
|
||||
The same applies to raw SQL executed with :ref:`db.execute() <python_api_transactions_execute>` - a write statement is committed as soon as it has run.
|
||||
|
||||
Another way to think about this is that each sqlite-utils method call is its own unit of work. If several method calls must either all succeed or all fail, use ``db.atomic()`` to turn them into a single unit of work.
|
||||
|
||||
You never need to call ``commit()``, and you do not need to close the database to persist your changes. There are exactly two situations where you need to think about transactions:
|
||||
|
||||
1. You want to group several write operations together, so they either all succeed or all fail - use :ref:`db.atomic() <python_api_atomic>`.
|
||||
2. You are :ref:`managing a transaction yourself <python_api_transactions_manual>` with ``db.begin()``, in which case nothing is committed until you commit - the library will never commit a transaction you opened.
|
||||
|
||||
``with Database(...) as db:`` is not a transaction block. It manages the lifetime of the database connection and closes it on exit. Use ``with db.atomic():`` for a transaction.
|
||||
|
||||
.. _python_api_atomic:
|
||||
|
||||
Grouping changes with db.atomic()
|
||||
|
|
@ -332,6 +350,27 @@ Use ``db.atomic()`` to group multiple operations in a single transaction:
|
|||
|
||||
The transaction commits when the block exits. If an exception is raised, changes made inside the block will be rolled back.
|
||||
|
||||
This matters when several operations represent a single logical change. Without ``db.atomic()``, an earlier method call remains committed if a later one fails:
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
# These are two separate transactions
|
||||
db.table("accounts").update(1, {"balance": 90})
|
||||
db.table("accounts").update(2, {"balance": 110})
|
||||
|
||||
# These updates either both succeed or both fail
|
||||
with db.atomic():
|
||||
db.table("accounts").update(1, {"balance": 90})
|
||||
db.table("accounts").update(2, {"balance": 110})
|
||||
|
||||
Transactions can also improve performance. Calling ``insert()`` repeatedly outside ``db.atomic()`` creates and commits a separate transaction for every call. For bulk inserts, prefer :ref:`insert_all() <python_api_bulk_inserts>`. If you need to call several different methods in a loop, wrap the loop in ``db.atomic()``:
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
with db.atomic():
|
||||
for row in rows:
|
||||
db.table("events").insert(row)
|
||||
|
||||
``db.atomic()`` can be nested. Nested blocks use SQLite savepoints, so an exception in an inner block can roll back to that savepoint without rolling back the entire outer transaction:
|
||||
|
||||
.. code-block:: python
|
||||
|
|
@ -360,6 +399,8 @@ Write statements executed with :ref:`db.execute() <python_api_execute>` follow t
|
|||
db.execute("insert into news (headline) values (?)", ["Dog wins award"])
|
||||
# Already committed
|
||||
|
||||
``db.execute()`` participates in sqlite-utils transaction handling. Calling ``db.conn.execute()`` directly bypasses that policy and leaves transaction handling to Python's underlying ``sqlite3.Connection``. Prefer ``db.execute()`` unless you deliberately need the lower-level API.
|
||||
|
||||
If a transaction is open - because the call happens inside a ``db.atomic()`` block, or after ``db.begin()`` - the statement becomes part of that transaction instead, and commits when the transaction commits:
|
||||
|
||||
.. code-block:: python
|
||||
|
|
@ -391,9 +432,12 @@ You can take full manual control using the ``db.begin()``, ``db.commit()`` and `
|
|||
|
||||
The library will never commit a transaction you opened. If you call write methods such as ``insert()`` - or use ``db.atomic()`` - while your transaction is open, they participate in it using SQLite savepoints instead of committing: exiting an ``atomic()`` block releases its savepoint, but nothing is saved to disk until you commit the outer transaction yourself. If you roll back, their changes are rolled back too.
|
||||
|
||||
Two related safeguards to be aware of:
|
||||
Prefer ``db.atomic()`` or ``db.begin()``, ``db.commit()`` and ``db.rollback()`` over mixing sqlite-utils transaction methods with calls to ``db.conn.commit()``, ``db.conn.rollback()`` or raw transaction-control SQL. Mixing the two layers makes it much harder to tell which layer owns the current transaction.
|
||||
|
||||
Some related safeguards to be aware of:
|
||||
|
||||
- ``db.enable_wal()`` and ``db.disable_wal()`` raise a ``sqlite_utils.db.TransactionError`` if called while a transaction is open, because changing the journal mode would commit it as a side effect.
|
||||
- ``table.transform()`` raises a ``sqlite_utils.db.TransactionError`` if called while a transaction is open with ``PRAGMA foreign_keys`` enabled and the table is referenced by foreign keys with destructive ``ON DELETE`` actions, because the pragma cannot be turned off mid-transaction to protect those referencing rows - see :ref:`python_api_transform_foreign_keys_transactions`.
|
||||
- Closing the database - explicitly with ``db.close()``, or by exiting a ``with Database(...) as db:`` block - rolls back any transaction that is still open, see :ref:`python_api_close`.
|
||||
|
||||
.. _python_api_transactions_modes:
|
||||
|
|
@ -401,9 +445,11 @@ Two related safeguards to be aware of:
|
|||
Supported connection modes
|
||||
--------------------------
|
||||
|
||||
``db.atomic()`` and the automatic per-method transactions require a connection in Python's default transaction handling mode. Passing a connection created with the Python 3.12+ ``sqlite3.connect(..., autocommit=True)`` or ``autocommit=False`` options to ``Database()`` raises a ``sqlite_utils.db.TransactionError``.
|
||||
``db.atomic()`` and the automatic per-method transactions currently require a connection using Python's legacy transaction control mode (``sqlite3.LEGACY_TRANSACTION_CONTROL`` on Python 3.12 and later). Passing a connection created with the Python 3.12+ ``sqlite3.connect(..., autocommit=True)`` or ``autocommit=False`` options to ``Database()`` raises a ``sqlite_utils.db.TransactionError``.
|
||||
|
||||
This is because ``commit()`` and ``rollback()`` behave differently on those connections - under ``autocommit=True`` they are documented no-ops - which would cause every write made by this library to be silently discarded when the connection closed, rather than failing loudly.
|
||||
Connections using ``autocommit=False`` are not supported because Python keeps a transaction open continuously. sqlite-utils uses ``Connection.in_transaction`` to distinguish its own transactions from transactions opened by its caller, and that distinction is not available in this mode.
|
||||
|
||||
Connections using ``autocommit=True`` are also currently rejected because sqlite-utils has not formally exposed that as a supported configuration.
|
||||
|
||||
.. _python_api_table:
|
||||
|
||||
|
|
@ -458,6 +504,9 @@ You can also iterate through the table objects themselves using the ``.tables``
|
|||
>>> db.tables
|
||||
[<Table dogs>]
|
||||
|
||||
.. note::
|
||||
In the CLI: :ref:`sqlite-utils tables <cli_tables>`
|
||||
|
||||
.. _python_api_views:
|
||||
|
||||
Listing views
|
||||
|
|
@ -483,6 +532,9 @@ View objects are similar to Table objects, except that any attempts to insert or
|
|||
* ``rows_where(where, where_args, order_by, select)``
|
||||
* ``drop()``
|
||||
|
||||
.. note::
|
||||
In the CLI: :ref:`sqlite-utils views <cli_views>`
|
||||
|
||||
.. _python_api_rows:
|
||||
|
||||
Listing rows
|
||||
|
|
@ -538,6 +590,9 @@ This method also accepts ``offset=`` and ``limit=`` arguments, for specifying an
|
|||
... print(row)
|
||||
{'id': 1, 'age': 4, 'name': 'Cleo'}
|
||||
|
||||
.. note::
|
||||
In the CLI: :ref:`sqlite-utils rows <cli_rows>`
|
||||
|
||||
.. _python_api_rows_count_where:
|
||||
|
||||
Counting rows
|
||||
|
|
@ -622,6 +677,9 @@ The ``db.schema`` property returns the full SQL schema for the database as a str
|
|||
"name" TEXT
|
||||
);
|
||||
|
||||
.. note::
|
||||
In the CLI: :ref:`sqlite-utils schema <cli_schema>`
|
||||
|
||||
.. _python_api_creating_tables:
|
||||
|
||||
Creating tables
|
||||
|
|
@ -770,6 +828,22 @@ You can pass ``strict=True`` to create a table in ``STRICT`` mode:
|
|||
"name": str,
|
||||
}, 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::
|
||||
In the CLI: :ref:`sqlite-utils create-table <cli_create_table>`
|
||||
|
||||
.. _python_api_compound_primary_keys:
|
||||
|
||||
Compound primary keys
|
||||
|
|
@ -950,6 +1024,9 @@ Here's an example that uses these features:
|
|||
# )
|
||||
|
||||
|
||||
.. note::
|
||||
In the CLI: :ref:`sqlite-utils insert --not-null and --default <cli_defaults_not_null>`
|
||||
|
||||
.. _python_api_rename_table:
|
||||
|
||||
Renaming a table
|
||||
|
|
@ -967,6 +1044,9 @@ This executes the following SQL:
|
|||
|
||||
ALTER TABLE [my_table] RENAME TO [new_name_for_my_table]
|
||||
|
||||
.. note::
|
||||
In the CLI: :ref:`sqlite-utils rename-table <cli_renaming_tables>`
|
||||
|
||||
.. _python_api_duplicate:
|
||||
|
||||
Duplicating tables
|
||||
|
|
@ -982,6 +1062,9 @@ The new ``authors_copy`` table will now contain a duplicate copy of the data fro
|
|||
|
||||
This method raises ``sqlite_utils.db.NoTable`` if the table does not exist.
|
||||
|
||||
.. note::
|
||||
In the CLI: :ref:`sqlite-utils duplicate <cli_duplicate_table>`
|
||||
|
||||
.. _python_api_bulk_inserts:
|
||||
|
||||
Bulk inserts
|
||||
|
|
@ -1024,6 +1107,9 @@ You can delete all the existing rows in the table before inserting the new recor
|
|||
|
||||
Pass ``analyze=True`` to run ``ANALYZE`` against the table after inserting the new records.
|
||||
|
||||
.. note::
|
||||
In the CLI: :ref:`sqlite-utils insert <cli_inserting_data>`
|
||||
|
||||
.. _python_api_insert_lists:
|
||||
|
||||
Inserting data from a list or tuple iterator
|
||||
|
|
@ -1101,6 +1187,9 @@ To replace any existing records that have a matching primary key, use the ``repl
|
|||
.. note::
|
||||
Prior to sqlite-utils 2.0 the ``.upsert()`` and ``.upsert_all()`` methods worked the same way as ``.insert(replace=True)`` does today. See :ref:`python_api_upsert` for the new behaviour of those methods introduced in 2.0.
|
||||
|
||||
.. note::
|
||||
In the CLI: :ref:`sqlite-utils insert --replace <cli_insert_replace>`
|
||||
|
||||
.. _python_api_update:
|
||||
|
||||
Updating a specific record
|
||||
|
|
@ -1186,6 +1275,9 @@ Every record passed to ``upsert()`` or ``upsert_all()`` must include a value for
|
|||
.. note::
|
||||
``.upsert()`` and ``.upsert_all()`` in sqlite-utils 1.x worked like ``.insert(..., replace=True)`` and ``.insert_all(..., replace=True)`` do in 2.x. See `issue #66 <https://github.com/simonw/sqlite-utils/issues/66>`__ for details of this change.
|
||||
|
||||
.. note::
|
||||
In the CLI: :ref:`sqlite-utils upsert <cli_upsert>`
|
||||
|
||||
.. _python_api_old_upsert:
|
||||
|
||||
Alternative upserts using INSERT OR IGNORE
|
||||
|
|
@ -1490,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.
|
||||
|
||||
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::
|
||||
|
||||
|
|
@ -1503,6 +1595,7 @@ If you pass a Python type, it will be mapped to SQLite types as shown here::
|
|||
datetime.date: "TEXT"
|
||||
datetime.time: "TEXT"
|
||||
datetime.timedelta: "TEXT"
|
||||
sqlite_utils.ANY: "ANY"
|
||||
|
||||
# If numpy is installed
|
||||
np.int8: "INTEGER"
|
||||
|
|
@ -1537,6 +1630,9 @@ You can set a ``NOT NULL DEFAULT 'x'`` constraint on the new column using ``not_
|
|||
|
||||
db.table("dogs").add_column("friends_count", int, not_null_default=0)
|
||||
|
||||
.. note::
|
||||
In the CLI: :ref:`sqlite-utils add-column <cli_add_column>`
|
||||
|
||||
.. _python_api_add_column_alter:
|
||||
|
||||
Adding columns automatically on insert/update
|
||||
|
|
@ -1560,6 +1656,9 @@ You can insert or update data that includes new columns and have the table autom
|
|||
new_table = db.table("new_table", alter=True)
|
||||
new_table.insert({"name": "Gareth", "age": 32, "shoe_size": 11})
|
||||
|
||||
.. note::
|
||||
In the CLI: :ref:`sqlite-utils insert --alter <cli_add_column_alter>`
|
||||
|
||||
.. _python_api_add_foreign_key:
|
||||
|
||||
Adding foreign key constraints
|
||||
|
|
@ -1618,6 +1717,9 @@ Use ``on_delete=`` and ``on_update=`` to specify ``ON DELETE`` and ``ON UPDATE``
|
|||
|
||||
This creates a foreign key with an ``ON DELETE CASCADE`` clause, so deleting an author will also delete their books (provided foreign key enforcement is enabled with ``PRAGMA foreign_keys = ON``). Valid actions are ``"SET NULL"``, ``"SET DEFAULT"``, ``"CASCADE"``, ``"RESTRICT"`` and the default ``"NO ACTION"``.
|
||||
|
||||
.. note::
|
||||
In the CLI: :ref:`sqlite-utils add-foreign-key <cli_add_foreign_key>`
|
||||
|
||||
.. _python_api_add_foreign_keys:
|
||||
|
||||
Adding multiple foreign key constraints at once
|
||||
|
|
@ -1638,6 +1740,9 @@ This method runs the same checks as ``.add_foreign_keys()`` and will raise ``sql
|
|||
|
||||
Foreign keys that already exist are silently skipped, so repeated calls are idempotent - but only if they match exactly. Requesting a foreign key that exists with different ``ON DELETE``/``ON UPDATE`` actions raises ``AlterError``: use ``table.transform()`` to change the actions of an existing foreign key.
|
||||
|
||||
.. note::
|
||||
In the CLI: :ref:`sqlite-utils add-foreign-keys <cli_add_foreign_keys>`
|
||||
|
||||
.. _python_api_index_foreign_keys:
|
||||
|
||||
Adding indexes for all foreign keys
|
||||
|
|
@ -1651,6 +1756,9 @@ If you want to ensure that every foreign key column in your database has a corre
|
|||
|
||||
Compound foreign keys get a single composite index across their columns.
|
||||
|
||||
.. note::
|
||||
In the CLI: :ref:`sqlite-utils index-foreign-keys <cli_index_foreign_keys>`
|
||||
|
||||
.. _python_api_drop:
|
||||
|
||||
Dropping a table or view
|
||||
|
|
@ -1672,6 +1780,9 @@ Pass ``ignore=True`` if you want to ignore the error caused by the table or view
|
|||
|
||||
db.table("my_table").drop(ignore=True)
|
||||
|
||||
.. note::
|
||||
In the CLI: :ref:`sqlite-utils drop-table <cli_drop_table>` and :ref:`sqlite-utils drop-view <cli_drop_view>`
|
||||
|
||||
.. _python_api_transform:
|
||||
|
||||
Transforming a table
|
||||
|
|
@ -1700,6 +1811,9 @@ To keep the original table around instead of dropping it, pass the ``keep_table=
|
|||
|
||||
This method raises a ``sqlite_utils.db.TransformError`` exception if the table cannot be transformed, usually because there are existing constraints or indexes that are incompatible with modifications to the columns.
|
||||
|
||||
.. note::
|
||||
In the CLI: :ref:`sqlite-utils transform <cli_transform_table>`
|
||||
|
||||
.. _python_api_transform_alter_column_types:
|
||||
|
||||
Altering column types
|
||||
|
|
@ -1712,8 +1826,35 @@ To alter the type of a column, use the ``types=`` argument:
|
|||
# Convert the 'age' column to an integer, and 'weight' to a 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.
|
||||
|
||||
.. _python_api_transform_strict:
|
||||
|
||||
Changing strict mode
|
||||
--------------------
|
||||
|
||||
The optional ``strict=`` parameter can change whether a table uses `SQLite STRICT mode <https://www.sqlite.org/stricttables.html>`__. Pass ``strict=True`` to convert a regular table to a strict table:
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
table.transform(strict=True)
|
||||
|
||||
Pass ``strict=False`` to convert a strict table back to a regular non-strict table:
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
table.transform(strict=False)
|
||||
|
||||
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.
|
||||
|
||||
Passing ``strict=True`` raises ``sqlite_utils.db.TransformError`` if the available SQLite version does not support strict tables.
|
||||
|
||||
Converting to a strict table validates all existing rows as they are copied into the replacement table. If a value is incompatible with its declared column type, SQLite raises ``sqlite3.IntegrityError`` and the transformation is rolled back, leaving the original table and its data unchanged.
|
||||
|
||||
.. _python_api_transform_rename_columns:
|
||||
|
||||
Renaming columns
|
||||
|
|
@ -1863,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.
|
||||
|
||||
.. _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:
|
||||
|
||||
Custom transformations with .transform_sql()
|
||||
|
|
@ -1874,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.
|
||||
|
||||
.. _python_api_transform_foreign_keys_transactions:
|
||||
|
||||
Foreign keys and transactions
|
||||
-----------------------------
|
||||
|
||||
Because ``.transform()`` drops the old table, running it with ``PRAGMA foreign_keys`` enabled could fire ``ON DELETE`` actions on any tables that reference it - an inbound ``ON DELETE CASCADE`` foreign key would silently delete those referencing rows. To prevent this, ``.transform()`` turns ``PRAGMA foreign_keys`` off for the duration of the operation and restores it afterwards, running ``PRAGMA foreign_key_check`` before committing.
|
||||
|
||||
``PRAGMA foreign_keys`` cannot be changed inside a transaction, so this protection is impossible if you call ``.transform()`` while a transaction is already open - for example inside a ``with db.atomic():`` block or after ``db.begin()``. If ``PRAGMA foreign_keys`` is on and another table references the table being transformed with a destructive ``ON DELETE`` action - ``CASCADE``, ``SET NULL`` or ``SET DEFAULT`` - the method will refuse to run and raise a ``sqlite_utils.db.TransactionError``:
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
from sqlite_utils.db import TransactionError
|
||||
|
||||
try:
|
||||
with db.atomic():
|
||||
db["authors"].transform(types={"id": str})
|
||||
except TransactionError as ex:
|
||||
print("Could not transform in transaction:", ex)
|
||||
|
||||
To transform such a table either call ``.transform()`` outside of the transaction, or execute ``PRAGMA foreign_keys = off`` before opening it:
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
db.execute("PRAGMA foreign_keys = off")
|
||||
with db.atomic():
|
||||
db["authors"].transform(types={"id": str})
|
||||
db.execute("PRAGMA foreign_keys = on")
|
||||
|
||||
Tables referenced by foreign keys without a destructive action (the default ``NO ACTION``, or ``RESTRICT``) can still be transformed inside a transaction - sqlite-utils uses ``PRAGMA defer_foreign_keys`` to postpone the foreign key checks until the transaction commits.
|
||||
|
||||
.. _python_api_extract:
|
||||
|
||||
Extracting columns into a separate table
|
||||
|
|
@ -2032,6 +2225,9 @@ This produces a lookup table like so:
|
|||
|
||||
Rows where every extracted column is ``null`` are not extracted: no record is created for them in the lookup table and their foreign key column is left as ``null``. When extracting multiple columns, rows where at least one of the extracted columns has a value will be extracted as usual.
|
||||
|
||||
.. note::
|
||||
In the CLI: :ref:`sqlite-utils extract <cli_extract>`
|
||||
|
||||
.. _python_api_hash:
|
||||
|
||||
Setting an ID based on the hash of the row contents
|
||||
|
|
@ -2093,6 +2289,9 @@ You can pass ``ignore=True`` to silently ignore an existing view and do nothing,
|
|||
select * from dogs where is_good_dog = 1
|
||||
""", replace=True)
|
||||
|
||||
.. note::
|
||||
In the CLI: :ref:`sqlite-utils create-view <cli_create_view>`
|
||||
|
||||
Storing JSON
|
||||
============
|
||||
|
||||
|
|
@ -2211,6 +2410,9 @@ If you are using ``pysqlite3`` the underlying method may be missing. If you inst
|
|||
|
||||
pip install sqlite-dump
|
||||
|
||||
.. note::
|
||||
In the CLI: :ref:`sqlite-utils dump <cli_dump>`
|
||||
|
||||
.. _python_api_introspection:
|
||||
|
||||
Introspecting tables and views
|
||||
|
|
@ -2274,6 +2476,11 @@ The ``.columns_dict`` property returns a dictionary version of the columns with
|
|||
>>> db.table("PlantType").columns_dict
|
||||
{'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:
|
||||
|
||||
.default_values
|
||||
|
|
@ -2307,6 +2514,43 @@ Almost all SQLite tables have a ``rowid`` column, but a table with no explicitly
|
|||
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:
|
||||
|
||||
.foreign_keys
|
||||
|
|
@ -2410,6 +2654,9 @@ The ``.indexes`` property returns all indexes created for a table, as a list of
|
|||
Index(seq=4, name='"Street_Tree_List_qCaretaker"', unique=0, origin='c', partial=0, columns=['qCaretaker']),
|
||||
Index(seq=5, name='"Street_Tree_List_PlantType"', unique=0, origin='c', partial=0, columns=['PlantType'])]
|
||||
|
||||
.. note::
|
||||
In the CLI: :ref:`sqlite-utils indexes <cli_indexes>`
|
||||
|
||||
.. _python_api_introspection_xindexes:
|
||||
|
||||
.xindexes
|
||||
|
|
@ -2453,6 +2700,9 @@ The ``.triggers`` property lists database triggers. It can be used on both datab
|
|||
>>> db.triggers
|
||||
... similar output to db.table("authors").triggers
|
||||
|
||||
.. note::
|
||||
In the CLI: :ref:`sqlite-utils triggers <cli_triggers>`
|
||||
|
||||
.. _python_api_introspection_triggers_dict:
|
||||
|
||||
.triggers_dict
|
||||
|
|
@ -2601,6 +2851,9 @@ To remove the FTS tables and triggers you created, use the ``disable_fts()`` tab
|
|||
|
||||
db.table("dogs").disable_fts()
|
||||
|
||||
.. note::
|
||||
In the CLI: :ref:`sqlite-utils enable-fts <cli_fts>`
|
||||
|
||||
.. _python_api_quote_fts:
|
||||
|
||||
Quoting characters for use in search
|
||||
|
|
@ -2665,6 +2918,9 @@ To return just the title and published columns for three matches for ``"dog"`` w
|
|||
):
|
||||
print(article)
|
||||
|
||||
.. note::
|
||||
In the CLI: :ref:`sqlite-utils search <cli_search>`
|
||||
|
||||
.. _python_api_fts_search_sql:
|
||||
|
||||
Building SQL queries with table.search_sql()
|
||||
|
|
@ -2749,6 +3005,9 @@ This runs the following SQL::
|
|||
|
||||
INSERT INTO dogs_fts (dogs_fts) VALUES ("rebuild");
|
||||
|
||||
.. note::
|
||||
In the CLI: :ref:`sqlite-utils rebuild-fts <cli_fts>`
|
||||
|
||||
.. _python_api_fts_optimize:
|
||||
|
||||
Optimizing a full-text search table
|
||||
|
|
@ -2764,6 +3023,9 @@ This runs the following SQL::
|
|||
|
||||
INSERT INTO dogs_fts (dogs_fts) VALUES ("optimize");
|
||||
|
||||
.. note::
|
||||
In the CLI: :ref:`sqlite-utils optimize <cli_optimize>`
|
||||
|
||||
.. _python_api_cached_table_counts:
|
||||
|
||||
Cached table counts using triggers
|
||||
|
|
@ -2826,6 +3088,9 @@ If the ``_counts`` table ever becomes out-of-sync with the actual table counts y
|
|||
|
||||
db.reset_counts()
|
||||
|
||||
.. note::
|
||||
In the CLI: :ref:`sqlite-utils enable-counts <cli_enable_counts>`
|
||||
|
||||
.. _python_api_create_index:
|
||||
|
||||
Creating indexes
|
||||
|
|
@ -2869,6 +3134,17 @@ Use ``if_not_exists=True`` to do nothing if an index with that name already exis
|
|||
|
||||
Pass ``analyze=True`` to run ``ANALYZE`` against the new index after creating it.
|
||||
|
||||
You can drop an index from a table using ``.drop_index(index_name)``:
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
db.table("dogs").drop_index("idx_dogs_name")
|
||||
|
||||
Use ``ignore=True`` to ignore the error if the index does not exist.
|
||||
|
||||
.. note::
|
||||
In the CLI: :ref:`sqlite-utils create-index <cli_create_index>` and :ref:`sqlite-utils drop-index <cli_drop_index>`
|
||||
|
||||
.. _python_api_analyze:
|
||||
|
||||
Optimizing index usage with ANALYZE
|
||||
|
|
@ -2896,6 +3172,9 @@ To run against all indexes attached to a specific table, you can either pass the
|
|||
|
||||
db.table("dogs").analyze()
|
||||
|
||||
.. note::
|
||||
In the CLI: :ref:`sqlite-utils analyze <cli_analyze>`
|
||||
|
||||
.. _python_api_vacuum:
|
||||
|
||||
Vacuum
|
||||
|
|
@ -2907,6 +3186,9 @@ You can optimize your database by running VACUUM against it like so:
|
|||
|
||||
Database("my_database.db").vacuum()
|
||||
|
||||
.. note::
|
||||
In the CLI: :ref:`sqlite-utils vacuum <cli_vacuum>`
|
||||
|
||||
.. _python_api_wal:
|
||||
|
||||
WAL mode
|
||||
|
|
@ -2934,6 +3216,9 @@ You can check the current journal mode for a database using the ``journal_mode``
|
|||
|
||||
This will usually be ``wal`` or ``delete`` (meaning WAL is disabled), but can have other values - see the `PRAGMA journal_mode <https://www.sqlite.org/pragma.html#pragma_journal_mode>`__ documentation.
|
||||
|
||||
.. note::
|
||||
In the CLI: :ref:`sqlite-utils enable-wal and disable-wal <cli_wal>`
|
||||
|
||||
.. _python_api_suggest_column_types:
|
||||
|
||||
Suggesting column types
|
||||
|
|
@ -3074,6 +3359,9 @@ You can cause ``sqlite3`` to return more useful errors, including the traceback
|
|||
|
||||
sqlite3.enable_callback_tracebacks(True)
|
||||
|
||||
.. note::
|
||||
In the CLI: :ref:`sqlite-utils query --functions <cli_query_functions>`
|
||||
|
||||
.. _python_api_quote:
|
||||
|
||||
Quoting strings for use in SQL
|
||||
|
|
@ -3206,6 +3494,9 @@ Initialize SpatiaLite
|
|||
.. automethod:: sqlite_utils.db.Database.init_spatialite
|
||||
:noindex:
|
||||
|
||||
.. note::
|
||||
In the CLI: :ref:`sqlite-utils create-database --init-spatialite <cli_create_database>`
|
||||
|
||||
.. _python_api_gis_find_spatialite:
|
||||
|
||||
Finding SpatiaLite
|
||||
|
|
@ -3221,6 +3512,9 @@ Adding geometry columns
|
|||
.. automethod:: sqlite_utils.db.Table.add_geometry_column
|
||||
:noindex:
|
||||
|
||||
.. note::
|
||||
In the CLI: :ref:`sqlite-utils add-geometry-column <cli_spatialite>`
|
||||
|
||||
.. _python_api_gis_create_spatial_index:
|
||||
|
||||
Creating a spatial index
|
||||
|
|
@ -3228,3 +3522,6 @@ Creating a spatial index
|
|||
|
||||
.. automethod:: sqlite_utils.db.Table.create_spatial_index
|
||||
:noindex:
|
||||
|
||||
.. note::
|
||||
In the CLI: :ref:`sqlite-utils create-spatial-index <cli_spatialite_indexes>`
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
[project]
|
||||
name = "sqlite-utils"
|
||||
version = "4.0rc4"
|
||||
version = "4.2.1"
|
||||
description = "CLI tool and Python library for manipulating SQLite databases"
|
||||
readme = { file = "README.md", content-type = "text/markdown" }
|
||||
authors = [
|
||||
|
|
@ -48,6 +48,7 @@ dev = [
|
|||
# flake8
|
||||
"flake8",
|
||||
"flake8-pyproject",
|
||||
"pyright>=1.1.411",
|
||||
"ty>=0.0.37",
|
||||
# For stable cog:
|
||||
"tabulate>=0.10.0",
|
||||
|
|
@ -79,7 +80,14 @@ build-backend = "setuptools.build_meta"
|
|||
max-line-length = 160
|
||||
# Black compatibility, E203 whitespace before ':':
|
||||
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]
|
||||
sqlite_utils = ["py.typed"]
|
||||
|
|
|
|||
|
|
@ -1,7 +1,13 @@
|
|||
from .utils import suggest_column_types
|
||||
from .hookspecs import hookimpl
|
||||
from .hookspecs import hookspec
|
||||
from .db import Database
|
||||
from .hookspecs import hookimpl, hookspec
|
||||
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",
|
||||
]
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
897
sqlite_utils/create_table_parser.py
Normal file
897
sqlite_utils/create_table_parser.py
Normal 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
|
||||
1663
sqlite_utils/db.py
1663
sqlite_utils/db.py
File diff suppressed because it is too large
Load diff
|
|
@ -1,8 +1,7 @@
|
|||
import sqlite3
|
||||
|
||||
import click
|
||||
from pluggy import HookimplMarker
|
||||
from pluggy import HookspecMarker
|
||||
from pluggy import HookimplMarker, HookspecMarker
|
||||
|
||||
hookspec = HookspecMarker("sqlite_utils")
|
||||
hookimpl = HookimplMarker("sqlite_utils")
|
||||
|
|
|
|||
|
|
@ -1,19 +1,28 @@
|
|||
from collections.abc import Iterable
|
||||
from dataclasses import dataclass
|
||||
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:
|
||||
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:
|
||||
migrations_table = "_sqlite_migrations"
|
||||
|
||||
@dataclass
|
||||
class _Migration:
|
||||
name: str
|
||||
fn: Callable
|
||||
fn: _MigrationFunction
|
||||
transactional: bool = True
|
||||
|
||||
@dataclass
|
||||
|
|
@ -32,7 +41,7 @@ class Migrations:
|
|||
|
||||
def __call__(
|
||||
self, *, name: str | None = None, transactional: bool = True
|
||||
) -> Callable:
|
||||
) -> Callable[[_MigrationFunctionT], _MigrationFunctionT]:
|
||||
"""
|
||||
:param name: The name to use for this migration - if not provided,
|
||||
the name of the function will be used.
|
||||
|
|
@ -43,13 +52,11 @@ class Migrations:
|
|||
example those that execute ``VACUUM``.
|
||||
"""
|
||||
|
||||
def inner(func: Callable) -> Callable:
|
||||
migration_name = name or getattr(func, "__name__")
|
||||
def inner(func: _MigrationFunctionT) -> _MigrationFunctionT:
|
||||
migration_name = name or func.__name__
|
||||
if any(m.name == migration_name for m in self._migrations):
|
||||
raise ValueError(
|
||||
"Migration '{}' is already registered in set '{}'".format(
|
||||
migration_name, self.name
|
||||
)
|
||||
f"Migration '{migration_name}' is already registered in set '{self.name}'"
|
||||
)
|
||||
self._migrations.append(
|
||||
self._Migration(migration_name, func, transactional)
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
from typing import Dict, List, Union
|
||||
import sys
|
||||
|
||||
import pluggy
|
||||
import sys
|
||||
|
||||
from . import hookspecs
|
||||
|
||||
pm: pluggy.PluginManager = pluggy.PluginManager("sqlite_utils")
|
||||
|
|
@ -17,13 +17,13 @@ def ensure_plugins_loaded() -> None:
|
|||
_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()
|
||||
plugins: List[Dict[str, Union[str, List[str]]]] = []
|
||||
plugins: list[dict[str, str | list[str]]] = []
|
||||
plugin_to_distinfo = dict(pm.list_plugin_distinfo())
|
||||
for plugin in pm.get_plugins():
|
||||
hookcallers = pm.get_hookcallers(plugin) or []
|
||||
plugin_info: Dict[str, Union[str, List[str]]] = {
|
||||
plugin_info: dict[str, str | list[str]] = {
|
||||
"name": plugin.__name__,
|
||||
"hooks": [h.name for h in hookcallers],
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,9 +1,9 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from typing import Callable, Optional
|
||||
import json
|
||||
from collections.abc import Callable
|
||||
|
||||
from dateutil import parser
|
||||
import json
|
||||
|
||||
IGNORE: object = object()
|
||||
SET_NULL: object = object()
|
||||
|
|
@ -13,8 +13,8 @@ def 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
|
||||
\b
|
||||
|
|
@ -44,8 +44,8 @@ def parsedatetime(
|
|||
value: str,
|
||||
dayfirst: bool = False,
|
||||
yearfirst: bool = False,
|
||||
errors: Optional[object] = None,
|
||||
) -> Optional[str]:
|
||||
errors: object | None = None,
|
||||
) -> str | None:
|
||||
"""
|
||||
Parse a datetime and convert it to ISO datetime format: yyyy-mm-ddTHH:MM:SS
|
||||
\b
|
||||
|
|
|
|||
|
|
@ -9,20 +9,12 @@ import itertools
|
|||
import json
|
||||
import os
|
||||
import sys
|
||||
from collections.abc import Callable, Generator, Iterable, Iterator
|
||||
from typing import (
|
||||
TYPE_CHECKING,
|
||||
Any,
|
||||
BinaryIO,
|
||||
Callable,
|
||||
Dict,
|
||||
Generator,
|
||||
Iterable,
|
||||
Iterator,
|
||||
List,
|
||||
Optional,
|
||||
Set,
|
||||
Tuple,
|
||||
Type,
|
||||
TYPE_CHECKING,
|
||||
Generic,
|
||||
TypeVar,
|
||||
Union,
|
||||
cast,
|
||||
|
|
@ -33,8 +25,8 @@ import click
|
|||
from . import recipes
|
||||
|
||||
if TYPE_CHECKING:
|
||||
import sqlite3 # noqa: F401
|
||||
from sqlite3 import dbapi2 # noqa: F401
|
||||
import sqlite3
|
||||
from sqlite3 import dbapi2
|
||||
|
||||
OperationalError = dbapi2.OperationalError
|
||||
else:
|
||||
|
|
@ -44,7 +36,7 @@ else:
|
|||
OperationalError = dbapi2.OperationalError
|
||||
except ImportError:
|
||||
import sqlite3 # noqa: F401
|
||||
from sqlite3 import dbapi2 # noqa: F401
|
||||
from sqlite3 import dbapi2
|
||||
|
||||
OperationalError = dbapi2.OperationalError
|
||||
|
||||
|
|
@ -61,12 +53,16 @@ SPATIALITE_PATHS = (
|
|||
ORIGINAL_CSV_FIELD_SIZE_LIMIT = csv.field_size_limit()
|
||||
|
||||
# Type alias for row dictionaries - values can be various SQLite-compatible types
|
||||
RowValue = Union[None, int, float, str, bytes, bool, List[str]]
|
||||
Row = Dict[str, RowValue]
|
||||
RowValue = None | int | float | str | bytes | bool | list[str]
|
||||
Row = dict[str, RowValue]
|
||||
|
||||
T = TypeVar("T")
|
||||
|
||||
|
||||
class ANY:
|
||||
"""Marker type for an SQLite ``ANY`` column."""
|
||||
|
||||
|
||||
class _CloseableIterator(Iterator[Row]):
|
||||
"""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)
|
||||
|
||||
|
||||
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>`__
|
||||
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(
|
||||
records: Iterable[Dict[str, Any]],
|
||||
) -> Dict[str, type]:
|
||||
all_column_types: Dict[str, Set[type]] = {}
|
||||
records: Iterable[dict[str, Any]],
|
||||
) -> dict[str, type]:
|
||||
all_column_types: dict[str, set[type]] = {}
|
||||
for record in records:
|
||||
for key, value in record.items():
|
||||
all_column_types.setdefault(key, set()).add(type(value))
|
||||
|
|
@ -142,9 +138,9 @@ def suggest_column_types(
|
|||
|
||||
|
||||
def types_for_column_types(
|
||||
all_column_types: Dict[str, Set[type]],
|
||||
) -> Dict[str, type]:
|
||||
column_types: Dict[str, type] = {}
|
||||
all_column_types: dict[str, set[type]],
|
||||
) -> dict[str, type]:
|
||||
column_types: dict[str, type] = {}
|
||||
for key, types in all_column_types.items():
|
||||
# Ignore null values if at least one other type present:
|
||||
if len(types) > 1:
|
||||
|
|
@ -153,7 +149,7 @@ def types_for_column_types(
|
|||
if {None.__class__} == types:
|
||||
t = str
|
||||
elif len(types) == 1:
|
||||
t = list(types)[0]
|
||||
t = next(iter(types))
|
||||
# But if it's a subclass of list / tuple / dict, use str
|
||||
# instead as we will be storing it as JSON in the table
|
||||
for superclass in (list, tuple, dict):
|
||||
|
|
@ -186,11 +182,13 @@ def column_affinity(column_type: str) -> type:
|
|||
return bytes
|
||||
if "REAL" in column_type or "FLOA" in column_type or "DOUB" in column_type:
|
||||
return float
|
||||
if column_type == "ANY":
|
||||
return ANY
|
||||
# Default is 'NUMERIC', which we currently also treat as 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
|
||||
to_fix = [
|
||||
k
|
||||
|
|
@ -263,9 +261,9 @@ class RowError(Exception):
|
|||
|
||||
|
||||
def _extra_key_strategy(
|
||||
reader: Iterable[Dict[Optional[str], object]],
|
||||
ignore_extras: Optional[bool] = False,
|
||||
extras_key: Optional[str] = None,
|
||||
reader: Iterable[dict[str | None, object]],
|
||||
ignore_extras: bool | None = False,
|
||||
extras_key: str | None = None,
|
||||
) -> Iterable[Row]:
|
||||
# Logic for handling CSV rows with more values than there are headings
|
||||
for row in reader:
|
||||
|
|
@ -279,9 +277,7 @@ def _extra_key_strategy(
|
|||
yield cast(Row, row)
|
||||
elif not extras_key:
|
||||
extras = row.pop(None)
|
||||
raise RowError(
|
||||
"Row {} contained these extra values: {}".format(row, extras)
|
||||
)
|
||||
raise RowError(f"Row {row} contained these extra values: {extras}")
|
||||
else:
|
||||
extras_value = row.pop(None)
|
||||
row_out = cast(Row, row)
|
||||
|
|
@ -291,12 +287,12 @@ def _extra_key_strategy(
|
|||
|
||||
def rows_from_file(
|
||||
fp: BinaryIO,
|
||||
format: Optional[Format] = None,
|
||||
dialect: Optional[Type[csv.Dialect]] = None,
|
||||
encoding: Optional[str] = None,
|
||||
ignore_extras: Optional[bool] = False,
|
||||
extras_key: Optional[str] = None,
|
||||
) -> Tuple[Iterable[Row], Format]:
|
||||
format: Format | None = None,
|
||||
dialect: type[csv.Dialect] | None = None,
|
||||
encoding: str | None = None,
|
||||
ignore_extras: bool | None = False,
|
||||
extras_key: str | None = None,
|
||||
) -> tuple[Iterable[Row], Format]:
|
||||
"""
|
||||
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)
|
||||
else:
|
||||
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
|
||||
elif format == Format.TSV:
|
||||
rows, _ = rows_from_file(
|
||||
|
|
@ -363,7 +363,7 @@ def rows_from_file(
|
|||
)
|
||||
return (
|
||||
_extra_key_strategy(
|
||||
cast(Iterable[Dict[Optional[str], object]], rows),
|
||||
cast(Iterable[dict[str | None, object]], rows),
|
||||
ignore_extras,
|
||||
extras_key,
|
||||
),
|
||||
|
|
@ -379,7 +379,9 @@ def rows_from_file(
|
|||
raise TypeError(
|
||||
"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
|
||||
return rows_from_file(buffered, format=Format.JSON)
|
||||
else:
|
||||
|
|
@ -393,7 +395,7 @@ def rows_from_file(
|
|||
detected_format = Format.TSV if dialect.delimiter == "\t" else Format.CSV
|
||||
return (
|
||||
_extra_key_strategy(
|
||||
cast(Iterable[Dict[Optional[str], object]], rows),
|
||||
cast(Iterable[dict[str | None, object]], rows),
|
||||
ignore_extras,
|
||||
extras_key,
|
||||
),
|
||||
|
|
@ -425,9 +427,9 @@ class TypeTracker:
|
|||
"""
|
||||
|
||||
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
|
||||
as part of the iteration.
|
||||
|
|
@ -441,7 +443,7 @@ class TypeTracker:
|
|||
yield row
|
||||
|
||||
@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
|
||||
to the ``db[table_name].transform(types=tracker.types)`` method.
|
||||
|
|
@ -450,17 +452,15 @@ class TypeTracker:
|
|||
|
||||
|
||||
class ValueTracker:
|
||||
couldbe: Dict[str, Callable[[object], bool]]
|
||||
couldbe: dict[str, Callable[[object], bool]]
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.couldbe = {key: getattr(self, "test_" + key) for key in self.get_tests()}
|
||||
|
||||
@classmethod
|
||||
def get_tests(cls) -> List[str]:
|
||||
def get_tests(cls) -> list[str]:
|
||||
return [
|
||||
key.split("test_")[-1]
|
||||
for key in cls.__dict__.keys()
|
||||
if key.startswith("test_")
|
||||
key.split("test_")[-1] for key in cls.__dict__ if key.startswith("test_")
|
||||
]
|
||||
|
||||
def test_integer(self, value: object) -> bool:
|
||||
|
|
@ -492,7 +492,7 @@ class ValueTracker:
|
|||
def evaluate(self, value: object) -> None:
|
||||
if not value or not self.couldbe:
|
||||
return
|
||||
not_these: List[str] = []
|
||||
not_these: list[str] = []
|
||||
for name, test in self.couldbe.items():
|
||||
if not test(value):
|
||||
not_these.append(name)
|
||||
|
|
@ -500,12 +500,12 @@ class ValueTracker:
|
|||
del self.couldbe[key]
|
||||
|
||||
|
||||
class NullProgressBar:
|
||||
class NullProgressBar(Generic[T]):
|
||||
def __init__(self, *args: Iterable[T]) -> None:
|
||||
self.args = args
|
||||
|
||||
def __iter__(self) -> Iterator[T]:
|
||||
yield from self.args[0] # type: ignore
|
||||
yield from self.args[0]
|
||||
|
||||
def update(self, value: int) -> None:
|
||||
pass
|
||||
|
|
@ -524,14 +524,14 @@ def progressbar(*args: Iterable[T], **kwargs: Any) -> Generator[Any, None, None]
|
|||
def _compile_code(
|
||||
code: str, imports: Iterable[str], variable: str = "value"
|
||||
) -> 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
|
||||
for import_ in imports:
|
||||
globals_dict[import_.split(".")[0]] = __import__(import_)
|
||||
|
||||
# If user defined a convert() function, return that
|
||||
try:
|
||||
exec(code, globals_dict)
|
||||
exec(code, globals_dict) # noqa: S102
|
||||
return cast(Callable[..., object], globals_dict["convert"])
|
||||
except (AttributeError, SyntaxError, NameError, KeyError, TypeError):
|
||||
pass
|
||||
|
|
@ -542,20 +542,20 @@ def _compile_code(
|
|||
fn = eval(code, globals_dict)
|
||||
if callable(fn):
|
||||
return cast(Callable[..., object], fn)
|
||||
except Exception:
|
||||
except Exception: # noqa: BLE001, S110
|
||||
pass
|
||||
|
||||
# Try compiling their code as a function instead
|
||||
body_variants = [code]
|
||||
# If single line and no 'return', try adding the 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
|
||||
for variant in body_variants:
|
||||
new_code = ["def fn({}):".format(variable)]
|
||||
new_code = [f"def fn({variable}):"]
|
||||
for line in variant.split("\n"):
|
||||
new_code.append(" {}".format(line))
|
||||
new_code.append(f" {line}")
|
||||
try:
|
||||
code_o = compile("\n".join(new_code), "<string>", "exec")
|
||||
break
|
||||
|
|
@ -566,7 +566,7 @@ def _compile_code(
|
|||
if code_o is None:
|
||||
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"])
|
||||
|
||||
|
||||
|
|
@ -582,7 +582,7 @@ def chunks(sequence: Iterable[T], size: int) -> Iterable[Iterable[T]]:
|
|||
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
|
||||
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 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:
|
||||
to_hash = {key: record[key] for key in keys}
|
||||
return hashlib.sha1(
|
||||
|
|
@ -613,7 +613,7 @@ def hash_record(record: Dict[str, Any], keys: Optional[Iterable[str]] = None) ->
|
|||
).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,
|
||||
by appending ``_2``, ``_3``... to later occurrences - skipping any
|
||||
|
|
@ -636,7 +636,7 @@ def dedupe_keys(keys: Iterable[str]) -> List[str]:
|
|||
new_key = key
|
||||
suffix = 2
|
||||
while new_key in seen or new_key in taken:
|
||||
new_key = "{}_{}".format(key, suffix)
|
||||
new_key = f"{key}_{suffix}"
|
||||
suffix += 1
|
||||
key = new_key
|
||||
seen.add(key)
|
||||
|
|
@ -644,7 +644,7 @@ def dedupe_keys(keys: Iterable[str]) -> List[str]:
|
|||
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():
|
||||
if isinstance(value, dict):
|
||||
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
|
||||
|
||||
|
||||
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}``
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import pytest
|
||||
|
||||
from sqlite_utils import Database
|
||||
from sqlite_utils.utils import sqlite3
|
||||
import pytest
|
||||
|
||||
CREATE_TABLES = """
|
||||
create table Gosh (c1 text, c2 text, c3 text);
|
||||
|
|
@ -55,7 +56,7 @@ def close_all_databases():
|
|||
for db in databases:
|
||||
try:
|
||||
db.close()
|
||||
except Exception:
|
||||
except sqlite3.Error:
|
||||
pass
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -3,11 +3,13 @@ import pytest
|
|||
|
||||
@pytest.fixture
|
||||
def db(fresh_db):
|
||||
fresh_db["one_index"].insert({"id": 1, "name": "Cleo"}, pk="id")
|
||||
fresh_db["one_index"].create_index(["name"])
|
||||
fresh_db["two_indexes"].insert({"id": 1, "name": "Cleo", "species": "dog"}, pk="id")
|
||||
fresh_db["two_indexes"].create_index(["name"])
|
||||
fresh_db["two_indexes"].create_index(["species"])
|
||||
fresh_db.table("one_index").insert({"id": 1, "name": "Cleo"}, pk="id")
|
||||
fresh_db.table("one_index").create_index(["name"])
|
||||
fresh_db.table("two_indexes").insert(
|
||||
{"id": 1, "name": "Cleo", "species": "dog"}, pk="id"
|
||||
)
|
||||
fresh_db.table("two_indexes").create_index(["name"])
|
||||
fresh_db.table("two_indexes").create_index(["species"])
|
||||
return fresh_db
|
||||
|
||||
|
||||
|
|
@ -17,7 +19,7 @@ def test_analyze_whole_database(db):
|
|||
assert set(db.table_names()).issuperset(
|
||||
{"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_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":
|
||||
db.analyze("one_index")
|
||||
elif method == "table_method":
|
||||
db["one_index"].analyze()
|
||||
db.table("one_index").analyze()
|
||||
|
||||
assert set(db.table_names()).issuperset(
|
||||
{"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"}
|
||||
]
|
||||
|
||||
|
|
@ -46,6 +48,6 @@ def test_analyze_index_by_name(db):
|
|||
assert set(db.table_names()).issuperset(
|
||||
{"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"},
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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 pytest
|
||||
from click.testing import CliRunner
|
||||
|
||||
from sqlite_utils import cli
|
||||
from sqlite_utils.db import ColumnDetails, Database
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def db_to_analyze(fresh_db):
|
||||
stuff = fresh_db["stuff"]
|
||||
stuff = fresh_db.table("stuff")
|
||||
stuff.insert_all(
|
||||
[
|
||||
{"id": 1, "owner": "Terryterryterry", "size": 5},
|
||||
|
|
@ -43,7 +45,7 @@ def big_db_to_analyze_path(tmpdir):
|
|||
"all_null": None,
|
||||
}
|
||||
)
|
||||
db["stuff"].insert_all(to_insert)
|
||||
db.table("stuff").insert_all(to_insert)
|
||||
return path
|
||||
|
||||
|
||||
|
|
@ -124,7 +126,7 @@ def big_db_to_analyze_path(tmpdir):
|
|||
)
|
||||
def test_analyze_column(db_to_analyze, column, extra_kwargs, expected):
|
||||
assert (
|
||||
db_to_analyze["stuff"].analyze_column(
|
||||
db_to_analyze.table("stuff").analyze_column(
|
||||
column, common_limit=2, value_truncate=5, **extra_kwargs
|
||||
)
|
||||
== expected
|
||||
|
|
@ -184,7 +186,7 @@ def test_analyze_table_save(db_to_analyze_path):
|
|||
cli.cli, ["analyze-tables", db_to_analyze_path, "--save"]
|
||||
)
|
||||
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 == [
|
||||
{
|
||||
"table": "stuff",
|
||||
|
|
@ -246,7 +248,7 @@ def test_analyze_table_save_no_most_no_least_options(
|
|||
args.append("--no-least")
|
||||
result = CliRunner().invoke(cli.cli, args)
|
||||
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 = {
|
||||
"table": "stuff",
|
||||
"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):
|
||||
path = str(tmpdir / "test_validate_columns.db")
|
||||
db = Database(path)
|
||||
db["one"].insert(
|
||||
db.table("one").insert(
|
||||
{
|
||||
"id": 1,
|
||||
"name": "one",
|
||||
}
|
||||
)
|
||||
db["two"].insert(
|
||||
db.table("two").insert(
|
||||
{
|
||||
"id": 1,
|
||||
"age": 5,
|
||||
|
|
|
|||
|
|
@ -28,11 +28,13 @@ from sqlite_utils.utils import sqlite3
|
|||
END;
|
||||
""",
|
||||
[
|
||||
(
|
||||
"CREATE TRIGGER t_ai AFTER INSERT ON t\n"
|
||||
" BEGIN\n"
|
||||
" UPDATE t SET value = 'a;b' WHERE id = new.id;\n"
|
||||
" INSERT INTO log VALUES ('x;y');\n"
|
||||
" END;"
|
||||
)
|
||||
],
|
||||
),
|
||||
),
|
||||
|
|
@ -43,51 +45,47 @@ def test_iter_complete_sql_statements(sql, expected):
|
|||
|
||||
def test_atomic_commits(fresh_db):
|
||||
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):
|
||||
with pytest.raises(RuntimeError):
|
||||
with fresh_db.atomic():
|
||||
fresh_db["dogs"].insert({"id": 1, "name": "Cleo"}, pk="id")
|
||||
with pytest.raises(RuntimeError), fresh_db.atomic():
|
||||
fresh_db.table("dogs").insert({"id": 1, "name": "Cleo"}, pk="id")
|
||||
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):
|
||||
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():
|
||||
fresh_db["dogs"].insert({"id": 1, "name": "Cleo"})
|
||||
with pytest.raises(RuntimeError):
|
||||
with fresh_db.atomic():
|
||||
fresh_db["dogs"].insert({"id": 2, "name": "Pancakes"})
|
||||
fresh_db.table("dogs").insert({"id": 1, "name": "Cleo"})
|
||||
with pytest.raises(RuntimeError), fresh_db.atomic():
|
||||
fresh_db.table("dogs").insert({"id": 2, "name": "Pancakes"})
|
||||
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": 3, "name": "Marnie"},
|
||||
]
|
||||
|
||||
|
||||
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():
|
||||
fresh_db["dogs"].insert({"id": 1, "name": "Cleo"}, pk="id")
|
||||
with fresh_db.atomic():
|
||||
fresh_db["dogs"].insert({"id": 2, "name": "Pancakes"})
|
||||
fresh_db.table("dogs").insert({"id": 2, "name": "Pancakes"})
|
||||
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):
|
||||
with pytest.raises(RuntimeError):
|
||||
with fresh_db.atomic():
|
||||
with pytest.raises(RuntimeError), fresh_db.atomic():
|
||||
fresh_db.executescript("""
|
||||
CREATE TABLE dogs(id INTEGER PRIMARY KEY, name TEXT);
|
||||
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")
|
||||
|
||||
assert not fresh_db["dogs"].exists()
|
||||
assert not fresh_db.table("dogs").exists()
|
||||
|
||||
|
||||
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 fresh_db.atomic():
|
||||
fresh_db["dogs"].insert({"id": 2, "name": "Pancakes", "age": "6"})
|
||||
fresh_db["dogs"].transform(rename={"age": "dog_age"})
|
||||
with pytest.raises(RuntimeError), fresh_db.atomic():
|
||||
fresh_db.table("dogs").insert({"id": 2, "name": "Pancakes", "age": "6"})
|
||||
fresh_db.table("dogs").transform(rename={"age": "dog_age"})
|
||||
raise RuntimeError("boom")
|
||||
|
||||
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)'
|
||||
)
|
||||
assert list(fresh_db["dogs"].rows) == [
|
||||
assert list(fresh_db.table("dogs").rows) == [
|
||||
{"id": 1, "name": "Cleo", "age": "5"},
|
||||
]
|
||||
|
||||
|
||||
def test_transform_parent_table_with_foreign_keys_in_atomic(fresh_db):
|
||||
fresh_db.conn.execute("PRAGMA foreign_keys=ON")
|
||||
fresh_db["authors"].insert({"id": 1, "name": "Tina"}, pk="id")
|
||||
fresh_db["books"].insert(
|
||||
fresh_db.table("authors").insert({"id": 1, "name": "Tina"}, pk="id")
|
||||
fresh_db.table("books").insert(
|
||||
{"id": 1, "title": "Book", "author_id": 1},
|
||||
pk="id",
|
||||
foreign_keys={"author_id"},
|
||||
)
|
||||
|
||||
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["authors"].schema
|
||||
fresh_db.table("authors").schema
|
||||
== 'CREATE TABLE "authors" (\n "id" INTEGER PRIMARY KEY,\n "full_name" TEXT\n)'
|
||||
)
|
||||
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):
|
||||
fresh_db.conn.execute("PRAGMA foreign_keys=ON")
|
||||
fresh_db["authors"].insert({"id": 1, "name": "Tina"}, pk="id")
|
||||
fresh_db["books"].insert(
|
||||
fresh_db.table("authors").insert({"id": 1, "name": "Tina"}, pk="id")
|
||||
fresh_db.table("books").insert(
|
||||
{"id": 1, "title": "Book", "author_id": 1},
|
||||
pk="id",
|
||||
foreign_keys={"author_id"},
|
||||
)
|
||||
|
||||
with pytest.raises(RuntimeError):
|
||||
with fresh_db.atomic():
|
||||
fresh_db["authors"].transform(rename={"name": "full_name"})
|
||||
with pytest.raises(RuntimeError), fresh_db.atomic():
|
||||
fresh_db.table("authors").transform(rename={"name": "full_name"})
|
||||
raise RuntimeError("boom")
|
||||
|
||||
assert (
|
||||
fresh_db["authors"].schema
|
||||
fresh_db.table("authors").schema
|
||||
== 'CREATE TABLE "authors" (\n "id" INTEGER PRIMARY KEY,\n "name" TEXT\n)'
|
||||
)
|
||||
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):
|
||||
fresh_db.conn.execute("PRAGMA foreign_keys=ON")
|
||||
fresh_db["authors"].insert({"id": 1, "name": "Tina"}, pk="id")
|
||||
fresh_db["books"].insert({"id": 1, "author_id": 2}, pk="id")
|
||||
fresh_db.table("authors").insert({"id": 1, "name": "Tina"}, pk="id")
|
||||
fresh_db.table("books").insert({"id": 1, "author_id": 2}, pk="id")
|
||||
|
||||
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]
|
||||
|
||||
|
||||
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")
|
||||
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
|
||||
assert fresh_db.conn.in_transaction
|
||||
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
|
||||
fresh_db.execute("begin")
|
||||
with fresh_db.atomic():
|
||||
fresh_db["t"].insert({"id": 3}, pk="id")
|
||||
fresh_db.table("t").insert({"id": 3}, pk="id")
|
||||
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):
|
||||
path = str(tmpdir / "test.db")
|
||||
db = Database(path)
|
||||
db["t"].insert({"id": 1}, pk="id")
|
||||
db.table("t").insert({"id": 1}, pk="id")
|
||||
db.begin()
|
||||
db["t"].insert({"id": 2}, pk="id")
|
||||
db.table("t").insert({"id": 2}, pk="id")
|
||||
assert db.conn.in_transaction
|
||||
db.rollback()
|
||||
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["t"].insert({"id": 3}, pk="id")
|
||||
db.table("t").insert({"id": 3}, pk="id")
|
||||
db.commit()
|
||||
db.close()
|
||||
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()
|
||||
|
||||
|
||||
|
|
@ -226,7 +224,7 @@ def test_commit_and_rollback_without_transaction_are_noops(fresh_db):
|
|||
def test_execute_write_commits_immediately(tmpdir):
|
||||
path = str(tmpdir / "test.db")
|
||||
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)")
|
||||
# No implicit transaction is left open
|
||||
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):
|
||||
fresh_db["t"].insert({"id": 1}, pk="id")
|
||||
fresh_db.table("t").insert({"id": 1}, pk="id")
|
||||
fresh_db.begin()
|
||||
fresh_db.execute("insert into t (id) values (2)")
|
||||
# Still inside the explicit transaction - not committed
|
||||
assert fresh_db.conn.in_transaction
|
||||
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):
|
||||
# A BEGIN hidden behind a leading comment must not be auto-committed
|
||||
# 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")
|
||||
assert fresh_db.conn.in_transaction
|
||||
fresh_db.execute("insert into t (id) values (2)")
|
||||
fresh_db.rollback()
|
||||
assert [r["id"] for r in fresh_db["t"].rows] == [1]
|
||||
assert [r["id"] for r in fresh_db.table("t").rows] == [1]
|
||||
|
||||
|
||||
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
|
||||
if begin_sql.startswith("\ufeff") and not _sqlite_accepts_bom():
|
||||
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)
|
||||
assert fresh_db.conn.in_transaction
|
||||
fresh_db.execute("insert into t (id) values (2)")
|
||||
fresh_db.rollback()
|
||||
assert [r["id"] for r in fresh_db["t"].rows] == [1]
|
||||
assert [r["id"] for r in fresh_db.table("t").rows] == [1]
|
||||
|
||||
|
||||
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
|
||||
path = str(tmpdir / "test.db")
|
||||
db = Database(path)
|
||||
db["t"].insert({"id": 1}, pk="id")
|
||||
db.table("t").insert({"id": 1}, pk="id")
|
||||
with pytest.raises(sqlite3.IntegrityError):
|
||||
db.execute("insert into t (id) values (1)")
|
||||
assert not db.conn.in_transaction
|
||||
# Subsequent writes commit as normal and survive closing the connection
|
||||
db["other"].insert({"id": 2})
|
||||
db.table("other").insert({"id": 2})
|
||||
db.close()
|
||||
db2 = Database(path)
|
||||
assert db2["other"].exists()
|
||||
assert db2.table("other").exists()
|
||||
db2.close()
|
||||
|
||||
|
||||
def test_execute_failed_write_preserves_explicit_transaction(fresh_db):
|
||||
# A failed write inside an explicit transaction must not roll back
|
||||
# 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.execute("insert into t (id) values (2)")
|
||||
with pytest.raises(sqlite3.IntegrityError):
|
||||
fresh_db.execute("insert into t (id) values (1)")
|
||||
assert fresh_db.conn.in_transaction
|
||||
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):
|
||||
# A caught failure inside an atomic() block must leave the block's
|
||||
# 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():
|
||||
fresh_db.execute("insert into t (id) values (2)")
|
||||
with pytest.raises(sqlite3.IntegrityError):
|
||||
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):
|
||||
|
|
@ -329,7 +327,7 @@ def test_query_returning_commits_after_iteration(tmpdir):
|
|||
_pytest.skip("RETURNING requires SQLite 3.35.0 or higher")
|
||||
path = str(tmpdir / "test.db")
|
||||
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"))
|
||||
assert rows == [{"id": 2}]
|
||||
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"
|
||||
fresh_db.execute("create table t (id integer primary key, v text)")
|
||||
fresh_db.execute(TRIGGER_SQL)
|
||||
with pytest.raises(sqlite3.IntegrityError, match="trigger says no"):
|
||||
with fresh_db.atomic():
|
||||
with (
|
||||
pytest.raises(sqlite3.IntegrityError, match="trigger says no"),
|
||||
fresh_db.atomic(),
|
||||
):
|
||||
fresh_db.execute("insert into t (v) values ('bad')")
|
||||
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
|
||||
fresh_db.execute("create table t (id integer primary key, v text)")
|
||||
fresh_db.execute(TRIGGER_SQL)
|
||||
with pytest.raises(sqlite3.IntegrityError, match="trigger says no"):
|
||||
with fresh_db.atomic():
|
||||
with fresh_db.atomic():
|
||||
with (
|
||||
pytest.raises(sqlite3.IntegrityError, match="trigger says no"),
|
||||
fresh_db.atomic(),
|
||||
fresh_db.atomic(),
|
||||
):
|
||||
fresh_db.execute("insert into t (v) values ('bad')")
|
||||
assert not fresh_db.conn.in_transaction
|
||||
|
||||
|
||||
def test_atomic_preserves_error_from_insert_or_rollback(fresh_db):
|
||||
fresh_db["t"].insert({"id": 1}, pk="id")
|
||||
with pytest.raises(sqlite3.IntegrityError):
|
||||
with fresh_db.atomic():
|
||||
fresh_db.table("t").insert({"id": 1}, pk="id")
|
||||
with pytest.raises(sqlite3.IntegrityError), fresh_db.atomic():
|
||||
fresh_db.execute("insert or rollback into t (id) values (1)")
|
||||
assert not fresh_db.conn.in_transaction
|
||||
|
|
|
|||
|
|
@ -6,10 +6,10 @@ def test_attach(tmpdir):
|
|||
bar_path = str(tmpdir / "bar.db")
|
||||
db = Database(foo_path)
|
||||
with db.conn:
|
||||
db["foo"].insert({"id": 1, "text": "foo"})
|
||||
db.table("foo").insert({"id": 1, "text": "foo"})
|
||||
db2 = Database(bar_path)
|
||||
with db2.conn:
|
||||
db2["bar"].insert({"id": 1, "text": "bar"})
|
||||
db2.table("bar").insert({"id": 1, "text": "bar"})
|
||||
db.attach("bar", bar_path)
|
||||
assert db.execute(
|
||||
"select * from foo union all select * from bar.bar"
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -1,17 +1,19 @@
|
|||
from click.testing import CliRunner
|
||||
from sqlite_utils import cli, Database
|
||||
import pathlib
|
||||
import pytest
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
|
||||
import pytest
|
||||
from click.testing import CliRunner
|
||||
|
||||
from sqlite_utils import Database, cli
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def test_db_and_path(tmpdir):
|
||||
db_path = str(pathlib.Path(tmpdir) / "data.db")
|
||||
db = Database(db_path)
|
||||
db["example"].insert_all(
|
||||
db.table("example").insert_all(
|
||||
[
|
||||
{"id": 1, "name": "One"},
|
||||
{"id": 2, "name": "Two"},
|
||||
|
|
@ -42,7 +44,7 @@ def test_cli_bulk(test_db_and_path):
|
|||
{"id": 2, "name": "Two"},
|
||||
{"id": 3, "name": "THREE"},
|
||||
{"id": 4, "name": "FOUR"},
|
||||
] == list(db["example"].rows)
|
||||
] == list(db.table("example").rows)
|
||||
|
||||
|
||||
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": 3, "name": "THREE"},
|
||||
{"id": 4, "name": "FOUR"},
|
||||
] == list(db["example"].rows)
|
||||
] == list(db.table("example").rows)
|
||||
|
||||
|
||||
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,
|
||||
stdout=sys.stdout,
|
||||
)
|
||||
assert proc.stdin is not None
|
||||
# Writing one record should not commit
|
||||
proc.stdin.write(b'{"id": 3, "name": "Three"}\n\n')
|
||||
proc.stdin.flush()
|
||||
time.sleep(1)
|
||||
assert db["example"].count == 2
|
||||
assert db.table("example").count == 2
|
||||
|
||||
# Writing another should trigger a commit:
|
||||
proc.stdin.write(b'{"id": 4, "name": "Four"}\n\n')
|
||||
proc.stdin.flush()
|
||||
time.sleep(1)
|
||||
assert db["example"].count == 4
|
||||
assert db.table("example").count == 4
|
||||
|
||||
proc.stdin.close()
|
||||
proc.wait()
|
||||
|
|
|
|||
|
|
@ -1,16 +1,18 @@
|
|||
from click.testing import CliRunner
|
||||
from sqlite_utils import cli
|
||||
import sqlite_utils
|
||||
import json
|
||||
import textwrap
|
||||
import pathlib
|
||||
import textwrap
|
||||
|
||||
import pytest
|
||||
from click.testing import CliRunner
|
||||
|
||||
import sqlite_utils
|
||||
from sqlite_utils import cli
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def test_db_and_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": 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):
|
||||
db, db_path = fresh_db_and_path
|
||||
db["t"].insert({"text": "October"})
|
||||
db.table("t").insert({"text": "October"})
|
||||
result = CliRunner().invoke(
|
||||
cli.cli, ["convert", db_path, "t", "text", code], catch_exceptions=False
|
||||
)
|
||||
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"
|
||||
|
||||
|
||||
|
|
@ -63,7 +65,7 @@ def test_convert_code(fresh_db_and_path, code):
|
|||
)
|
||||
def test_convert_code_errors(fresh_db_and_path, bad_code):
|
||||
db, db_path = fresh_db_and_path
|
||||
db["t"].insert({"text": "October"})
|
||||
db.table("t").insert({"text": "October"})
|
||||
result = CliRunner().invoke(
|
||||
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": 3, "dt": ""},
|
||||
{"id": 4, "dt": None},
|
||||
] == list(db["example"].rows)
|
||||
] == list(db.table("example").rows)
|
||||
|
||||
|
||||
def test_convert_import_nested(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(
|
||||
cli.cli,
|
||||
[
|
||||
|
|
@ -112,7 +114,7 @@ def test_convert_import_nested(fresh_db_and_path):
|
|||
assert result.exit_code == 0, result.output
|
||||
assert [
|
||||
{"xml": "Cleo"},
|
||||
] == list(db["example"].rows)
|
||||
] == list(db.table("example").rows)
|
||||
|
||||
|
||||
def test_convert_dryrun(test_db_and_path):
|
||||
|
|
@ -150,7 +152,7 @@ def test_convert_dryrun(test_db_and_path):
|
|||
"Would affect 4 rows"
|
||||
)
|
||||
# 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": 2, "dt": "6th October 2019 00:05:06"},
|
||||
{"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"
|
||||
|
||||
|
||||
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):
|
||||
db_path = test_db_and_path[1]
|
||||
result = CliRunner().invoke(
|
||||
|
|
@ -267,7 +297,7 @@ def test_convert_output_column(test_db_and_path, drop):
|
|||
if drop:
|
||||
for row in expected:
|
||||
del row["dt"]
|
||||
assert list(db["example"].rows) == expected
|
||||
assert list(db.table("example").rows) == expected
|
||||
|
||||
|
||||
@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))
|
||||
def test_convert_multi(fresh_db_and_path, drop):
|
||||
db, db_path = fresh_db_and_path
|
||||
db["creatures"].insert_all(
|
||||
db.table("creatures").insert_all(
|
||||
[
|
||||
{"id": 1, "name": "Simon"},
|
||||
{"id": 2, "name": "Cleo"},
|
||||
|
|
@ -376,12 +406,12 @@ def test_convert_multi(fresh_db_and_path, drop):
|
|||
if drop:
|
||||
for row in expected:
|
||||
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):
|
||||
db, db_path = fresh_db_and_path
|
||||
db["rows"].insert_all(
|
||||
db.table("rows").insert_all(
|
||||
[
|
||||
{"id": 1},
|
||||
{"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 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": 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},
|
||||
]
|
||||
assert db["rows"].schema == (
|
||||
assert db.table("rows").schema == (
|
||||
'CREATE TABLE "rows" (\n'
|
||||
' "id" INTEGER PRIMARY KEY\n'
|
||||
', "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):
|
||||
db_path = str(pathlib.Path(tmpdir) / "data.db")
|
||||
db = sqlite_utils.Database(db_path)
|
||||
db["example"].insert_all(
|
||||
db.table("example").insert_all(
|
||||
[
|
||||
{"id": 1, "tags": (delimiter or ",").join(["foo", "bar"])},
|
||||
{"id": 2, "tags": (delimiter or ",").join(["bar", "baz"])},
|
||||
|
|
@ -442,11 +472,11 @@ def test_recipe_jsonsplit(tmpdir, delimiter):
|
|||
)
|
||||
code = "r.jsonsplit(value)"
|
||||
if delimiter:
|
||||
code = 'recipes.jsonsplit(value, delimiter="{}")'.format(delimiter)
|
||||
code = f'recipes.jsonsplit(value, delimiter="{delimiter}")'
|
||||
args = ["convert", db_path, "example", "tags", code]
|
||||
result = CliRunner().invoke(cli.cli, args)
|
||||
assert result.exit_code == 0, result.output
|
||||
assert list(db["example"].rows) == [
|
||||
assert list(db.table("example").rows) == [
|
||||
{"id": 1, "tags": '["foo", "bar"]'},
|
||||
{"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):
|
||||
db, db_path = fresh_db_and_path
|
||||
db["example"].insert_all(
|
||||
db.table("example").insert_all(
|
||||
[
|
||||
{"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)"
|
||||
if type:
|
||||
code = "recipes.jsonsplit(value, type={})".format(type)
|
||||
code = f"recipes.jsonsplit(value, type={type})"
|
||||
args = ["convert", db_path, "example", "records", code]
|
||||
result = CliRunner().invoke(cli.cli, args)
|
||||
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))
|
||||
def test_recipe_jsonsplit_output(fresh_db_and_path, drop):
|
||||
db, db_path = fresh_db_and_path
|
||||
db["example"].insert_all(
|
||||
db.table("example").insert_all(
|
||||
[
|
||||
{"id": 1, "records": "1,2,3"},
|
||||
],
|
||||
|
|
@ -499,7 +529,7 @@ def test_recipe_jsonsplit_output(fresh_db_and_path, drop):
|
|||
}
|
||||
if drop:
|
||||
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):
|
||||
|
|
@ -556,7 +586,7 @@ def test_convert_where(test_db_and_path):
|
|||
],
|
||||
)
|
||||
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": 2, "dt": "6TH OCTOBER 2019 00:05:06"},
|
||||
{"id": 3, "dt": ""},
|
||||
|
|
@ -566,7 +596,7 @@ def test_convert_where(test_db_and_path):
|
|||
|
||||
def test_convert_where_multi(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"
|
||||
)
|
||||
result = CliRunner().invoke(
|
||||
|
|
@ -586,7 +616,7 @@ def test_convert_where_multi(fresh_db_and_path):
|
|||
],
|
||||
)
|
||||
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": 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):
|
||||
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(
|
||||
cli.cli,
|
||||
[
|
||||
|
|
@ -607,27 +637,27 @@ def test_convert_code_standard_input(fresh_db_and_path):
|
|||
input="value.upper()",
|
||||
)
|
||||
assert result.exit_code == 0, result.output
|
||||
assert list(db["names"].rows) == [
|
||||
assert list(db.table("names").rows) == [
|
||||
{"id": 1, "name": "CLEO"},
|
||||
]
|
||||
|
||||
|
||||
def test_convert_hyphen_workaround(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(
|
||||
cli.cli,
|
||||
["convert", db_path, "names", "name", '"-"'],
|
||||
)
|
||||
assert result.exit_code == 0, result.output
|
||||
assert list(db["names"].rows) == [
|
||||
assert list(db.table("names").rows) == [
|
||||
{"id": 1, "name": "-"},
|
||||
]
|
||||
|
||||
|
||||
def test_convert_initialization_pattern(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(
|
||||
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)",
|
||||
)
|
||||
assert result.exit_code == 0, result.output
|
||||
assert list(db["names"].rows) == [
|
||||
assert list(db.table("names").rows) == [
|
||||
{"id": 1, "name": "17"},
|
||||
]
|
||||
|
||||
|
|
@ -655,13 +685,13 @@ def test_convert_handles_falsey_values(fresh_db_and_path):
|
|||
"x",
|
||||
"-",
|
||||
]
|
||||
db["t"].insert_all([{"x": 0}, {"x": 1}])
|
||||
assert db["t"].get(1)["x"] == 0
|
||||
assert db["t"].get(2)["x"] == 1
|
||||
db.table("t").insert_all([{"x": 0}, {"x": 1}])
|
||||
assert db.table("t").get(1)["x"] == 0
|
||||
assert db.table("t").get(2)["x"] == 1
|
||||
result = CliRunner().invoke(cli.cli, args, input="value + 1")
|
||||
assert result.exit_code == 0, result.output
|
||||
assert db["t"].get(1)["x"] == 1
|
||||
assert db["t"].get(2)["x"] == 2
|
||||
assert db.table("t").get(1)["x"] == 1
|
||||
assert db.table("t").get(2)["x"] == 2
|
||||
|
||||
|
||||
@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
|
||||
)
|
||||
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[1]["dt"] == "2019-10-06"
|
||||
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):
|
||||
"""Test callable reference from an imported module"""
|
||||
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(
|
||||
cli.cli,
|
||||
[
|
||||
|
|
@ -708,5 +738,5 @@ def test_convert_callable_reference_with_import(fresh_db_and_path):
|
|||
)
|
||||
assert result.exit_code == 0, result.output
|
||||
# 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"}'
|
||||
|
|
|
|||
|
|
@ -1,11 +1,13 @@
|
|||
from sqlite_utils import cli, Database
|
||||
from click.testing import CliRunner
|
||||
import json
|
||||
import pytest
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
|
||||
import pytest
|
||||
from click.testing import CliRunner
|
||||
|
||||
from sqlite_utils import Database, cli
|
||||
|
||||
|
||||
def test_insert_simple(tmpdir):
|
||||
json_path = str(tmpdir / "dog.json")
|
||||
|
|
@ -19,7 +21,7 @@ def test_insert_simple(tmpdir):
|
|||
)
|
||||
db = Database(db_path)
|
||||
assert ["dogs"] == db.table_names()
|
||||
assert [] == db["dogs"].indexes
|
||||
assert [] == db.table("dogs").indexes
|
||||
|
||||
|
||||
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")
|
||||
)
|
||||
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):
|
||||
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:
|
||||
fp.write(json.dumps(dogs))
|
||||
result = CliRunner().invoke(
|
||||
|
|
@ -108,13 +110,13 @@ def test_insert_multiple_with_primary_key(db_path, tmpdir):
|
|||
assert result.exit_code == 0
|
||||
db = Database(db_path)
|
||||
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):
|
||||
json_path = str(tmpdir / "dogs.json")
|
||||
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)
|
||||
]
|
||||
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
|
||||
db = Database(db_path)
|
||||
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 (
|
||||
'CREATE TABLE "dogs" (\n'
|
||||
' "breed" TEXT,\n'
|
||||
|
|
@ -134,14 +136,13 @@ def test_insert_multiple_with_compound_primary_key(db_path, tmpdir):
|
|||
' "age" INTEGER,\n'
|
||||
' PRIMARY KEY ("id", "breed")\n'
|
||||
")"
|
||||
) == db["dogs"].schema
|
||||
) == db.table("dogs").schema
|
||||
|
||||
|
||||
def test_insert_not_null_default(db_path, tmpdir):
|
||||
json_path = str(tmpdir / "dogs.json")
|
||||
dogs = [
|
||||
{"id": i, "name": "Cleo {}".format(i), "age": i + 3, "score": 10}
|
||||
for i in range(1, 21)
|
||||
{"id": i, "name": f"Cleo {i}", "age": i + 3, "score": 10} for i in range(1, 21)
|
||||
]
|
||||
with open(json_path, "w") as fp:
|
||||
fp.write(json.dumps(dogs))
|
||||
|
|
@ -159,7 +160,7 @@ def test_insert_not_null_default(db_path, tmpdir):
|
|||
' "name" TEXT NOT NULL,\n'
|
||||
" \"age\" INTEGER NOT NULL DEFAULT '1',\n"
|
||||
" \"score\" INTEGER DEFAULT '5'\n)"
|
||||
) == db["dogs"].schema
|
||||
) == db.table("dogs").schema
|
||||
|
||||
|
||||
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):
|
||||
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")
|
||||
with open(json_path, "w") as fp:
|
||||
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,
|
||||
)
|
||||
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))
|
||||
|
|
@ -247,7 +248,7 @@ def test_insert_csv_empty_null(db_path, empty_null):
|
|||
)
|
||||
assert result.exit_code == 0
|
||||
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"}
|
||||
]
|
||||
|
||||
|
|
@ -301,7 +302,7 @@ def test_insert_replace(db_path, tmpdir):
|
|||
test_insert_multiple_with_primary_key(db_path, tmpdir)
|
||||
json_path = str(tmpdir / "insert-replace.json")
|
||||
db = Database(db_path)
|
||||
assert db["dogs"].count == 20
|
||||
assert db.table("dogs").count == 20
|
||||
insert_replace_dogs = [
|
||||
{"id": 1, "name": "Insert replaced 1", "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"]
|
||||
)
|
||||
assert result.exit_code == 0, result.output
|
||||
assert db["dogs"].count == 21
|
||||
assert db.table("dogs").count == 21
|
||||
assert (
|
||||
list(db.query("select * from dogs where id in (1, 2, 21) order by id"))
|
||||
== insert_replace_dogs
|
||||
|
|
@ -376,7 +377,7 @@ def test_insert_alter(db_path, tmpdir):
|
|||
assert result.exit_code == 0, result.output
|
||||
# Soundness check the database itself
|
||||
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 [
|
||||
{"foo": "bar", "n": 1, "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):
|
||||
db = Database(db_path)
|
||||
db["rows"].insert({"foo": "x", "n": 3})
|
||||
db["rows"].create_index(["n"])
|
||||
db.table("rows").insert({"foo": "x", "n": 3})
|
||||
db.table("rows").create_index(["n"])
|
||||
assert "sqlite_stat1" not in db.table_names()
|
||||
result = CliRunner().invoke(
|
||||
cli.cli,
|
||||
|
|
@ -576,18 +577,19 @@ def test_insert_streaming_batch_size_1(db_path):
|
|||
stdin=subprocess.PIPE,
|
||||
stdout=sys.stdout,
|
||||
)
|
||||
assert proc.stdin is not None
|
||||
proc.stdin.write(b'{"name": "Azi"}\n')
|
||||
proc.stdin.flush()
|
||||
|
||||
def try_until(expected):
|
||||
tries = 0
|
||||
while True:
|
||||
rows = list(Database(db_path)["rows"].rows)
|
||||
rows = list(Database(db_path).table("rows").rows)
|
||||
if rows == expected:
|
||||
return
|
||||
tries += 1
|
||||
if tries > 10:
|
||||
assert False, "Expected {}, got {}".format(expected, rows)
|
||||
assert False, f"Expected {expected}, got {rows}"
|
||||
time.sleep(tries * 0.1)
|
||||
|
||||
try_until([{"name": "Azi"}])
|
||||
|
|
@ -614,13 +616,13 @@ def test_insert_csv_headers_only(tmpdir):
|
|||
assert result.exit_code == 0
|
||||
# Table should not exist since there were no data rows
|
||||
db = Database(db_path)
|
||||
assert not db["data"].exists()
|
||||
assert not db.table("data").exists()
|
||||
|
||||
|
||||
def test_insert_into_view_errors(tmpdir):
|
||||
db_path = str(tmpdir / "test.db")
|
||||
db = Database(db_path)
|
||||
db["t"].insert({"id": 1})
|
||||
db.table("t").insert({"id": 1})
|
||||
db.create_view("v", "select * from t")
|
||||
db.close()
|
||||
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
|
||||
# TEXT zip codes with leading zeros
|
||||
db = Database(db_path)
|
||||
db["places"].insert({"name": "Boston", "zip": "01234"})
|
||||
db.table("places").insert({"name": "Boston", "zip": "01234"})
|
||||
result = CliRunner().invoke(
|
||||
cli.cli,
|
||||
["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",
|
||||
)
|
||||
assert result.exit_code == 0, result.output
|
||||
assert db["places"].columns_dict["zip"] is str
|
||||
assert list(db["places"].rows) == [
|
||||
assert db.table("places").columns_dict["zip"] is str
|
||||
assert list(db.table("places").rows) == [
|
||||
{"name": "Boston", "zip": "01234"},
|
||||
{"name": "SF", "zip": "94107"},
|
||||
]
|
||||
|
|
@ -661,12 +663,59 @@ def test_insert_csv_detect_types_new_table(db_path):
|
|||
)
|
||||
assert result.exit_code == 0, result.output
|
||||
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(
|
||||
"command,extra_args,input_text,expected_row",
|
||||
(
|
||||
(
|
||||
"insert",
|
||||
[],
|
||||
"zipcode,score\n01234,9.5\n",
|
||||
{"zipcode": "01234", "score": 9.5},
|
||||
),
|
||||
(
|
||||
"upsert",
|
||||
["--pk", "id"],
|
||||
"id,zipcode,score\n1,01234,9.5\n",
|
||||
{"id": 1, "zipcode": "01234", "score": 9.5},
|
||||
),
|
||||
),
|
||||
)
|
||||
def test_insert_upsert_csv_type_overrides_detected_types(
|
||||
db_path, command, extra_args, input_text, expected_row
|
||||
):
|
||||
result = CliRunner().invoke(
|
||||
cli.cli,
|
||||
[
|
||||
command,
|
||||
db_path,
|
||||
"places",
|
||||
"-",
|
||||
"--csv",
|
||||
]
|
||||
+ extra_args
|
||||
+ [
|
||||
"--type",
|
||||
"zipcode",
|
||||
"text",
|
||||
],
|
||||
catch_exceptions=False,
|
||||
input=input_text,
|
||||
)
|
||||
assert result.exit_code == 0, result.output
|
||||
db = Database(db_path)
|
||||
expected_columns = {"zipcode": str, "score": float}
|
||||
if command == "upsert":
|
||||
expected_columns = {"id": int, **expected_columns}
|
||||
assert db.table("places").columns_dict == expected_columns
|
||||
assert list(db.table("places").rows) == [expected_row]
|
||||
|
||||
|
||||
def test_upsert_csv_detect_types_leaves_existing_table_alone(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(
|
||||
cli.cli,
|
||||
["upsert", db_path, "places", "-", "--csv", "--pk", "id"],
|
||||
|
|
@ -674,15 +723,15 @@ def test_upsert_csv_detect_types_leaves_existing_table_alone(db_path):
|
|||
input="id,name,zip\n2,SF,94107",
|
||||
)
|
||||
assert result.exit_code == 0, result.output
|
||||
assert db["places"].columns_dict["zip"] is str
|
||||
assert db["places"].get(1)["zip"] == "01234"
|
||||
assert db.table("places").columns_dict["zip"] is str
|
||||
assert db.table("places").get(1)["zip"] == "01234"
|
||||
|
||||
|
||||
def test_insert_invalid_pk_clean_error(db_path):
|
||||
# An invalid --pk against an existing table should be a clean CLI
|
||||
# error, not a raw InvalidColumns traceback
|
||||
db = Database(db_path)
|
||||
db["t"].insert({"a": 1})
|
||||
db.table("t").insert({"a": 1})
|
||||
result = CliRunner().invoke(
|
||||
cli.cli,
|
||||
["insert", db_path, "t", "-", "--pk", "badcol"],
|
||||
|
|
@ -691,3 +740,155 @@ def test_insert_invalid_pk_clean_error(db_path):
|
|||
assert result.exit_code == 1
|
||||
assert result.exception is None or isinstance(result.exception, SystemExit)
|
||||
assert result.output.startswith("Error: Invalid primary key column")
|
||||
|
||||
|
||||
# --code tests, see https://github.com/simonw/sqlite-utils/issues/684
|
||||
CODE_ROWS_FUNCTION = """
|
||||
def rows():
|
||||
yield {"id": 1, "name": "Cleo"}
|
||||
yield {"id": 2, "name": "Suna"}
|
||||
"""
|
||||
|
||||
CODE_ROWS_ITERABLE = """
|
||||
rows = [
|
||||
{"id": 1, "name": "Cleo"},
|
||||
{"id": 2, "name": "Suna"},
|
||||
]
|
||||
"""
|
||||
|
||||
|
||||
@pytest.mark.parametrize("code", (CODE_ROWS_FUNCTION, CODE_ROWS_ITERABLE))
|
||||
def test_insert_code(tmpdir, code):
|
||||
db_path = str(tmpdir / "dogs.db")
|
||||
result = CliRunner().invoke(
|
||||
cli.cli,
|
||||
["insert", db_path, "creatures", "--code", code, "--pk", "id"],
|
||||
)
|
||||
assert result.exit_code == 0, result.output
|
||||
db = Database(db_path)
|
||||
assert db.table("creatures").pks == ["id"]
|
||||
assert list(db.table("creatures").rows) == [
|
||||
{"id": 1, "name": "Cleo"},
|
||||
{"id": 2, "name": "Suna"},
|
||||
]
|
||||
|
||||
|
||||
def test_insert_code_from_file(tmpdir):
|
||||
db_path = str(tmpdir / "dogs.db")
|
||||
code_path = str(tmpdir / "gen.py")
|
||||
with open(code_path, "w") as fp:
|
||||
fp.write(CODE_ROWS_FUNCTION)
|
||||
result = CliRunner().invoke(
|
||||
cli.cli,
|
||||
["insert", db_path, "creatures", "--code", code_path],
|
||||
)
|
||||
assert result.exit_code == 0, result.output
|
||||
assert list(Database(db_path).table("creatures").rows) == [
|
||||
{"id": 1, "name": "Cleo"},
|
||||
{"id": 2, "name": "Suna"},
|
||||
]
|
||||
|
||||
|
||||
def test_upsert_code(tmpdir):
|
||||
db_path = str(tmpdir / "dogs.db")
|
||||
db = Database(db_path)
|
||||
db.table("creatures").insert_all(
|
||||
[{"id": 1, "name": "old"}, {"id": 2, "name": "Suna"}], pk="id"
|
||||
)
|
||||
result = CliRunner().invoke(
|
||||
cli.cli,
|
||||
["upsert", db_path, "creatures", "--code", CODE_ROWS_FUNCTION, "--pk", "id"],
|
||||
)
|
||||
assert result.exit_code == 0, result.output
|
||||
assert list(db.table("creatures").rows) == [
|
||||
{"id": 1, "name": "Cleo"},
|
||||
{"id": 2, "name": "Suna"},
|
||||
]
|
||||
|
||||
|
||||
def test_insert_code_requires_file_or_code(tmpdir):
|
||||
db_path = str(tmpdir / "dogs.db")
|
||||
result = CliRunner().invoke(cli.cli, ["insert", db_path, "creatures"])
|
||||
assert result.exit_code == 1
|
||||
assert "Provide either a FILE argument or --code" in result.output
|
||||
|
||||
|
||||
def test_insert_code_mutually_exclusive_with_file(tmpdir):
|
||||
db_path = str(tmpdir / "dogs.db")
|
||||
result = CliRunner().invoke(
|
||||
cli.cli,
|
||||
["insert", db_path, "creatures", "-", "--code", CODE_ROWS_FUNCTION],
|
||||
input="{}",
|
||||
)
|
||||
assert result.exit_code == 1
|
||||
assert "--code cannot be used with a FILE argument" in result.output
|
||||
|
||||
|
||||
def test_insert_code_rejects_input_format_options(tmpdir):
|
||||
db_path = str(tmpdir / "dogs.db")
|
||||
result = CliRunner().invoke(
|
||||
cli.cli,
|
||||
["insert", db_path, "creatures", "--code", CODE_ROWS_FUNCTION, "--csv"],
|
||||
)
|
||||
assert result.exit_code == 1
|
||||
assert "--code cannot be used with input format options" in result.output
|
||||
|
||||
|
||||
def test_insert_code_missing_rows(tmpdir):
|
||||
db_path = str(tmpdir / "dogs.db")
|
||||
result = CliRunner().invoke(
|
||||
cli.cli,
|
||||
["insert", db_path, "creatures", "--code", "x = 1"],
|
||||
)
|
||||
assert result.exit_code == 1
|
||||
assert "must define a 'rows' function or iterable" in result.output
|
||||
|
||||
|
||||
def test_insert_code_single_dict(tmpdir):
|
||||
db_path = str(tmpdir / "dogs.db")
|
||||
result = CliRunner().invoke(
|
||||
cli.cli,
|
||||
[
|
||||
"insert",
|
||||
db_path,
|
||||
"creatures",
|
||||
"--code",
|
||||
'rows = {"id": 1, "name": "Cleo"}',
|
||||
"--pk",
|
||||
"id",
|
||||
],
|
||||
)
|
||||
assert result.exit_code == 0, result.output
|
||||
assert list(Database(db_path).table("creatures").rows) == [
|
||||
{"id": 1, "name": "Cleo"}
|
||||
]
|
||||
|
||||
|
||||
def test_insert_code_not_iterable(tmpdir):
|
||||
db_path = str(tmpdir / "dogs.db")
|
||||
result = CliRunner().invoke(
|
||||
cli.cli,
|
||||
["insert", db_path, "creatures", "--code", "rows = 5"],
|
||||
)
|
||||
assert result.exit_code == 1
|
||||
assert "must define a 'rows' function or iterable" in result.output
|
||||
|
||||
|
||||
def test_insert_code_syntax_error(tmpdir):
|
||||
db_path = str(tmpdir / "dogs.db")
|
||||
result = CliRunner().invoke(
|
||||
cli.cli,
|
||||
["insert", db_path, "creatures", "--code", "def rows(:"],
|
||||
)
|
||||
assert result.exit_code == 1
|
||||
assert "Error in --code" in result.output
|
||||
|
||||
|
||||
def test_insert_code_file_not_found(tmpdir):
|
||||
db_path = str(tmpdir / "dogs.db")
|
||||
result = CliRunner().invoke(
|
||||
cli.cli,
|
||||
["insert", db_path, "creatures", "--code", "missing.py"],
|
||||
)
|
||||
assert result.exit_code == 1
|
||||
assert "File not found: missing.py" in result.output
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import click
|
||||
import json
|
||||
|
||||
import click
|
||||
import pytest
|
||||
from click.testing import CliRunner
|
||||
|
||||
|
|
@ -28,7 +29,7 @@ def test_memory_csv(tmpdir, sql_from, use_stdin):
|
|||
fp.write(content)
|
||||
result = CliRunner().invoke(
|
||||
cli.cli,
|
||||
["memory", csv_path, "select * from {}".format(sql_from), "--nl"],
|
||||
["memory", csv_path, f"select * from {sql_from}", "--nl"],
|
||||
input=input,
|
||||
)
|
||||
assert result.exit_code == 0
|
||||
|
|
@ -53,7 +54,7 @@ def test_memory_tsv(tmpdir, use_stdin):
|
|||
sql_from = "chickens"
|
||||
result = CliRunner().invoke(
|
||||
cli.cli,
|
||||
["memory", path, "select * from {}".format(sql_from)],
|
||||
["memory", path, f"select * from {sql_from}"],
|
||||
input=input,
|
||||
)
|
||||
assert result.exit_code == 0, result.output
|
||||
|
|
@ -79,7 +80,7 @@ def test_memory_json(tmpdir, use_stdin):
|
|||
sql_from = "chickens"
|
||||
result = CliRunner().invoke(
|
||||
cli.cli,
|
||||
["memory", path, "select * from {}".format(sql_from)],
|
||||
["memory", path, f"select * from {sql_from}"],
|
||||
input=input,
|
||||
)
|
||||
assert result.exit_code == 0, result.output
|
||||
|
|
@ -105,7 +106,7 @@ def test_memory_json_nl(tmpdir, use_stdin):
|
|||
sql_from = "chickens"
|
||||
result = CliRunner().invoke(
|
||||
cli.cli,
|
||||
["memory", path, "select * from {}".format(sql_from)],
|
||||
["memory", path, f"select * from {sql_from}"],
|
||||
input=input,
|
||||
)
|
||||
assert result.exit_code == 0, result.output
|
||||
|
|
@ -135,7 +136,7 @@ def test_memory_csv_encoding(tmpdir, use_stdin):
|
|||
CliRunner()
|
||||
.invoke(
|
||||
cli.cli,
|
||||
["memory", csv_path, "select * from {}".format(sql_from), "--nl"],
|
||||
["memory", csv_path, f"select * from {sql_from}", "--nl"],
|
||||
input=input,
|
||||
)
|
||||
.exit_code
|
||||
|
|
@ -227,7 +228,7 @@ def test_memory_save(tmpdir, extra_args):
|
|||
)
|
||||
assert result.exit_code == 0
|
||||
db = Database(save_to)
|
||||
assert list(db["stdin"].rows) == [
|
||||
assert list(db.table("stdin").rows) == [
|
||||
{"id": 1, "name": "Cleo"},
|
||||
{"id": 2, "name": "Bants"},
|
||||
]
|
||||
|
|
|
|||
|
|
@ -1,7 +1,8 @@
|
|||
import pathlib
|
||||
|
||||
from click.testing import CliRunner
|
||||
import pytest
|
||||
from click.testing import CliRunner
|
||||
|
||||
import sqlite_utils
|
||||
import sqlite_utils.cli
|
||||
|
||||
|
|
@ -12,11 +13,11 @@ m = Migrations("hello")
|
|||
|
||||
@m()
|
||||
def foo(db):
|
||||
db["foo"].insert({"hello": "world"})
|
||||
db.table("foo").insert({"hello": "world"})
|
||||
|
||||
@m()
|
||||
def bar(db):
|
||||
db["bar"].insert({"hello": "world"})
|
||||
db.table("bar").insert({"hello": "world"})
|
||||
"""
|
||||
|
||||
|
||||
|
|
@ -41,21 +42,21 @@ creatures = Migrations("creatures")
|
|||
|
||||
@creatures()
|
||||
def create_table(db):
|
||||
db["creatures"].insert({"name": "Cleo"})
|
||||
db.table("creatures").insert({"name": "Cleo"})
|
||||
|
||||
@creatures()
|
||||
def add_weight(db):
|
||||
db["creature_weights"].insert({"weight": 4.2})
|
||||
db.table("creature_weights").insert({"weight": 4.2})
|
||||
|
||||
sales = Migrations("sales")
|
||||
|
||||
@sales()
|
||||
def create_table(db):
|
||||
db["sales"].insert({"id": 1})
|
||||
db.table("sales").insert({"id": 1})
|
||||
|
||||
@sales()
|
||||
def add_weight(db):
|
||||
db["sales_weights"].insert({"weight": 10})
|
||||
db.table("sales_weights").insert({"weight": 10})
|
||||
""",
|
||||
"utf-8",
|
||||
)
|
||||
|
|
@ -98,10 +99,10 @@ def test_basic(two_migrations, arg):
|
|||
assert " Pending:\n (none)" in list_output
|
||||
|
||||
db = sqlite_utils.Database(db_path)
|
||||
assert db["foo"].exists()
|
||||
assert db["bar"].exists()
|
||||
assert db["_sqlite_migrations"].exists()
|
||||
rows = list(db["_sqlite_migrations"].rows)
|
||||
assert db.table("foo").exists()
|
||||
assert db.table("bar").exists()
|
||||
assert db.table("_sqlite_migrations").exists()
|
||||
rows = list(db.table("_sqlite_migrations").rows)
|
||||
assert len(rows) == 2
|
||||
assert rows[0]["name"] == "foo"
|
||||
assert rows[1]["name"] == "bar"
|
||||
|
|
@ -112,13 +113,13 @@ def test_list_same_migration_names_in_different_sets(capsys):
|
|||
|
||||
@applied(name="foo")
|
||||
def applied_foo(db):
|
||||
db["applied"].insert({"hello": "world"})
|
||||
db.table("applied").insert({"hello": "world"})
|
||||
|
||||
pending = sqlite_utils.Migrations("pending")
|
||||
|
||||
@pending(name="foo")
|
||||
def pending_foo(db):
|
||||
db["pending"].insert({"hello": "world"})
|
||||
db.table("pending").insert({"hello": "world"})
|
||||
|
||||
db = sqlite_utils.Database(memory=True)
|
||||
applied.apply(db)
|
||||
|
|
@ -143,7 +144,7 @@ m = Migrations("hello")
|
|||
|
||||
@m()
|
||||
def foo(db):
|
||||
db["dogs"].insert({"id": 1, "name": "Cleo"})
|
||||
db.table("dogs").insert({"id": 1, "name": "Cleo"})
|
||||
""",
|
||||
"utf-8",
|
||||
)
|
||||
|
|
@ -183,9 +184,9 @@ Schema after:
|
|||
new_migration = """
|
||||
@m()
|
||||
def bar(db):
|
||||
db["dogs"].add_column("age", int)
|
||||
db["dogs"].add_column("weight", float)
|
||||
db["dogs"].transform()
|
||||
db.table("dogs").add_column("age", int)
|
||||
db.table("dogs").add_column("weight", float)
|
||||
db.table("dogs").transform()
|
||||
"""
|
||||
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
|
||||
db = sqlite_utils.Database(db_path)
|
||||
assert db["foo"].exists()
|
||||
assert not db["bar"].exists()
|
||||
assert db.table("foo").exists()
|
||||
assert not db.table("bar").exists()
|
||||
|
||||
|
||||
def test_stop_before_multiple_sets_unqualified(two_migrations):
|
||||
|
|
@ -238,7 +239,7 @@ m = Migrations("hello2")
|
|||
|
||||
@m()
|
||||
def foo(db):
|
||||
db["foo"].insert({"hello": "world"})
|
||||
db.table("foo").insert({"hello": "world"})
|
||||
""",
|
||||
"utf-8",
|
||||
)
|
||||
|
|
@ -256,7 +257,7 @@ def foo(db):
|
|||
assert result.exit_code == 0, result.output
|
||||
db = sqlite_utils.Database(db_path)
|
||||
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):
|
||||
|
|
@ -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
|
||||
db = sqlite_utils.Database(db_path)
|
||||
assert db["creatures"].exists()
|
||||
assert not db["creature_weights"].exists()
|
||||
assert db["sales"].exists()
|
||||
assert db["sales_weights"].exists()
|
||||
assert db.table("creatures").exists()
|
||||
assert not db.table("creature_weights").exists()
|
||||
assert db.table("sales").exists()
|
||||
assert db.table("sales_weights").exists()
|
||||
|
||||
|
||||
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
|
||||
db = sqlite_utils.Database(db_path)
|
||||
assert db["creatures"].exists()
|
||||
assert not db["creature_weights"].exists()
|
||||
assert db["sales"].exists()
|
||||
assert not db["sales_weights"].exists()
|
||||
assert db.table("creatures").exists()
|
||||
assert not db.table("creature_weights").exists()
|
||||
assert db.table("sales").exists()
|
||||
assert not db.table("sales_weights").exists()
|
||||
|
||||
|
||||
LEGACY_MIGRATIONS = """
|
||||
|
|
@ -330,7 +331,7 @@ class LegacyMigrations:
|
|||
return fn
|
||||
|
||||
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},
|
||||
pk=("migration_set", "name"),
|
||||
if_not_exists=True,
|
||||
|
|
@ -340,7 +341,7 @@ class LegacyMigrations:
|
|||
self.ensure_migrations_table(db)
|
||||
return [
|
||||
_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]
|
||||
)
|
||||
]
|
||||
|
|
@ -354,7 +355,7 @@ class LegacyMigrations:
|
|||
if migration.name == stop_before:
|
||||
return
|
||||
migration.fn(db)
|
||||
db[self.migrations_table].insert(
|
||||
db.table(self.migrations_table).insert(
|
||||
{
|
||||
"migration_set": self.name,
|
||||
"name": migration.name,
|
||||
|
|
@ -368,11 +369,11 @@ legacy = LegacyMigrations("legacy_set")
|
|||
|
||||
@legacy
|
||||
def first(db):
|
||||
db["first"].insert({"hello": "world"})
|
||||
db.table("first").insert({"hello": "world"})
|
||||
|
||||
@legacy
|
||||
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
|
||||
db_path = str(path / "test.db")
|
||||
db = sqlite_utils.Database(db_path)
|
||||
db["_sqlite_migrations"].create(
|
||||
db.table("_sqlite_migrations").create(
|
||||
{"migration_set": str, "name": str, "applied_at": str},
|
||||
pk=("migration_set", "name"),
|
||||
)
|
||||
db["_sqlite_migrations"].insert(
|
||||
db.table("_sqlite_migrations").insert(
|
||||
{"migration_set": "hello", "name": "foo", "applied_at": "x"}
|
||||
)
|
||||
db.close()
|
||||
|
|
@ -461,7 +462,7 @@ def test_list_does_not_upgrade_legacy_migrations_table(two_migrations):
|
|||
assert "foo - x" in result.output
|
||||
# --list must not perform the one-way legacy schema upgrade
|
||||
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()
|
||||
|
||||
|
||||
|
|
@ -484,7 +485,7 @@ def test_stop_before_applied_migration_errors(two_migrations):
|
|||
assert result.exit_code != 0
|
||||
assert "already been applied" in result.output
|
||||
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):
|
||||
|
|
@ -495,7 +496,7 @@ def test_list_with_legacy_class_is_read_only(tmpdir):
|
|||
(path / "migrations.py").write_text(LEGACY_MIGRATIONS, "utf-8")
|
||||
db_path = str(path / "test.db")
|
||||
db = sqlite_utils.Database(db_path)
|
||||
db["existing"].insert({"id": 1})
|
||||
db.table("existing").insert({"id": 1})
|
||||
db.close()
|
||||
result = CliRunner().invoke(
|
||||
sqlite_utils.cli.cli, ["migrate", db_path, str(path), "--list"]
|
||||
|
|
|
|||
|
|
@ -1,4 +1,6 @@
|
|||
import pytest
|
||||
|
||||
from sqlite_utils import ANY
|
||||
from sqlite_utils.utils import column_affinity
|
||||
|
||||
EXAMPLES = [
|
||||
|
|
@ -25,6 +27,8 @@ EXAMPLES = [
|
|||
("DOUBLE", float),
|
||||
("DOUBLE PRECISION", float),
|
||||
("FLOAT", float),
|
||||
("ANY", ANY),
|
||||
("any", ANY),
|
||||
# Numeric, treated as float:
|
||||
("NUMERIC", 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)
|
||||
def test_columns_dict(fresh_db, column_def, expected_type):
|
||||
fresh_db.execute("create table foo (col {})".format(column_def))
|
||||
assert {"col": expected_type} == fresh_db["foo"].columns_dict
|
||||
fresh_db.execute(f"create table foo (col {column_def})")
|
||||
assert {"col": expected_type} == fresh_db.table("foo").columns_dict
|
||||
|
|
|
|||
|
|
@ -13,14 +13,14 @@ from sqlite_utils.db import ForeignKey
|
|||
|
||||
|
||||
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.insert({"Id": 1, "Title": "One"}, pk="id")
|
||||
assert books.last_pk == 1
|
||||
|
||||
|
||||
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.insert(
|
||||
{"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))
|
||||
def test_upsert_pk_case_differs_from_schema(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.insert({"Id": 1, "Title": "One"})
|
||||
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):
|
||||
# all_columns comes from the record keys, pk= from the caller
|
||||
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.upsert({"ID": 1, "Title": "One"}, pk="id")
|
||||
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):
|
||||
# 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.upsert({"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):
|
||||
books = fresh_db["books"]
|
||||
books = fresh_db.table("books")
|
||||
books.create({"Id": int, "Title": str}, pk="Id")
|
||||
books.upsert_all([["id", "title"], [1, "One"]], pk="Id")
|
||||
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):
|
||||
fresh_db["species"].create({"ID": int, "Name": str}, pk="ID")
|
||||
fresh_db["species"].insert({"ID": 5, "Name": "Palm"})
|
||||
fresh_db["species"].create_index(["Name"], unique=True)
|
||||
assert fresh_db["species"].lookup({"Name": "Palm"}, pk="id") == 5
|
||||
fresh_db.table("species").create({"ID": int, "Name": str}, pk="ID")
|
||||
fresh_db.table("species").insert({"ID": 5, "Name": "Palm"})
|
||||
fresh_db.table("species").create_index(["Name"], unique=True)
|
||||
assert fresh_db.table("species").lookup({"Name": "Palm"}, pk="id") == 5
|
||||
|
||||
|
||||
def test_lookup_does_not_create_redundant_index(fresh_db):
|
||||
fresh_db["species"].create({"id": int, "Name": str}, pk="id")
|
||||
fresh_db["species"].create_index(["Name"], unique=True)
|
||||
fresh_db["species"].lookup({"name": "Palm"})
|
||||
assert len(fresh_db["species"].indexes) == 1
|
||||
fresh_db.table("species").create({"id": int, "Name": str}, pk="id")
|
||||
fresh_db.table("species").create_index(["Name"], unique=True)
|
||||
fresh_db.table("species").lookup({"name": "Palm"})
|
||||
assert len(fresh_db.table("species").indexes) == 1
|
||||
|
||||
|
||||
def test_create_table_transform_same_columns_different_case(fresh_db):
|
||||
fresh_db["t"].create({"Name": str, "Age": int})
|
||||
fresh_db["t"].insert({"Name": "Cleo", "Age": 5})
|
||||
fresh_db.table("t").create({"Name": str, "Age": int})
|
||||
fresh_db.table("t").insert({"Name": "Cleo", "Age": 5})
|
||||
fresh_db.create_table("t", {"name": str, "age": int}, transform=True)
|
||||
# Schema casing is preserved - SQLite considers these the same columns
|
||||
assert fresh_db["t"].columns_dict == {"Name": str, "Age": int}
|
||||
assert list(fresh_db["t"].rows) == [{"Name": "Cleo", "Age": 5}]
|
||||
assert fresh_db.table("t").columns_dict == {"Name": str, "Age": int}
|
||||
assert list(fresh_db.table("t").rows) == [{"Name": "Cleo", "Age": 5}]
|
||||
|
||||
|
||||
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)
|
||||
# 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):
|
||||
fresh_db["t"].create({"Name": str, "Age": str})
|
||||
fresh_db["t"].transform(types={"age": int})
|
||||
assert fresh_db["t"].columns_dict == {"Name": str, "Age": int}
|
||||
fresh_db.table("t").create({"Name": str, "Age": str})
|
||||
fresh_db.table("t").transform(types={"age": int})
|
||||
assert fresh_db.table("t").columns_dict == {"Name": str, "Age": int}
|
||||
|
||||
|
||||
def test_transform_rename_case_insensitive(fresh_db):
|
||||
fresh_db["t"].create({"Name": str})
|
||||
fresh_db["t"].transform(rename={"name": "title"})
|
||||
assert fresh_db["t"].columns_dict == {"title": str}
|
||||
fresh_db.table("t").create({"Name": str})
|
||||
fresh_db.table("t").transform(rename={"name": "title"})
|
||||
assert fresh_db.table("t").columns_dict == {"title": str}
|
||||
|
||||
|
||||
def test_transform_drop_case_insensitive(fresh_db):
|
||||
fresh_db["t"].create({"Name": str, "Age": int})
|
||||
fresh_db["t"].transform(drop=["name"])
|
||||
assert fresh_db["t"].columns_dict == {"Age": int}
|
||||
fresh_db.table("t").create({"Name": str, "Age": int})
|
||||
fresh_db.table("t").transform(drop=["name"])
|
||||
assert fresh_db.table("t").columns_dict == {"Age": int}
|
||||
|
||||
|
||||
def test_transform_not_null_and_defaults_case_insensitive(fresh_db):
|
||||
fresh_db["t"].create({"Name": str, "Age": int})
|
||||
fresh_db["t"].transform(not_null={"name"}, defaults={"age": 3})
|
||||
columns = {c.name: c for c in fresh_db["t"].columns}
|
||||
fresh_db.table("t").create({"Name": str, "Age": int})
|
||||
fresh_db.table("t").transform(not_null={"name"}, defaults={"age": 3})
|
||||
columns = {c.name: c for c in fresh_db.table("t").columns}
|
||||
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):
|
||||
fresh_db["t"].create({"Id": int, "Name": str})
|
||||
fresh_db["t"].transform(pk="id")
|
||||
assert fresh_db["t"].pks == ["Id"]
|
||||
assert fresh_db["t"].columns_dict == {"Id": int, "Name": str}
|
||||
fresh_db.table("t").create({"Id": int, "Name": str})
|
||||
fresh_db.table("t").transform(pk="id")
|
||||
assert fresh_db.table("t").pks == ["Id"]
|
||||
assert fresh_db.table("t").columns_dict == {"Id": int, "Name": str}
|
||||
|
||||
|
||||
def test_transform_drop_foreign_keys_case_insensitive(fresh_db):
|
||||
fresh_db["parent"].create({"Id": int}, pk="Id")
|
||||
fresh_db["child"].create(
|
||||
fresh_db.table("parent").create({"Id": int}, pk="Id")
|
||||
fresh_db.table("child").create(
|
||||
{"id": int, "Parent_ID": int},
|
||||
pk="id",
|
||||
foreign_keys=[("Parent_ID", "parent", "Id")],
|
||||
)
|
||||
fresh_db["child"].transform(drop_foreign_keys=["parent_id"])
|
||||
assert fresh_db["child"].foreign_keys == []
|
||||
fresh_db.table("child").transform(drop_foreign_keys=["parent_id"])
|
||||
assert fresh_db.table("child").foreign_keys == []
|
||||
|
||||
|
||||
def test_add_foreign_key_case_insensitive(fresh_db):
|
||||
fresh_db["parent"].create({"Id": int}, pk="Id")
|
||||
fresh_db["child"].create({"id": int, "Parent_ID": int}, pk="id")
|
||||
fresh_db["child"].add_foreign_key("parent_id", "parent", "id")
|
||||
fks = fresh_db["child"].foreign_keys
|
||||
fresh_db.table("parent").create({"Id": int}, pk="Id")
|
||||
fresh_db.table("child").create({"id": int, "Parent_ID": int}, pk="id")
|
||||
fresh_db.table("child").add_foreign_key("parent_id", "parent", "id")
|
||||
fks = fresh_db.table("child").foreign_keys
|
||||
assert len(fks) == 1
|
||||
# The foreign key should use the schema casing of the columns
|
||||
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):
|
||||
fresh_db["parent"].create({"Id": int}, pk="Id")
|
||||
fresh_db["child"].create({"id": int, "Parent_ID": int}, pk="id")
|
||||
fresh_db.table("parent").create({"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")])
|
||||
fks = fresh_db["child"].foreign_keys
|
||||
fks = fresh_db.table("child").foreign_keys
|
||||
assert len(fks) == 1
|
||||
assert fks[0].column == "Parent_ID"
|
||||
assert fks[0].other_column == "Id"
|
||||
|
||||
|
||||
def test_add_foreign_key_detects_existing_case_insensitively(fresh_db):
|
||||
fresh_db["parent"].create({"Id": int}, pk="Id")
|
||||
fresh_db["child"].create(
|
||||
fresh_db.table("parent").create({"Id": int}, pk="Id")
|
||||
fresh_db.table("child").create(
|
||||
{"id": int, "Parent_ID": int},
|
||||
pk="id",
|
||||
foreign_keys=[("Parent_ID", "parent", "Id")],
|
||||
)
|
||||
# ignore=True should treat this as already existing, not add a duplicate
|
||||
fresh_db["child"].add_foreign_key("parent_id", "parent", "id", ignore=True)
|
||||
assert len(fresh_db["child"].foreign_keys) == 1
|
||||
fresh_db.table("child").add_foreign_key("parent_id", "parent", "id", ignore=True)
|
||||
assert len(fresh_db.table("child").foreign_keys) == 1
|
||||
|
||||
|
||||
def test_add_column_fk_col_case_insensitive(fresh_db):
|
||||
fresh_db["parent"].create({"Id": int}, pk="Id")
|
||||
fresh_db["child"].create({"id": int}, pk="id")
|
||||
fresh_db["child"].add_column("parent_id", int, fk="parent", fk_col="id")
|
||||
fks = fresh_db["child"].foreign_keys
|
||||
fresh_db.table("parent").create({"Id": int}, pk="Id")
|
||||
fresh_db.table("child").create({"id": int}, pk="id")
|
||||
fresh_db.table("child").add_column("parent_id", int, fk="parent", fk_col="id")
|
||||
fks = fresh_db.table("child").foreign_keys
|
||||
assert len(fks) == 1
|
||||
assert fks[0].other_column == "Id"
|
||||
|
||||
|
||||
def test_extract_case_insensitive(fresh_db):
|
||||
fresh_db["trees"].insert({"id": 1, "Species": "Palm"}, pk="id")
|
||||
fresh_db["trees"].extract("species")
|
||||
assert fresh_db["trees"].columns_dict == {"id": int, "Species_id": int}
|
||||
assert list(fresh_db["Species"].rows) == [{"id": 1, "Species": "Palm"}]
|
||||
fresh_db.table("trees").insert({"id": 1, "Species": "Palm"}, pk="id")
|
||||
fresh_db.table("trees").extract("species")
|
||||
assert fresh_db.table("trees").columns_dict == {"id": int, "Species_id": int}
|
||||
assert list(fresh_db.table("Species").rows) == [{"id": 1, "Species": "Palm"}]
|
||||
|
||||
|
||||
def test_convert_multi_case_insensitive(fresh_db):
|
||||
fresh_db["t"].insert({"id": 1, "Name": "Cleo"}, pk="id")
|
||||
fresh_db["t"].convert("name", lambda v: {"upper": v.upper()}, multi=True)
|
||||
assert list(fresh_db["t"].rows) == [{"id": 1, "Name": "Cleo", "upper": "CLEO"}]
|
||||
fresh_db.table("t").insert({"id": 1, "Name": "Cleo"}, pk="id")
|
||||
fresh_db.table("t").convert("name", lambda v: {"upper": v.upper()}, multi=True)
|
||||
assert list(fresh_db.table("t").rows) == [
|
||||
{"id": 1, "Name": "Cleo", "upper": "CLEO"}
|
||||
]
|
||||
|
||||
|
||||
def test_convert_output_case_insensitive(fresh_db):
|
||||
fresh_db["t"].insert({"id": 1, "Name": "Cleo", "Upper": None}, pk="id")
|
||||
fresh_db["t"].convert("name", lambda v: v.upper(), output="upper")
|
||||
assert list(fresh_db["t"].rows) == [{"id": 1, "Name": "Cleo", "Upper": "CLEO"}]
|
||||
fresh_db.table("t").insert({"id": 1, "Name": "Cleo", "Upper": None}, pk="id")
|
||||
fresh_db.table("t").convert("name", lambda v: v.upper(), output="upper")
|
||||
assert list(fresh_db.table("t").rows) == [
|
||||
{"id": 1, "Name": "Cleo", "Upper": "CLEO"}
|
||||
]
|
||||
|
||||
|
||||
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
|
||||
assert fresh_db["t"].columns_dict == {"Id": int, "Name": str}
|
||||
assert fresh_db["t"].pks == ["Id"]
|
||||
assert fresh_db.table("t").columns_dict == {"Id": int, "Name": str}
|
||||
assert fresh_db.table("t").pks == ["Id"]
|
||||
|
||||
|
||||
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}
|
||||
)
|
||||
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 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):
|
||||
fresh_db["parent"].create({"Id": int}, pk="Id")
|
||||
fresh_db["child"].create(
|
||||
fresh_db.table("parent").create({"Id": int}, pk="Id")
|
||||
fresh_db.table("child").create(
|
||||
{"id": int, "Parent_ID": int},
|
||||
pk="id",
|
||||
foreign_keys=[("parent_id", "parent", "id")],
|
||||
)
|
||||
fks = fresh_db["child"].foreign_keys
|
||||
fks = fresh_db.table("child").foreign_keys
|
||||
assert fks == [
|
||||
ForeignKey(
|
||||
table="child", column="Parent_ID", other_table="parent", other_column="Id"
|
||||
|
|
|
|||
|
|
@ -1,8 +1,10 @@
|
|||
import sys
|
||||
|
||||
import pytest
|
||||
|
||||
from sqlite_utils import Database
|
||||
from sqlite_utils.db import TransactionError
|
||||
from sqlite_utils.utils import sqlite3
|
||||
import pytest
|
||||
import sys
|
||||
|
||||
|
||||
def test_recursive_triggers():
|
||||
|
|
@ -18,8 +20,8 @@ def test_recursive_triggers_off():
|
|||
def test_memory_name():
|
||||
db1 = Database(memory_name="shared")
|
||||
db2 = Database(memory_name="shared")
|
||||
db1["dogs"].insert({"name": "Cleo"})
|
||||
assert list(db2["dogs"].rows) == [{"name": "Cleo"}]
|
||||
db1.table("dogs").insert({"name": "Cleo"})
|
||||
assert list(db2.table("dogs").rows) == [{"name": "Cleo"}]
|
||||
|
||||
|
||||
def test_sqlite_version():
|
||||
|
|
@ -34,7 +36,7 @@ def test_sqlite_version():
|
|||
def test_database_context_manager(tmpdir):
|
||||
path = str(tmpdir / "test.db")
|
||||
with Database(path) as db:
|
||||
db["t"].insert({"id": 1})
|
||||
db.table("t").insert({"id": 1})
|
||||
# Raw writes commit automatically too
|
||||
db.execute("insert into t (id) values (2)")
|
||||
# An explicitly opened transaction left uncommitted on purpose:
|
||||
|
|
@ -45,7 +47,7 @@ def test_database_context_manager(tmpdir):
|
|||
db.execute("select 1")
|
||||
# ... and the open explicit transaction was rolled back, not committed
|
||||
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()
|
||||
|
||||
|
||||
|
|
@ -81,9 +83,34 @@ def test_autocommit_connections_are_rejected(tmpdir, autocommit):
|
|||
)
|
||||
def test_legacy_transaction_control_connection_is_accepted(tmpdir):
|
||||
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["t"].insert({"id": 1}, pk="id")
|
||||
assert [r["id"] for r in db["t"].rows] == [1]
|
||||
db.table("t").insert({"id": 1}, pk="id")
|
||||
assert [r["id"] for r in db.table("t").rows] == [1]
|
||||
db.close()
|
||||
|
||||
|
||||
def test_memory_attribute_for_memory_true():
|
||||
db = Database(memory=True)
|
||||
assert db.memory is True
|
||||
assert db.memory_name is None
|
||||
|
||||
|
||||
def test_memory_attribute_for_memory_name():
|
||||
db = Database(memory_name="shared_attr")
|
||||
assert db.memory is True
|
||||
assert db.memory_name == "shared_attr"
|
||||
|
||||
|
||||
def test_memory_attribute_for_memory_string_path():
|
||||
db = Database(":memory:")
|
||||
assert db.memory is True
|
||||
assert db.memory_name is None
|
||||
|
||||
|
||||
def test_memory_attribute_for_file_path(tmpdir):
|
||||
db = Database(str(tmpdir / "file.db"))
|
||||
assert db.memory is False
|
||||
assert db.memory_name is None
|
||||
|
|
|
|||
|
|
@ -1,17 +1,17 @@
|
|||
def test_insert_conversion(fresh_db):
|
||||
table = fresh_db["table"]
|
||||
table = fresh_db.table("table")
|
||||
table.insert({"foo": "bar"}, conversions={"foo": "upper(?)"})
|
||||
assert [{"foo": "BAR"}] == list(table.rows)
|
||||
|
||||
|
||||
def test_insert_all_conversion(fresh_db):
|
||||
table = fresh_db["table"]
|
||||
table = fresh_db.table("table")
|
||||
table.insert_all([{"foo": "bar"}], conversions={"foo": "upper(?)"})
|
||||
assert [{"foo": "BAR"}] == list(table.rows)
|
||||
|
||||
|
||||
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(?)"})
|
||||
assert [{"id": 1, "foo": "BAR"}] == list(table.rows)
|
||||
table.upsert(
|
||||
|
|
@ -21,7 +21,7 @@ def test_upsert_conversion(fresh_db):
|
|||
|
||||
|
||||
def test_upsert_all_conversion(fresh_db):
|
||||
table = fresh_db["table"]
|
||||
table = fresh_db.table("table")
|
||||
table.upsert_all(
|
||||
[{"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):
|
||||
table = fresh_db["table"]
|
||||
table = fresh_db.table("table")
|
||||
table.insert({"id": 5, "foo": "bar"}, pk="id")
|
||||
table.update(5, {"foo": "baz"}, conversions={"foo": "upper(?)"})
|
||||
assert [{"id": 5, "foo": "BAZ"}] == list(table.rows)
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
from sqlite_utils.db import BadMultiValues
|
||||
import pytest
|
||||
|
||||
from sqlite_utils.db import BadMultiValues
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"columns,fn,expected",
|
||||
|
|
@ -26,7 +27,7 @@ import pytest
|
|||
),
|
||||
)
|
||||
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.convert(columns, fn)
|
||||
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]))
|
||||
)
|
||||
def test_convert_where(fresh_db, where, where_args):
|
||||
table = fresh_db["table"]
|
||||
table = fresh_db.table("table")
|
||||
table.insert_all(
|
||||
[
|
||||
{"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):
|
||||
# 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}])
|
||||
assert table.get(1)["x"] == 0
|
||||
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):
|
||||
table = fresh_db["table"]
|
||||
table = fresh_db.table("table")
|
||||
table.insert({"title": "Mixed Case"})
|
||||
table.convert("title", lambda v: v.upper(), output="other", drop=drop)
|
||||
assert list(table.rows) == [expected]
|
||||
|
||||
|
||||
def test_convert_output_multiple_column_error(fresh_db):
|
||||
table = fresh_db["table"]
|
||||
table = fresh_db.table("table")
|
||||
with pytest.raises(ValueError) as excinfo:
|
||||
table.convert(["title", "other"], lambda v: v, output="out")
|
||||
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):
|
||||
table = fresh_db["table"]
|
||||
table = fresh_db.table("table")
|
||||
table.insert({"number": "123"})
|
||||
table.convert("number", lambda v: v, output="other", output_type=type, drop=True)
|
||||
assert list(table.rows) == [expected]
|
||||
|
||||
|
||||
def test_convert_multi(fresh_db):
|
||||
table = fresh_db["table"]
|
||||
table = fresh_db.table("table")
|
||||
table.insert({"title": "Mixed Case"})
|
||||
table.convert(
|
||||
"title",
|
||||
|
|
@ -122,7 +123,7 @@ def test_convert_multi(fresh_db):
|
|||
|
||||
|
||||
def test_convert_multi_where(fresh_db):
|
||||
table = fresh_db["table"]
|
||||
table = fresh_db.table("table")
|
||||
table.insert_all(
|
||||
[
|
||||
{"id": 1, "title": "One"},
|
||||
|
|
@ -144,14 +145,14 @@ def test_convert_multi_where(fresh_db):
|
|||
|
||||
|
||||
def test_convert_multi_exception(fresh_db):
|
||||
table = fresh_db["table"]
|
||||
table = fresh_db.table("table")
|
||||
table.insert({"title": "Mixed Case"})
|
||||
with pytest.raises(BadMultiValues):
|
||||
table.convert("title", lambda v: v.upper(), multi=True)
|
||||
|
||||
|
||||
def test_convert_repeated(fresh_db):
|
||||
table = fresh_db["table"]
|
||||
table = fresh_db.table("table")
|
||||
col = "num"
|
||||
table.insert({col: 1})
|
||||
table.convert(col, lambda x: x * 2)
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
243
tests/test_create_table_parser.py
Normal file
243
tests/test_create_table_parser.py
Normal 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}'"
|
||||
|
|
@ -1,4 +1,5 @@
|
|||
import pytest
|
||||
|
||||
from sqlite_utils.utils import OperationalError
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -31,10 +31,10 @@ EXAMPLES = [
|
|||
|
||||
@pytest.mark.parametrize("column_def,initial_value,expected_value", EXAMPLES)
|
||||
def test_quote_default_value(fresh_db, column_def, initial_value, expected_value):
|
||||
fresh_db.execute("create table foo (col {})".format(column_def))
|
||||
assert initial_value == fresh_db["foo"].columns[0].default_value
|
||||
fresh_db.execute(f"create table foo (col {column_def})")
|
||||
assert initial_value == fresh_db.table("foo").columns[0].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({})
|
||||
|
||||
rows = list(table.rows)
|
||||
|
|
|
|||
|
|
@ -2,15 +2,15 @@ import sqlite_utils
|
|||
|
||||
|
||||
def test_delete_rowid_table(fresh_db):
|
||||
table = fresh_db["table"]
|
||||
table.insert({"foo": 1}).last_pk
|
||||
table = fresh_db.table("table")
|
||||
table.insert({"foo": 1})
|
||||
rowid = table.insert({"foo": 2}).last_pk
|
||||
table.delete(rowid)
|
||||
assert [{"foo": 1}] == list(table.rows)
|
||||
|
||||
|
||||
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": 2}, pk="id")
|
||||
table.delete(1)
|
||||
|
|
@ -18,7 +18,7 @@ def test_delete_pk_table(fresh_db):
|
|||
|
||||
|
||||
def test_delete_where(fresh_db):
|
||||
table = fresh_db["table"]
|
||||
table = fresh_db.table("table")
|
||||
for i in range(1, 11):
|
||||
table.insert({"id": i}, pk="id")
|
||||
assert table.count == 10
|
||||
|
|
@ -27,7 +27,7 @@ def test_delete_where(fresh_db):
|
|||
|
||||
|
||||
def test_delete_where_all(fresh_db):
|
||||
table = fresh_db["table"]
|
||||
table = fresh_db.table("table")
|
||||
for i in range(1, 11):
|
||||
table.insert({"id": i}, pk="id")
|
||||
assert table.count == 10
|
||||
|
|
@ -38,27 +38,27 @@ def test_delete_where_all(fresh_db):
|
|||
def test_delete_where_commits(tmpdir):
|
||||
path = str(tmpdir / "test.db")
|
||||
db = sqlite_utils.Database(path)
|
||||
db["table"].insert_all([{"id": i} for i in range(5)], pk="id")
|
||||
db["table"].delete_where("id > ?", [2])
|
||||
db.table("table").insert_all([{"id": i} for i in range(5)], pk="id")
|
||||
db.table("table").delete_where("id > ?", [2])
|
||||
# The connection must not be left inside an open transaction,
|
||||
# otherwise subsequent atomic() blocks never commit either
|
||||
assert not db.conn.in_transaction
|
||||
db["table"].insert({"id": 100})
|
||||
db.table("table").insert({"id": 100})
|
||||
db.close()
|
||||
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()
|
||||
|
||||
|
||||
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.create_index(["i"], analyze=True)
|
||||
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"}
|
||||
]
|
||||
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"}
|
||||
]
|
||||
|
|
|
|||
|
|
@ -1,8 +1,10 @@
|
|||
from click.testing import CliRunner
|
||||
from sqlite_utils import cli, recipes
|
||||
from pathlib import Path
|
||||
import pytest
|
||||
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"
|
||||
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())
|
||||
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():
|
||||
|
|
|
|||
|
|
@ -1,7 +1,9 @@
|
|||
from sqlite_utils.db import NoTable
|
||||
import datetime
|
||||
|
||||
import pytest
|
||||
|
||||
from sqlite_utils.db import NoTable
|
||||
|
||||
|
||||
def test_duplicate(fresh_db):
|
||||
# Create table using native Sqlite statement:
|
||||
|
|
@ -12,7 +14,7 @@ def test_duplicate(fresh_db):
|
|||
"bool_col" INTEGER,
|
||||
"datetime_col" TEXT)""")
|
||||
# Insert one row of mock data:
|
||||
dt = datetime.datetime.now()
|
||||
dt = datetime.datetime.now(datetime.timezone.utc)
|
||||
data = {
|
||||
"text_col": "Cleo",
|
||||
"real_col": 3.14,
|
||||
|
|
@ -20,7 +22,7 @@ def test_duplicate(fresh_db):
|
|||
"bool_col": True,
|
||||
"datetime_col": str(dt),
|
||||
}
|
||||
table1 = fresh_db["table1"]
|
||||
table1 = fresh_db.table("table1")
|
||||
row_id = table1.insert(data).last_rowid
|
||||
# Duplicate table:
|
||||
table2 = table1.duplicate("table2")
|
||||
|
|
@ -38,4 +40,4 @@ def test_duplicate(fresh_db):
|
|||
|
||||
def test_duplicate_fails_if_table_does_not_exist(fresh_db):
|
||||
with pytest.raises(NoTable):
|
||||
fresh_db["not_a_table"].duplicate("duplicated")
|
||||
fresh_db.table("not_a_table").duplicate("duplicated")
|
||||
|
|
|
|||
|
|
@ -1,14 +1,14 @@
|
|||
from sqlite_utils import Database
|
||||
from sqlite_utils import cli
|
||||
from click.testing import CliRunner
|
||||
import pytest
|
||||
from click.testing import CliRunner
|
||||
|
||||
from sqlite_utils import Database, cli
|
||||
|
||||
|
||||
def test_enable_counts_specific_table(fresh_db):
|
||||
foo = fresh_db["foo"]
|
||||
foo = fresh_db.table("foo")
|
||||
assert fresh_db.table_names() == []
|
||||
for i in range(10):
|
||||
foo.insert({"name": "item {}".format(i)})
|
||||
foo.insert({"name": f"item {i}"})
|
||||
assert fresh_db.table_names() == ["foo"]
|
||||
assert foo.count == 10
|
||||
# Now enable counts
|
||||
|
|
@ -41,24 +41,24 @@ def test_enable_counts_specific_table(fresh_db):
|
|||
),
|
||||
}
|
||||
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
|
||||
for i in range(5):
|
||||
foo.insert({"name": "item {}".format(10 + i)})
|
||||
foo.insert({"name": f"item {10 + i}"})
|
||||
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
|
||||
foo.delete_where("rowid < 7")
|
||||
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()
|
||||
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):
|
||||
foo = fresh_db["foo"]
|
||||
bar = fresh_db["bar"]
|
||||
foo = fresh_db.table("foo")
|
||||
bar = fresh_db.table("bar")
|
||||
foo.insert({"name": "Cleo"})
|
||||
bar.insert({"name": "Cleo"})
|
||||
foo.enable_fts(["name"])
|
||||
|
|
@ -73,7 +73,7 @@ def test_enable_counts_all_tables(fresh_db):
|
|||
"foo_fts_config",
|
||||
"_counts",
|
||||
}
|
||||
assert list(fresh_db["_counts"].rows) == [
|
||||
assert list(fresh_db.table("_counts").rows) == [
|
||||
{"count": 1, "table": "foo"},
|
||||
{"count": 1, "table": "bar"},
|
||||
{"count": 3, "table": "foo_fts_data"},
|
||||
|
|
@ -87,10 +87,10 @@ def test_enable_counts_all_tables(fresh_db):
|
|||
def counts_db_path(tmpdir):
|
||||
path = str(tmpdir / "test.db")
|
||||
db = Database(path)
|
||||
db["foo"].insert({"name": "bar"})
|
||||
db["bar"].insert({"name": "bar"})
|
||||
db["bar"].insert({"name": "bar"})
|
||||
db["baz"].insert({"name": "bar"})
|
||||
db.table("foo").insert({"name": "bar"})
|
||||
db.table("bar").insert({"name": "bar"})
|
||||
db.table("bar").insert({"name": "bar"})
|
||||
db.table("baz").insert({"name": "bar"})
|
||||
return path
|
||||
|
||||
|
||||
|
|
@ -163,25 +163,25 @@ def test_uses_counts_after_enable_counts(counts_db_path):
|
|||
|
||||
def test_reset_counts(counts_db_path):
|
||||
db = Database(counts_db_path)
|
||||
db["foo"].enable_counts()
|
||||
db["bar"].enable_counts()
|
||||
db.table("foo").enable_counts()
|
||||
db.table("bar").enable_counts()
|
||||
assert db.cached_counts() == {"foo": 1, "bar": 2}
|
||||
# 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["foo"].count == 3
|
||||
assert db.table("foo").count == 3
|
||||
# Reset them
|
||||
db.reset_counts()
|
||||
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):
|
||||
db = Database(counts_db_path)
|
||||
db["foo"].enable_counts()
|
||||
db["bar"].enable_counts()
|
||||
db.table("foo").enable_counts()
|
||||
db.table("bar").enable_counts()
|
||||
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])
|
||||
assert result.exit_code == 0
|
||||
assert db.cached_counts() == {"foo": 1, "bar": 2}
|
||||
|
|
|
|||
|
|
@ -1,19 +1,22 @@
|
|||
from sqlite_utils.db import InvalidColumns
|
||||
import itertools
|
||||
|
||||
import pytest
|
||||
|
||||
from sqlite_utils import ANY
|
||||
from sqlite_utils.db import InvalidColumns
|
||||
|
||||
|
||||
@pytest.mark.parametrize("table", [None, "Species"])
|
||||
@pytest.mark.parametrize("fk_column", [None, "species"])
|
||||
def test_extract_single_column(fresh_db, table, fk_column):
|
||||
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"])
|
||||
fresh_db["tree"].insert_all(
|
||||
fresh_db.table("tree").insert_all(
|
||||
(
|
||||
{
|
||||
"id": i,
|
||||
"name": "Tree {}".format(i),
|
||||
"name": f"Tree {i}",
|
||||
"species": next(iter_species),
|
||||
"end": 1,
|
||||
}
|
||||
|
|
@ -21,28 +24,27 @@ def test_extract_single_column(fresh_db, table, fk_column):
|
|||
),
|
||||
pk="id",
|
||||
)
|
||||
fresh_db["tree"].extract("species", table=table, fk_column=fk_column)
|
||||
assert fresh_db["tree"].schema == (
|
||||
fresh_db.table("tree").extract("species", table=table, fk_column=fk_column)
|
||||
assert fresh_db.table("tree").schema == (
|
||||
'CREATE TABLE "tree" (\n'
|
||||
' "id" INTEGER PRIMARY KEY,\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'
|
||||
+ ")"
|
||||
)
|
||||
assert fresh_db[expected_table].schema == (
|
||||
'CREATE TABLE "{}" (\n'.format(expected_table)
|
||||
+ ' "id" INTEGER PRIMARY KEY,\n'
|
||||
assert fresh_db.table(expected_table).schema == (
|
||||
f'CREATE TABLE "{expected_table}" (\n' + ' "id" INTEGER PRIMARY KEY,\n'
|
||||
' "species" TEXT\n'
|
||||
")"
|
||||
)
|
||||
assert list(fresh_db[expected_table].rows) == [
|
||||
assert list(fresh_db.table(expected_table).rows) == [
|
||||
{"id": 1, "species": "Palm"},
|
||||
{"id": 2, "species": "Spruce"},
|
||||
{"id": 3, "species": "Mangrove"},
|
||||
{"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": 2, "name": "Tree 2", expected_fk: 2, "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):
|
||||
iter_common = itertools.cycle(["Palm", "Spruce", "Mangrove", "Oak"])
|
||||
iter_latin = itertools.cycle(["Arecaceae", "Picea", "Rhizophora", "Quercus"])
|
||||
fresh_db["tree"].insert_all(
|
||||
fresh_db.table("tree").insert_all(
|
||||
(
|
||||
{
|
||||
"id": i,
|
||||
"name": "Tree {}".format(i),
|
||||
"name": f"Tree {i}",
|
||||
"common_name": next(iter_common),
|
||||
"latin_name": next(iter_latin),
|
||||
}
|
||||
|
|
@ -66,30 +68,30 @@ def test_extract_multiple_columns_with_rename(fresh_db):
|
|||
pk="id",
|
||||
)
|
||||
|
||||
fresh_db["tree"].extract(
|
||||
fresh_db.table("tree").extract(
|
||||
["common_name", "latin_name"], rename={"common_name": "name"}
|
||||
)
|
||||
assert fresh_db["tree"].schema == (
|
||||
assert fresh_db.table("tree").schema == (
|
||||
'CREATE TABLE "tree" (\n'
|
||||
' "id" INTEGER PRIMARY KEY,\n'
|
||||
' "name" TEXT,\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'
|
||||
' "id" INTEGER PRIMARY KEY,\n'
|
||||
' "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": "Spruce", "id": 2, "latin_name": "Picea"},
|
||||
{"name": "Mangrove", "id": 3, "latin_name": "Rhizophora"},
|
||||
{"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": 2, "name": "Tree 2", "common_name_latin_name_id": 2},
|
||||
{"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):
|
||||
fresh_db["tree"].insert(
|
||||
fresh_db.table("tree").insert(
|
||||
{
|
||||
"id": 1,
|
||||
"name": "Tree 1",
|
||||
|
|
@ -108,19 +110,19 @@ def test_extract_invalid_columns(fresh_db):
|
|||
pk="id",
|
||||
)
|
||||
with pytest.raises(InvalidColumns):
|
||||
fresh_db["tree"].extract(["bad_column"])
|
||||
fresh_db.table("tree").extract(["bad_column"])
|
||||
|
||||
|
||||
def test_extract_rowid_table(fresh_db):
|
||||
fresh_db["tree"].insert(
|
||||
fresh_db.table("tree").insert(
|
||||
{
|
||||
"name": "Tree 1",
|
||||
"common_name": "Palm",
|
||||
"latin_name": "Arecaceae",
|
||||
}
|
||||
)
|
||||
fresh_db["tree"].extract(["common_name", "latin_name"])
|
||||
assert fresh_db["tree"].schema == (
|
||||
fresh_db.table("tree").extract(["common_name", "latin_name"])
|
||||
assert fresh_db.table("tree").schema == (
|
||||
'CREATE TABLE "tree" (\n'
|
||||
' "name" TEXT,\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):
|
||||
fresh_db["species"].insert({"id": 1, "name": "Wolf"}, pk="id")
|
||||
fresh_db["sightings"].insert({"id": 10, "species": "Wolf"}, pk="id")
|
||||
fresh_db["individuals"].insert(
|
||||
fresh_db.table("species").insert({"id": 1, "name": "Wolf"}, pk="id")
|
||||
fresh_db.table("sightings").insert({"id": 10, "species": "Wolf"}, pk="id")
|
||||
fresh_db.table("individuals").insert(
|
||||
{"id": 10, "name": "Terriana", "species": "Fox"}, pk="id"
|
||||
)
|
||||
fresh_db["sightings"].extract("species", rename={"species": "name"})
|
||||
fresh_db["individuals"].extract("species", rename={"species": "name"})
|
||||
assert fresh_db["sightings"].schema == (
|
||||
fresh_db.table("sightings").extract("species", rename={"species": "name"})
|
||||
fresh_db.table("individuals").extract("species", rename={"species": "name"})
|
||||
assert fresh_db.table("sightings").schema == (
|
||||
'CREATE TABLE "sightings" (\n'
|
||||
' "id" INTEGER PRIMARY KEY,\n'
|
||||
' "species_id" INTEGER REFERENCES "species"("id")\n'
|
||||
")"
|
||||
)
|
||||
assert fresh_db["individuals"].schema == (
|
||||
assert fresh_db.table("individuals").schema == (
|
||||
'CREATE TABLE "individuals" (\n'
|
||||
' "id" INTEGER PRIMARY KEY,\n'
|
||||
' "name" TEXT,\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": 2, "name": "Fox"},
|
||||
]
|
||||
|
||||
|
||||
def test_extract_error_on_incompatible_existing_lookup_table(fresh_db):
|
||||
fresh_db["species"].insert({"id": 1})
|
||||
fresh_db["tree"].insert({"name": "Tree 1", "common_name": "Palm"})
|
||||
fresh_db.table("species").insert({"id": 1})
|
||||
fresh_db.table("tree").insert({"name": "Tree 1", "common_name": "Palm"})
|
||||
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
|
||||
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):
|
||||
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):
|
||||
fresh_db["listens"].insert_all(
|
||||
fresh_db.table("listens").insert_all(
|
||||
[
|
||||
{"id": 1, "track_title": "foo", "album_title": "bar"},
|
||||
{"id": 2, "track_title": "baz", "album_title": None},
|
||||
],
|
||||
pk="id",
|
||||
)
|
||||
fresh_db["listens"].extract(
|
||||
fresh_db.table("listens").extract(
|
||||
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": 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"},
|
||||
]
|
||||
|
||||
|
||||
def test_extract_null_values_single_column(fresh_db):
|
||||
# https://github.com/simonw/sqlite-utils/issues/186
|
||||
fresh_db["species"].insert({"id": 1, "species": "Wolf"}, pk="id")
|
||||
fresh_db["individuals"].insert_all(
|
||||
fresh_db.table("species").insert({"id": 1, "species": "Wolf"}, pk="id")
|
||||
fresh_db.table("individuals").insert_all(
|
||||
[
|
||||
{"id": 10, "name": "Terriana", "species": "Fox"},
|
||||
{"id": 11, "name": "Spenidorm", "species": None},
|
||||
|
|
@ -209,13 +211,13 @@ def test_extract_null_values_single_column(fresh_db):
|
|||
],
|
||||
pk="id",
|
||||
)
|
||||
fresh_db["individuals"].extract("species")
|
||||
fresh_db.table("individuals").extract("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": 2, "species": "Fox"},
|
||||
]
|
||||
assert list(fresh_db["individuals"].rows) == [
|
||||
assert list(fresh_db.table("individuals").rows) == [
|
||||
{"id": 10, "name": "Terriana", "species_id": 2},
|
||||
{"id": 11, "name": "Spenidorm", "species_id": None},
|
||||
{"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):
|
||||
# A row should be extracted if at least one column is not null -
|
||||
# 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": 2, "title": "title two", "creator": None, "year": 2019},
|
||||
|
|
@ -236,14 +238,14 @@ def test_extract_null_values_multiple_columns(fresh_db):
|
|||
],
|
||||
pk="id",
|
||||
)
|
||||
fresh_db["circulation"].extract(
|
||||
fresh_db.table("circulation").extract(
|
||||
["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": 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": 2, "book_id": 2, "year": 2019},
|
||||
{"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):
|
||||
# Even if the lookup table already contains an all-null row, rows where
|
||||
# every extracted column is null should keep a null foreign key
|
||||
fresh_db["species"].insert({"id": 1, "species": None}, pk="id")
|
||||
fresh_db["individuals"].insert_all(
|
||||
fresh_db.table("species").insert({"id": 1, "species": None}, pk="id")
|
||||
fresh_db.table("individuals").insert_all(
|
||||
[
|
||||
{"id": 10, "name": "Terriana", "species": "Fox"},
|
||||
{"id": 11, "name": "Spenidorm", "species": None},
|
||||
],
|
||||
pk="id",
|
||||
)
|
||||
fresh_db["individuals"].extract("species")
|
||||
assert list(fresh_db["species"].rows) == [
|
||||
fresh_db.table("individuals").extract("species")
|
||||
assert list(fresh_db.table("species").rows) == [
|
||||
{"id": 1, "species": None},
|
||||
{"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": 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
|
||||
# table - extracting a second table into the same lookup previously
|
||||
# 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": 2, "species": "Oak", "common": "Oak"},
|
||||
],
|
||||
pk="id",
|
||||
)
|
||||
fresh_db["t2"].insert_all([{"id": 1, "species": None, "common": "X"}], pk="id")
|
||||
fresh_db["t1"].extract(["species", "common"], table="lk")
|
||||
fresh_db["t2"].extract(["species", "common"], table="lk")
|
||||
assert fresh_db["lk"].count == 2
|
||||
fresh_db.table("t2").insert_all(
|
||||
[{"id": 1, "species": None, "common": "X"}], pk="id"
|
||||
)
|
||||
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
|
||||
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]
|
||||
|
|
@ -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):
|
||||
# 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["t2"].insert_all([{"id": 1, "species": "Oak"}], pk="id")
|
||||
fresh_db["t1"].extract(["species"], table="lk")
|
||||
fresh_db["t2"].extract(["species"], table="lk")
|
||||
assert fresh_db["lk"].count == 1
|
||||
fresh_db.table("t1").insert_all([{"id": 1, "species": "Oak"}], pk="id")
|
||||
fresh_db.table("t2").insert_all([{"id": 1, "species": "Oak"}], pk="id")
|
||||
fresh_db.table("t1").extract(["species"], table="lk")
|
||||
fresh_db.table("t2").extract(["species"], table="lk")
|
||||
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",
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,13 +1,14 @@
|
|||
from sqlite_utils.db import Index
|
||||
import pytest
|
||||
|
||||
from sqlite_utils.db import Index
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"kwargs,expected_table",
|
||||
[
|
||||
(dict(extracts={"species_id": "Species"}), "Species"),
|
||||
(dict(extracts=["species_id"]), "species_id"),
|
||||
(dict(extracts=("species_id",)), "species_id"),
|
||||
({"extracts": {"species_id": "Species"}}, "Species"),
|
||||
({"extracts": ["species_id"]}, "species_id"),
|
||||
({"extracts": ("species_id",)}, "species_id"),
|
||||
],
|
||||
)
|
||||
@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
|
||||
assert {expected_table, "Trees"} == set(fresh_db.table_names())
|
||||
assert (
|
||||
'CREATE TABLE "{}" (\n "id" INTEGER PRIMARY KEY,\n "value" TEXT\n)'.format(
|
||||
expected_table
|
||||
)
|
||||
== fresh_db[expected_table].schema
|
||||
f'CREATE TABLE "{expected_table}" (\n "id" INTEGER PRIMARY KEY,\n "value" TEXT\n)'
|
||||
== fresh_db.table(expected_table).schema
|
||||
)
|
||||
assert (
|
||||
'CREATE TABLE "Trees" (\n "id" INTEGER,\n "species_id" INTEGER REFERENCES "{}"("id")\n)'.format(
|
||||
expected_table
|
||||
)
|
||||
== fresh_db["Trees"].schema
|
||||
f'CREATE TABLE "Trees" (\n "id" INTEGER,\n "species_id" INTEGER REFERENCES "{expected_table}"("id")\n)'
|
||||
== fresh_db.table("Trees").schema
|
||||
)
|
||||
# Should have a foreign key reference
|
||||
assert len(fresh_db["Trees"].foreign_keys) == 1
|
||||
fk = fresh_db["Trees"].foreign_keys[0]
|
||||
assert len(fresh_db.table("Trees").foreign_keys) == 1
|
||||
fk = fresh_db.table("Trees").foreign_keys[0]
|
||||
assert fk.table == "Trees"
|
||||
assert fk.column == "species_id"
|
||||
|
||||
|
|
@ -51,28 +48,28 @@ def test_extracts(fresh_db, kwargs, expected_table, use_table_factory):
|
|||
assert [
|
||||
Index(
|
||||
seq=0,
|
||||
name="idx_{}_value".format(expected_table),
|
||||
name=f"idx_{expected_table}_value",
|
||||
unique=1,
|
||||
origin="c",
|
||||
partial=0,
|
||||
columns=["value"],
|
||||
)
|
||||
] == fresh_db[expected_table].indexes
|
||||
] == fresh_db.table(expected_table).indexes
|
||||
# Finally, check the rows
|
||||
assert [{"id": 1, "value": "Oak"}, {"id": 2, "value": "Palm"}] == list(
|
||||
fresh_db[expected_table].rows
|
||||
fresh_db.table(expected_table).rows
|
||||
)
|
||||
assert [
|
||||
{"id": 1, "species_id": 1},
|
||||
{"id": 2, "species_id": 1},
|
||||
{"id": 3, "species_id": 2},
|
||||
] == list(fresh_db["Trees"].rows)
|
||||
] == list(fresh_db.table("Trees").rows)
|
||||
|
||||
|
||||
def test_extracts_null_values(fresh_db):
|
||||
# https://github.com/simonw/sqlite-utils/issues/186
|
||||
# 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": 2, "species_id": None},
|
||||
|
|
@ -81,11 +78,11 @@ def test_extracts_null_values(fresh_db):
|
|||
],
|
||||
extracts={"species_id": "Species"},
|
||||
)
|
||||
assert list(fresh_db["Species"].rows) == [
|
||||
assert list(fresh_db.table("Species").rows) == [
|
||||
{"id": 1, "value": "Oak"},
|
||||
{"id": 2, "value": "Palm"},
|
||||
]
|
||||
assert list(fresh_db["Trees"].rows) == [
|
||||
assert list(fresh_db.table("Trees").rows) == [
|
||||
{"id": 1, "species_id": 1},
|
||||
{"id": 2, "species_id": None},
|
||||
{"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):
|
||||
# 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"],
|
||||
[1, "Oak"],
|
||||
|
|
@ -105,11 +102,11 @@ def test_extracts_null_values_list_mode(fresh_db):
|
|||
],
|
||||
extracts={"species_id": "Species"},
|
||||
)
|
||||
assert list(fresh_db["Species"].rows) == [
|
||||
assert list(fresh_db.table("Species").rows) == [
|
||||
{"id": 1, "value": "Oak"},
|
||||
{"id": 2, "value": "Palm"},
|
||||
]
|
||||
assert list(fresh_db["Trees"].rows) == [
|
||||
assert list(fresh_db.table("Trees").rows) == [
|
||||
{"id": 1, "species_id": 1},
|
||||
{"id": 2, "species_id": None},
|
||||
{"id": 3, "species_id": 2},
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
"""Tests for compound (multi-column) foreign keys - issue #594."""
|
||||
|
||||
import pytest
|
||||
|
||||
from sqlite_utils import Database
|
||||
from sqlite_utils.db import AlterError, ForeignKey
|
||||
from sqlite_utils.utils import sqlite3
|
||||
|
|
@ -31,7 +32,7 @@ def 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
|
||||
fk = fks[0]
|
||||
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):
|
||||
fresh_db["authors"].insert({"id": 1, "name": "Sally"}, pk="id")
|
||||
fresh_db["books"].insert({"title": "Hedgehogs", "author_id": 1})
|
||||
fresh_db["books"].add_foreign_key("author_id", "authors", "id")
|
||||
fk = fresh_db["books"].foreign_keys[0]
|
||||
fresh_db.table("authors").insert({"id": 1, "name": "Sally"}, pk="id")
|
||||
fresh_db.table("books").insert({"title": "Hedgehogs", "author_id": 1})
|
||||
fresh_db.table("books").add_foreign_key("author_id", "authors", "id")
|
||||
fk = fresh_db.table("books").foreign_keys[0]
|
||||
assert fk.is_compound is False
|
||||
assert fk.column == "author_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):
|
||||
# Clean break in 4.0: ForeignKey is a dataclass, not a namedtuple, so the
|
||||
# old tuple unpacking and indexing patterns now fail hard.
|
||||
fresh_db["authors"].insert({"id": 1, "name": "Sally"}, pk="id")
|
||||
fresh_db["books"].insert({"title": "Hedgehogs", "author_id": 1})
|
||||
fresh_db["books"].add_foreign_key("author_id", "authors", "id")
|
||||
fk = fresh_db["books"].foreign_keys[0]
|
||||
fresh_db.table("authors").insert({"id": 1, "name": "Sally"}, pk="id")
|
||||
fresh_db.table("books").insert({"title": "Hedgehogs", "author_id": 1})
|
||||
fresh_db.table("books").add_foreign_key("author_id", "authors", "id")
|
||||
fk = fresh_db.table("books").foreign_keys[0]
|
||||
with pytest.raises(TypeError):
|
||||
table, column, other_table, other_column = fk
|
||||
_table, _column, _other_table, _other_column = fk
|
||||
with pytest.raises(TypeError):
|
||||
fk[0]
|
||||
|
||||
|
||||
def test_foreign_keys_are_sortable(fresh_db):
|
||||
fresh_db["authors"].insert({"id": 1, "name": "Sally"}, pk="id")
|
||||
fresh_db["categories"].insert({"id": 1, "name": "Wildlife"}, pk="id")
|
||||
fresh_db["books"].insert({"title": "Hedgehogs", "author_id": 1, "category_id": 1})
|
||||
fresh_db.table("authors").insert({"id": 1, "name": "Sally"}, pk="id")
|
||||
fresh_db.table("categories").insert({"id": 1, "name": "Wildlife"}, pk="id")
|
||||
fresh_db.table("books").insert(
|
||||
{"title": "Hedgehogs", "author_id": 1, "category_id": 1}
|
||||
)
|
||||
fresh_db.add_foreign_keys(
|
||||
[
|
||||
("books", "author_id", "authors", "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[1].column == "category_id"
|
||||
|
||||
|
|
@ -104,7 +107,7 @@ def test_mixed_compound_and_single_foreign_keys_are_sortable():
|
|||
REFERENCES departments(campus_name, dept_code)
|
||||
);
|
||||
""")
|
||||
fks = db["courses"].foreign_keys
|
||||
fks = db.table("courses").foreign_keys
|
||||
assert len(fks) == 2
|
||||
assert {fk.is_compound for fk in fks} == {True, False}
|
||||
fks_sorted = sorted(fks)
|
||||
|
|
@ -162,8 +165,8 @@ def test_create_table_with_compound_foreign_key(departments_db, foreign_keys):
|
|||
pk="course_code",
|
||||
foreign_keys=foreign_keys,
|
||||
)
|
||||
assert departments_db["courses"].schema == EXPECTED_COURSES_SCHEMA
|
||||
fks = departments_db["courses"].foreign_keys
|
||||
assert departments_db.table("courses").schema == EXPECTED_COURSES_SCHEMA
|
||||
fks = departments_db.table("courses").foreign_keys
|
||||
assert len(fks) == 1
|
||||
fk = fks[0]
|
||||
assert fk.is_compound is True
|
||||
|
|
@ -180,10 +183,10 @@ def test_create_table_compound_foreign_key_enforced(departments_db):
|
|||
pk="course_code",
|
||||
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"}
|
||||
)
|
||||
departments_db["courses"].insert(
|
||||
departments_db.table("courses").insert(
|
||||
{"course_code": "CS101", "campus_name": "Berkeley", "dept_code": "CS"}
|
||||
)
|
||||
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):
|
||||
compound_db["courses"].transform(rename={"course_name": "title"})
|
||||
fks = compound_db["courses"].foreign_keys
|
||||
compound_db.table("courses").transform(rename={"course_name": "title"})
|
||||
fks = compound_db.table("courses").foreign_keys
|
||||
assert len(fks) == 1
|
||||
fk = fks[0]
|
||||
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):
|
||||
compound_db["courses"].transform(rename={"campus_name": "campus"})
|
||||
fks = compound_db["courses"].foreign_keys
|
||||
compound_db.table("courses").transform(rename={"campus_name": "campus"})
|
||||
fks = compound_db.table("courses").foreign_keys
|
||||
assert len(fks) == 1
|
||||
fk = fks[0]
|
||||
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):
|
||||
# Matches single-column behavior: dropping the column silently
|
||||
# drops the foreign key that used it
|
||||
compound_db["courses"].transform(drop={"dept_code"})
|
||||
assert compound_db["courses"].foreign_keys == []
|
||||
assert "FOREIGN KEY" not in compound_db["courses"].schema
|
||||
compound_db.table("courses").transform(drop={"dept_code"})
|
||||
assert compound_db.table("courses").foreign_keys == []
|
||||
assert "FOREIGN KEY" not in compound_db.table("courses").schema
|
||||
|
||||
|
||||
@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):
|
||||
compound_db["courses"].transform(drop_foreign_keys=drop_foreign_keys)
|
||||
assert compound_db["courses"].foreign_keys == []
|
||||
compound_db.table("courses").transform(drop_foreign_keys=drop_foreign_keys)
|
||||
assert compound_db.table("courses").foreign_keys == []
|
||||
# The columns themselves survive
|
||||
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):
|
||||
t = courses_db["courses"].add_foreign_key(
|
||||
t = courses_db.table("courses").add_foreign_key(
|
||||
("campus_name", "dept_code"), "departments", ("campus_name", "dept_code")
|
||||
)
|
||||
# Returns self
|
||||
assert t.name == "courses"
|
||||
fks = courses_db["courses"].foreign_keys
|
||||
fks = courses_db.table("courses").foreign_keys
|
||||
assert len(fks) == 1
|
||||
fk = fks[0]
|
||||
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):
|
||||
# Lists work here too, though tuples are the documented form
|
||||
courses_db["courses"].add_foreign_key(["campus_name", "dept_code"], "departments")
|
||||
fk = courses_db["courses"].foreign_keys[0]
|
||||
courses_db.table("courses").add_foreign_key(
|
||||
["campus_name", "dept_code"], "departments"
|
||||
)
|
||||
fk = courses_db.table("courses").foreign_keys[0]
|
||||
assert fk.other_columns == ("campus_name", "dept_code")
|
||||
|
||||
|
||||
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:
|
||||
courses_db["courses"].add_foreign_key(
|
||||
courses_db.table("courses").add_foreign_key(
|
||||
("campus_name", "dept_code"), "departments"
|
||||
)
|
||||
assert "already exists" in ex.value.args[0]
|
||||
# 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
|
||||
)
|
||||
|
||||
|
||||
def test_add_compound_foreign_key_error_if_column_missing(courses_db):
|
||||
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):
|
||||
|
|
@ -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.columns == ("campus_name", "dept_code")
|
||||
|
||||
|
||||
def test_index_foreign_keys_compound_creates_composite_index(compound_db):
|
||||
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
|
||||
# No separate single-column indexes for the members
|
||||
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
|
||||
);
|
||||
""")
|
||||
fk = db["books"].foreign_keys[0]
|
||||
fk = db.table("books").foreign_keys[0]
|
||||
assert fk.on_delete == "CASCADE"
|
||||
assert fk.on_update == "RESTRICT"
|
||||
|
||||
|
||||
def test_foreign_key_on_delete_defaults_to_no_action(fresh_db):
|
||||
fresh_db["authors"].insert({"id": 1}, pk="id")
|
||||
fresh_db["books"].insert({"id": 1, "author_id": 1}, pk="id")
|
||||
fresh_db["books"].add_foreign_key("author_id", "authors", "id")
|
||||
fk = fresh_db["books"].foreign_keys[0]
|
||||
fresh_db.table("authors").insert({"id": 1}, pk="id")
|
||||
fresh_db.table("books").insert({"id": 1, "author_id": 1}, pk="id")
|
||||
fresh_db.table("books").add_foreign_key("author_id", "authors", "id")
|
||||
fk = fresh_db.table("books").foreign_keys[0]
|
||||
assert fk.on_delete == "NO ACTION"
|
||||
assert fk.on_update == "NO ACTION"
|
||||
|
||||
|
||||
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(
|
||||
"books",
|
||||
{"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 fresh_db["books"].foreign_keys[0].on_delete == "CASCADE"
|
||||
assert "ON DELETE CASCADE" in fresh_db.table("books").schema
|
||||
assert fresh_db.table("books").foreign_keys[0].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
|
||||
);
|
||||
""")
|
||||
db["books"].transform(rename={"title": "book_title"})
|
||||
fk = db["books"].foreign_keys[0]
|
||||
db.table("books").transform(rename={"title": "book_title"})
|
||||
fk = db.table("books").foreign_keys[0]
|
||||
assert fk.on_delete == "CASCADE"
|
||||
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():
|
||||
|
|
@ -405,11 +414,11 @@ def test_transform_preserves_compound_foreign_key_on_delete():
|
|||
REFERENCES departments(campus_name, dept_code) ON DELETE CASCADE
|
||||
);
|
||||
""")
|
||||
db["courses"].transform(rename={"course_code": "code"})
|
||||
fk = db["courses"].foreign_keys[0]
|
||||
db.table("courses").transform(rename={"course_code": "code"})
|
||||
fk = db.table("courses").foreign_keys[0]
|
||||
assert fk.is_compound is True
|
||||
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():
|
||||
|
|
@ -423,7 +432,7 @@ def test_implicit_primary_key_reference_is_resolved():
|
|||
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.other_column == "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
|
||||
);
|
||||
""")
|
||||
fk = db["courses"].foreign_keys[0]
|
||||
fk = db.table("courses").foreign_keys[0]
|
||||
assert fk.is_compound is True
|
||||
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:
|
||||
# ForeignKey objects passed to db.add_foreign_keys() were flattened
|
||||
# to plain tuples, losing on_delete/on_update
|
||||
fresh_db["authors"].insert({"id": 1}, pk="id")
|
||||
fresh_db["books"].insert({"id": 1, "author_id": 1}, pk="id")
|
||||
fresh_db.table("authors").insert({"id": 1}, pk="id")
|
||||
fresh_db.table("books").insert({"id": 1, "author_id": 1}, pk="id")
|
||||
fresh_db.add_foreign_keys(
|
||||
[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 "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):
|
||||
|
|
@ -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.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):
|
||||
fresh_db["authors"].insert({"id": 1}, pk="id")
|
||||
fresh_db["books"].insert({"id": 1, "author_id": 1}, pk="id")
|
||||
fresh_db["books"].add_foreign_key(
|
||||
fresh_db.table("authors").insert({"id": 1}, pk="id")
|
||||
fresh_db.table("books").insert({"id": 1, "author_id": 1}, pk="id")
|
||||
fresh_db.table("books").add_foreign_key(
|
||||
"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_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
|
||||
fresh_db.execute("PRAGMA foreign_keys = ON")
|
||||
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):
|
||||
courses_db["courses"].add_foreign_key(
|
||||
courses_db.table("courses").add_foreign_key(
|
||||
("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.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):
|
||||
|
|
@ -535,7 +544,7 @@ def test_implicit_compound_foreign_key_resolves_pk_declaration_order(fresh_db):
|
|||
fresh_db.execute(
|
||||
"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")
|
||||
|
||||
|
||||
|
|
@ -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)"
|
||||
)
|
||||
fresh_db.execute("PRAGMA foreign_keys = ON")
|
||||
fresh_db["other"].insert({"a": "A", "b": "B"})
|
||||
fresh_db["child"].insert({"x": "A", "y": "B"})
|
||||
fresh_db["child"].transform(types={"x": str})
|
||||
assert fresh_db["child"].foreign_keys[0].other_columns == ("a", "b")
|
||||
fresh_db.table("other").insert({"a": "A", "b": "B"})
|
||||
fresh_db.table("child").insert({"x": "A", "y": "B"})
|
||||
fresh_db.table("child").transform(types={"x": str})
|
||||
assert fresh_db.table("child").foreign_keys[0].other_columns == ("a", "b")
|
||||
# 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):
|
||||
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):
|
||||
fresh_db.execute("create table other (b text, a text, primary key (a, b))")
|
||||
fresh_db["other"].insert({"a": "A", "b": "B"})
|
||||
fresh_db["child"].create(
|
||||
fresh_db.table("other").insert({"a": "A", "b": "B"})
|
||||
fresh_db.table("child").create(
|
||||
{"id": int, "x": str, "y": str},
|
||||
pk="id",
|
||||
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["child"].insert({"id": 1, "x": "A", "y": "B"})
|
||||
fresh_db.table("child").insert({"id": 1, "x": "A", "y": "B"})
|
||||
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):
|
||||
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["child"].add_foreign_key(("x", "y"), "other")
|
||||
assert fresh_db["child"].foreign_keys[0].other_columns == ("a", "b")
|
||||
fresh_db.table("child").insert({"id": 1, "x": "A", "y": "B"}, pk="id")
|
||||
fresh_db.table("child").add_foreign_key(("x", "y"), "other")
|
||||
assert fresh_db.table("child").foreign_keys[0].other_columns == ("a", "b")
|
||||
|
||||
|
||||
def test_foreign_keys_are_hashable(fresh_db):
|
||||
# set() over foreign_keys worked with the 3.x namedtuple and must
|
||||
# keep working with the dataclass
|
||||
fresh_db["p"].insert({"id": 1}, pk="id")
|
||||
fresh_db["c"].insert(
|
||||
fresh_db.table("p").insert({"id": 1}, pk="id")
|
||||
fresh_db.table("c").insert(
|
||||
{"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 ForeignKey("c", "pid", "p", "id") in fks
|
||||
# Usable as dict keys too
|
||||
|
|
@ -599,7 +608,7 @@ def test_foreign_key_is_immutable():
|
|||
|
||||
fk = ForeignKey("c", "pid", "p", "id")
|
||||
with pytest.raises(dataclasses.FrozenInstanceError):
|
||||
fk.table = "other"
|
||||
setattr(fk, "table", "other")
|
||||
|
||||
|
||||
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
|
||||
# strings in foreign_keys= (ForeignKey was a namedtuple, so it passed
|
||||
# the tuple check) - keep accepting the mix
|
||||
fresh_db["authors"].insert({"id": 1}, pk="id")
|
||||
fresh_db["publishers"].insert({"id": 1}, pk="id")
|
||||
fresh_db["books"].create(
|
||||
fresh_db.table("authors").insert({"id": 1}, pk="id")
|
||||
fresh_db.table("publishers").insert({"id": 1}, pk="id")
|
||||
fresh_db.table("books").create(
|
||||
{"id": int, "author_id": int, "publisher_id": int},
|
||||
pk="id",
|
||||
foreign_keys=[
|
||||
|
|
@ -626,14 +635,14 @@ def test_create_table_mixed_foreign_keys_list(fresh_db):
|
|||
("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"}
|
||||
|
||||
|
||||
def test_create_table_mixed_foreign_keys_with_string(fresh_db):
|
||||
fresh_db["authors"].insert({"id": 1}, pk="id")
|
||||
fresh_db["publishers"].insert({"id": 1}, pk="id")
|
||||
fresh_db["books"].create(
|
||||
fresh_db.table("authors").insert({"id": 1}, pk="id")
|
||||
fresh_db.table("publishers").insert({"id": 1}, pk="id")
|
||||
fresh_db.table("books").create(
|
||||
{"id": int, "author_id": int, "publisher_id": int},
|
||||
pk="id",
|
||||
foreign_keys=[
|
||||
|
|
@ -641,15 +650,15 @@ def test_create_table_mixed_foreign_keys_with_string(fresh_db):
|
|||
("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"}
|
||||
|
||||
|
||||
def test_add_foreign_keys_existing_with_different_actions_errors(fresh_db):
|
||||
# Requesting an existing foreign key with different ON DELETE/ON UPDATE
|
||||
# actions was silently skipped, dropping the requested change
|
||||
fresh_db["authors"].insert({"id": 1}, pk="id")
|
||||
fresh_db["books"].insert(
|
||||
fresh_db.table("authors").insert({"id": 1}, pk="id")
|
||||
fresh_db.table("books").insert(
|
||||
{"id": 1, "author_id": 1},
|
||||
pk="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")]
|
||||
)
|
||||
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):
|
||||
# An exact match, including actions, is silently skipped so repeated
|
||||
# calls stay idempotent
|
||||
fresh_db["authors"].insert({"id": 1}, pk="id")
|
||||
fresh_db["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("authors").insert({"id": 1}, pk="id")
|
||||
fresh_db.table("books").insert({"id": 1, "author_id": 1}, pk="id")
|
||||
fresh_db.table("books").add_foreign_key(
|
||||
"author_id", "authors", "id", on_delete="CASCADE"
|
||||
)
|
||||
fresh_db.add_foreign_keys(
|
||||
[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 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):
|
||||
# Previously the extra other-column was silently discarded, creating
|
||||
# a single-column foreign key to just ("id")
|
||||
fresh_db["departments"].insert(
|
||||
fresh_db.table("departments").insert(
|
||||
{"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:
|
||||
fresh_db.add_foreign_keys(
|
||||
[("courses", ("campus",), "departments", ("campus", "code"))]
|
||||
)
|
||||
assert "same number of columns" in str(ex.value)
|
||||
assert fresh_db["courses"].foreign_keys == []
|
||||
assert fresh_db.table("courses").foreign_keys == []
|
||||
|
|
|
|||
|
|
@ -1,7 +1,9 @@
|
|||
from unittest.mock import ANY
|
||||
|
||||
import pytest
|
||||
|
||||
from sqlite_utils import Database
|
||||
from sqlite_utils.utils import sqlite3
|
||||
from unittest.mock import ANY
|
||||
|
||||
search_records = [
|
||||
{
|
||||
|
|
@ -18,7 +20,7 @@ search_records = [
|
|||
|
||||
|
||||
def test_enable_fts(fresh_db):
|
||||
table = fresh_db["searchable"]
|
||||
table = fresh_db.table("searchable")
|
||||
table.insert_all(search_records)
|
||||
assert ["searchable"] == fresh_db.table_names()
|
||||
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):
|
||||
# Table names with restricted chars are handled correctly.
|
||||
# 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)
|
||||
assert ["http://example.com"] == fresh_db.table_names()
|
||||
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):
|
||||
# https://github.com/simonw/sqlite-utils/issues/624
|
||||
table = fresh_db["t"]
|
||||
table = fresh_db.table("t")
|
||||
table.insert_all(search_records)
|
||||
table.enable_fts(["text", "country"], fts_version="FTS4")
|
||||
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):
|
||||
table = fresh_db["t"]
|
||||
table = fresh_db.table("t")
|
||||
table.insert_all(search_records)
|
||||
table.enable_fts(["text", "country"], fts_version="FTS4")
|
||||
assert len(list(table.search("are"))) == 2
|
||||
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 (
|
||||
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"))
|
||||
def test_search_where(fresh_db, fts_version):
|
||||
table = fresh_db["t"]
|
||||
table = fresh_db.table("t")
|
||||
table.insert_all(search_records)
|
||||
table.enable_fts(["text", "country"], fts_version=fts_version)
|
||||
results = list(
|
||||
|
|
@ -128,7 +142,7 @@ def test_search_where(fresh_db, fts_version):
|
|||
|
||||
|
||||
def test_search_where_args_disallows_query(fresh_db):
|
||||
table = fresh_db["t"]
|
||||
table = fresh_db.table("t")
|
||||
with pytest.raises(ValueError) as ex:
|
||||
list(
|
||||
table.search(
|
||||
|
|
@ -142,7 +156,7 @@ def test_search_where_args_disallows_query(fresh_db):
|
|||
|
||||
|
||||
def test_search_include_rank(fresh_db):
|
||||
table = fresh_db["t"]
|
||||
table = fresh_db.table("t")
|
||||
table.insert_all(search_records)
|
||||
table.enable_fts(["text", "country"], fts_version="FTS5")
|
||||
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):
|
||||
table = fresh_db["test"]
|
||||
table = fresh_db.table("test")
|
||||
table.insert({"column with spaces": "in its name"})
|
||||
table.enable_fts(["column with spaces"])
|
||||
assert [
|
||||
|
|
@ -182,7 +196,7 @@ def test_enable_fts_table_names_containing_spaces(fresh_db):
|
|||
|
||||
|
||||
def test_populate_fts(fresh_db):
|
||||
table = fresh_db["populatable"]
|
||||
table = fresh_db.table("populatable")
|
||||
table.insert(search_records[0])
|
||||
table.enable_fts(["text", "country"], fts_version="FTS4")
|
||||
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):
|
||||
# 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.enable_fts(["text", "country"], fts_version="FTS4")
|
||||
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"))
|
||||
def test_fts_tokenize(fresh_db, fts_version):
|
||||
table_name = "searchable_{}".format(fts_version)
|
||||
table = fresh_db[table_name]
|
||||
table_name = f"searchable_{fts_version}"
|
||||
table = fresh_db.table(table_name)
|
||||
table.insert_all(search_records)
|
||||
# Test without porter stemming
|
||||
table.enable_fts(
|
||||
["text", "country"],
|
||||
fts_version="FTS{}".format(fts_version),
|
||||
fts_version=f"FTS{fts_version}",
|
||||
)
|
||||
assert [] == list(table.search("bite"))
|
||||
# Test WITH stemming
|
||||
table.disable_fts()
|
||||
table.enable_fts(
|
||||
["text", "country"],
|
||||
fts_version="FTS{}".format(fts_version),
|
||||
fts_version=f"FTS{fts_version}",
|
||||
tokenize="porter",
|
||||
)
|
||||
rows = list(table.search("bite", order_by="rowid"))
|
||||
|
|
@ -249,12 +263,24 @@ def test_fts_tokenize(fresh_db, fts_version):
|
|||
}.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):
|
||||
for fts_version in ("4", "5"):
|
||||
table_name = "searchable_{}".format(fts_version)
|
||||
table = fresh_db[table_name]
|
||||
table_name = f"searchable_{fts_version}"
|
||||
table = fresh_db.table(table_name)
|
||||
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:
|
||||
for table_name in (
|
||||
"searchable_4",
|
||||
|
|
@ -262,11 +288,11 @@ def test_optimize_fts(fresh_db):
|
|||
"searchable_4_fts",
|
||||
"searchable_5_fts",
|
||||
):
|
||||
fresh_db[table_name].optimize()
|
||||
fresh_db.table(table_name).optimize()
|
||||
|
||||
|
||||
def test_enable_fts_with_triggers(fresh_db):
|
||||
table = fresh_db["searchable"]
|
||||
table = fresh_db.table("searchable")
|
||||
table.insert(search_records[0])
|
||||
table.enable_fts(["text", "country"], fts_version="FTS4", create_triggers=True)
|
||||
rows1 = list(table.search("tanuki"))
|
||||
|
|
@ -295,7 +321,7 @@ def test_enable_fts_with_triggers(fresh_db):
|
|||
|
||||
@pytest.mark.parametrize("create_triggers", [True, False])
|
||||
def test_disable_fts(fresh_db, create_triggers):
|
||||
table = fresh_db["searchable"]
|
||||
table = fresh_db.table("searchable")
|
||||
table.insert(search_records[0])
|
||||
table.enable_fts(["text", "country"], create_triggers=create_triggers)
|
||||
assert {
|
||||
|
|
@ -310,12 +336,12 @@ def test_disable_fts(fresh_db, create_triggers):
|
|||
expected_triggers = {"searchable_ai", "searchable_ad", "searchable_au"}
|
||||
else:
|
||||
expected_triggers = set()
|
||||
assert expected_triggers == set(
|
||||
assert expected_triggers == {
|
||||
r[0]
|
||||
for r in fresh_db.execute(
|
||||
"select name from sqlite_master where type = 'trigger'"
|
||||
).fetchall()
|
||||
)
|
||||
}
|
||||
# Now run .disable_fts() and confirm it worked
|
||||
table.disable_fts()
|
||||
assert (
|
||||
|
|
@ -328,7 +354,7 @@ def test_disable_fts(fresh_db, create_triggers):
|
|||
|
||||
|
||||
def test_rebuild_fts(fresh_db):
|
||||
table = fresh_db["searchable"]
|
||||
table = fresh_db.table("searchable")
|
||||
table.insert(search_records[0])
|
||||
table.enable_fts(["text", "country"])
|
||||
# Run a search
|
||||
|
|
@ -354,7 +380,7 @@ def test_rebuild_fts(fresh_db):
|
|||
def test_optimize_and_rebuild_fts_commit(tmpdir, method):
|
||||
path = str(tmpdir / "test.db")
|
||||
db = Database(path)
|
||||
table = db["searchable"]
|
||||
table = db.table("searchable")
|
||||
table.insert(search_records[0])
|
||||
table.enable_fts(["text", "country"])
|
||||
getattr(table, method)()
|
||||
|
|
@ -364,16 +390,16 @@ def test_optimize_and_rebuild_fts_commit(tmpdir, method):
|
|||
table.insert(search_records[1])
|
||||
db.close()
|
||||
db2 = Database(path)
|
||||
assert db2["searchable"].count == 2
|
||||
assert db2.table("searchable").count == 2
|
||||
db2.close()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("invalid_table", ["does_not_exist", "not_searchable"])
|
||||
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
|
||||
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"])
|
||||
|
|
@ -382,15 +408,17 @@ def test_rebuild_removes_junk_docsize_rows(tmpdir, fts_version):
|
|||
path = tmpdir / "test.db"
|
||||
db = Database(str(path), recursive_triggers=False)
|
||||
licenses = [{"key": "apache2", "name": "Apache 2"}, {"key": "bsd", "name": "BSD"}]
|
||||
db["licenses"].insert_all(licenses, pk="key", replace=True)
|
||||
db["licenses"].enable_fts(["name"], create_triggers=True, fts_version=fts_version)
|
||||
assert db["licenses_fts_docsize"].count == 2
|
||||
db.table("licenses").insert_all(licenses, pk="key", replace=True)
|
||||
db.table("licenses").enable_fts(
|
||||
["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:
|
||||
db["licenses"].insert_all(licenses, pk="key", replace=True)
|
||||
assert db["licenses_fts_docsize"].count == 4
|
||||
db.table("licenses").insert_all(licenses, pk="key", replace=True)
|
||||
assert db.table("licenses_fts_docsize").count == 4
|
||||
# rebuild should fix this:
|
||||
db["licenses_fts"].rebuild_fts()
|
||||
assert db["licenses_fts_docsize"].count == 2
|
||||
db.table("licenses_fts").rebuild_fts()
|
||||
assert db.table("licenses_fts_docsize").count == 2
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
|
|
@ -404,7 +432,7 @@ def test_rebuild_removes_junk_docsize_rows(tmpdir, fts_version):
|
|||
)
|
||||
def test_enable_fts_replace(kwargs):
|
||||
db = Database(memory=True)
|
||||
db["books"].insert(
|
||||
db.table("books").insert(
|
||||
{
|
||||
"id": 1,
|
||||
"title": "Habits of Australian Marsupials",
|
||||
|
|
@ -412,31 +440,31 @@ def test_enable_fts_replace(kwargs):
|
|||
},
|
||||
pk="id",
|
||||
)
|
||||
db["books"].enable_fts(["title", "author"])
|
||||
assert not db["books"].triggers
|
||||
assert db["books_fts"].columns_dict.keys() == {"title", "author"}
|
||||
assert "FTS5" in db["books_fts"].schema
|
||||
assert "porter" not in db["books_fts"].schema
|
||||
db.table("books").enable_fts(["title", "author"])
|
||||
assert not db.table("books").triggers
|
||||
assert db.table("books_fts").columns_dict.keys() == {"title", "author"}
|
||||
assert "FTS5" in db.table("books_fts").schema
|
||||
assert "porter" not in db.table("books_fts").schema
|
||||
# Now modify the FTS configuration
|
||||
should_have_changed_columns = "columns" in kwargs
|
||||
if "columns" not in kwargs:
|
||||
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
|
||||
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:
|
||||
assert db["books"].triggers
|
||||
assert db.table("books").triggers
|
||||
if "fts_version" in kwargs:
|
||||
assert "FTS4" in db["books_fts"].schema
|
||||
assert "FTS4" in db.table("books_fts").schema
|
||||
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():
|
||||
queries = []
|
||||
db = Database(memory=True, tracer=lambda sql, params: queries.append((sql, params)))
|
||||
db["books"].insert(
|
||||
db.table("books").insert(
|
||||
{
|
||||
"id": 1,
|
||||
"title": "Habits of Australian Marsupials",
|
||||
|
|
@ -444,17 +472,19 @@ def test_enable_fts_replace_does_nothing_if_args_the_same():
|
|||
},
|
||||
pk="id",
|
||||
)
|
||||
db["books"].enable_fts(["title", "author"], create_triggers=True)
|
||||
db.table("books").enable_fts(["title", "author"], create_triggers=True)
|
||||
queries.clear()
|
||||
# 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
|
||||
assert all(q[0].startswith("select ") for q in queries)
|
||||
|
||||
|
||||
def test_enable_fts_replace_handles_legacy_bracket_quoted_content_table():
|
||||
db = Database(memory=True)
|
||||
db["books"].insert(
|
||||
db.table("books").insert(
|
||||
{
|
||||
"id": 1,
|
||||
"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 'content="books"' in db["books_fts"].schema
|
||||
assert db.table("books_fts").columns_dict.keys() == {"title", "author"}
|
||||
assert 'content="books"' in db.table("books_fts").schema
|
||||
|
||||
|
||||
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")
|
||||
# Views deliberately do not have an enable_fts() method
|
||||
with pytest.raises(AttributeError):
|
||||
db["hello"].enable_fts() # type: ignore[union-attr]
|
||||
db.view("hello").enable_fts() # type: ignore[attr-defined]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
|
|
@ -686,14 +716,14 @@ def test_view_has_no_enable_fts():
|
|||
)
|
||||
def test_search_sql(kwargs, fts, expected):
|
||||
db = Database(memory=True)
|
||||
db["books"].insert(
|
||||
db.table("books").insert(
|
||||
{
|
||||
"title": "Habits of Australian Marsupials",
|
||||
"author": "Marlee Hawkins",
|
||||
}
|
||||
)
|
||||
db["books"].enable_fts(["title", "author"], fts_version=fts)
|
||||
sql = db["books"].search_sql(**kwargs)
|
||||
db.table("books").enable_fts(["title", "author"], fts_version=fts)
|
||||
sql = db.table("books").search_sql(**kwargs)
|
||||
assert sql == expected
|
||||
|
||||
|
||||
|
|
@ -714,7 +744,7 @@ def test_search_sql(kwargs, fts, 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.enable_fts(["text", "country"])
|
||||
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):
|
||||
table = fresh_db["searchable"]
|
||||
table = fresh_db.table("searchable")
|
||||
table.insert_all(search_records)
|
||||
table.enable_fts(["text", "country"])
|
||||
query = "cat's"
|
||||
|
|
@ -737,10 +767,11 @@ def test_search_quote(fresh_db):
|
|||
def test_enable_fts_cli_on_view_errors(tmpdir):
|
||||
db_path = str(tmpdir / "test.db")
|
||||
db = Database(db_path)
|
||||
db["t"].insert({"text": "hello"})
|
||||
db.table("t").insert({"text": "hello"})
|
||||
db.create_view("v", "select * from t")
|
||||
db.close()
|
||||
from click.testing import CliRunner
|
||||
|
||||
from sqlite_utils import cli as cli_module
|
||||
|
||||
result = CliRunner().invoke(cli_module.cli, ["enable-fts", db_path, "v", "text"])
|
||||
|
|
|
|||
|
|
@ -1,16 +1,17 @@
|
|||
import pytest
|
||||
|
||||
from sqlite_utils.db import NotFoundError
|
||||
|
||||
|
||||
def test_get_rowid(fresh_db):
|
||||
dogs = fresh_db["dogs"]
|
||||
dogs = fresh_db.table("dogs")
|
||||
cleo = {"name": "Cleo", "age": 4}
|
||||
row_id = dogs.insert(cleo).last_rowid
|
||||
assert cleo == dogs.get(row_id)
|
||||
|
||||
|
||||
def test_get_primary_key(fresh_db):
|
||||
dogs = fresh_db["dogs"]
|
||||
dogs = fresh_db.table("dogs")
|
||||
cleo = {"name": "Cleo", "age": 4, "id": 5}
|
||||
last_pk = dogs.insert(cleo, pk="id").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)],
|
||||
)
|
||||
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"
|
||||
)
|
||||
with pytest.raises(NotFoundError) as excinfo:
|
||||
fresh_db["dogs"].get(argument)
|
||||
fresh_db.table("dogs").get(argument)
|
||||
if expected_msg is not None:
|
||||
assert expected_msg == excinfo.value.args[0]
|
||||
|
|
|
|||
|
|
@ -1,7 +1,8 @@
|
|||
import json
|
||||
import pytest
|
||||
|
||||
import pytest
|
||||
from click.testing import CliRunner
|
||||
|
||||
from sqlite_utils.cli import cli
|
||||
from sqlite_utils.db import Database
|
||||
from sqlite_utils.utils import find_spatialite, sqlite3
|
||||
|
|
@ -44,7 +45,7 @@ def test_add_geometry_column():
|
|||
coord_dimension="XY",
|
||||
)
|
||||
|
||||
assert db["geometry_columns"].get(["locations", "geometry"]) == {
|
||||
assert db.table("geometry_columns").get(["locations", "geometry"]) == {
|
||||
"f_table_name": "locations",
|
||||
"f_geometry_column": "geometry",
|
||||
"geometry_type": 1, # point
|
||||
|
|
@ -104,7 +105,7 @@ def test_query_load_extension(use_spatialite_shortcut):
|
|||
[
|
||||
":memory:",
|
||||
"select spatialite_version()",
|
||||
"--load-extension={}".format(load_extension),
|
||||
f"--load-extension={load_extension}",
|
||||
],
|
||||
)
|
||||
assert result.exit_code == 0, result.stdout
|
||||
|
|
@ -132,7 +133,7 @@ def test_cli_add_geometry_column(tmpdir):
|
|||
db = Database(str(db_path))
|
||||
db.init_spatialite()
|
||||
|
||||
table = db["locations"].create({"name": str})
|
||||
table = db.table("locations").create({"name": str})
|
||||
|
||||
result = CliRunner().invoke(
|
||||
cli,
|
||||
|
|
@ -148,7 +149,7 @@ def test_cli_add_geometry_column(tmpdir):
|
|||
|
||||
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_geometry_column": "geometry",
|
||||
"geometry_type": 1, # point
|
||||
|
|
@ -163,7 +164,7 @@ def test_cli_add_geometry_column_options(tmpdir):
|
|||
db_path = tmpdir / "spatial.db"
|
||||
db = Database(str(db_path))
|
||||
db.init_spatialite()
|
||||
table = db["locations"].create({"name": str})
|
||||
table = db.table("locations").create({"name": str})
|
||||
|
||||
result = CliRunner().invoke(
|
||||
cli,
|
||||
|
|
@ -182,7 +183,7 @@ def test_cli_add_geometry_column_options(tmpdir):
|
|||
|
||||
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_geometry_column": "geometry",
|
||||
"geometry_type": 3, # polygon
|
||||
|
|
@ -201,7 +202,7 @@ def test_cli_add_geometry_column_invalid_type(tmpdir):
|
|||
db = Database(str(db_path))
|
||||
db.init_spatialite()
|
||||
|
||||
table = db["locations"].create({"name": str})
|
||||
table = db.table("locations").create({"name": str})
|
||||
|
||||
result = CliRunner().invoke(
|
||||
cli,
|
||||
|
|
@ -224,7 +225,7 @@ def test_cli_create_spatial_index(tmpdir):
|
|||
db = Database(str(db_path))
|
||||
db.init_spatialite()
|
||||
|
||||
table = db["locations"].create({"name": str})
|
||||
table = db.table("locations").create({"name": str})
|
||||
table.add_geometry_column("geometry", "POINT")
|
||||
|
||||
result = CliRunner().invoke(
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
from hypothesis import given
|
||||
import hypothesis.strategies as st
|
||||
from hypothesis import given
|
||||
|
||||
import sqlite_utils
|
||||
|
||||
|
||||
|
|
@ -10,8 +11,8 @@ def test_roundtrip_integers(integer):
|
|||
row = {
|
||||
"integer": integer,
|
||||
}
|
||||
db["test"].insert(row)
|
||||
assert list(db["test"].rows) == [row]
|
||||
db.table("test").insert(row)
|
||||
assert list(db.table("test").rows) == [row]
|
||||
|
||||
|
||||
@given(st.text())
|
||||
|
|
@ -20,8 +21,8 @@ def test_roundtrip_text(text):
|
|||
row = {
|
||||
"text": text,
|
||||
}
|
||||
db["test"].insert(row)
|
||||
assert list(db["test"].rows) == [row]
|
||||
db.table("test").insert(row)
|
||||
assert list(db.table("test").rows) == [row]
|
||||
|
||||
|
||||
@given(st.binary(max_size=1024 * 1024))
|
||||
|
|
@ -30,8 +31,8 @@ def test_roundtrip_binary(binary):
|
|||
row = {
|
||||
"binary": binary,
|
||||
}
|
||||
db["test"].insert(row)
|
||||
assert list(db["test"].rows) == [row]
|
||||
db.table("test").insert(row)
|
||||
assert list(db.table("test").rows) == [row]
|
||||
|
||||
|
||||
@given(st.floats(allow_nan=False))
|
||||
|
|
@ -40,5 +41,5 @@ def test_roundtrip_floats(floats):
|
|||
row = {
|
||||
"floats": floats,
|
||||
}
|
||||
db["test"].insert(row)
|
||||
assert list(db["test"].rows) == [row]
|
||||
db.table("test").insert(row)
|
||||
assert list(db.table("test").rows) == [row]
|
||||
|
|
|
|||
|
|
@ -1,10 +1,12 @@
|
|||
from sqlite_utils import cli, Database
|
||||
from click.testing import CliRunner
|
||||
import os
|
||||
import pathlib
|
||||
import pytest
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
from click.testing import CliRunner
|
||||
|
||||
from sqlite_utils import Database, cli
|
||||
|
||||
|
||||
@pytest.mark.parametrize("silent", (False, True))
|
||||
@pytest.mark.parametrize(
|
||||
|
|
@ -44,7 +46,7 @@ def test_insert_files(silent, pk_args, expected_pks):
|
|||
)
|
||||
cols = []
|
||||
for coltype in coltypes:
|
||||
cols += ["-c", "{}:{}".format(coltype, coltype)]
|
||||
cols += ["-c", f"{coltype}:{coltype}"]
|
||||
result = runner.invoke(
|
||||
cli.cli,
|
||||
["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
|
||||
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 = (
|
||||
rows_by_path["one.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 row in (one, two, three):
|
||||
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(
|
||||
|
|
@ -142,7 +144,7 @@ def test_insert_files_stdin(use_text, encoding, input, expected):
|
|||
)
|
||||
assert result.exit_code == 0, result.stdout
|
||||
db = Database(db_path)
|
||||
row = list(db["files"].rows)[0]
|
||||
row = next(iter(db.table("files").rows))
|
||||
key = "content"
|
||||
if use_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.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"
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
from sqlite_utils.db import Index, View, Database, XIndex, XIndexColumn
|
||||
import pytest
|
||||
|
||||
from sqlite_utils.db import Check, Database, Index, Table, View, XIndex, XIndexColumn
|
||||
|
||||
|
||||
def _check_supports_strict():
|
||||
"""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):
|
||||
existing_db["woo"].insert({"title": "Hello"}).enable_fts(
|
||||
existing_db.table("woo").insert({"title": "Hello"}).enable_fts(
|
||||
["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"
|
||||
)
|
||||
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):
|
||||
existing_db["woo"].insert({"title": "Hello"}).enable_fts(
|
||||
existing_db.table("woo").insert({"title": "Hello"}).enable_fts(
|
||||
["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"
|
||||
)
|
||||
assert "woo_fts" == existing_db["woo"].detect_fts()
|
||||
assert "woo_fts" == existing_db["woo_fts"].detect_fts()
|
||||
assert "woo2_fts" == existing_db["woo2"].detect_fts()
|
||||
assert "woo2_fts" == existing_db["woo2_fts"].detect_fts()
|
||||
assert existing_db["foo"].detect_fts() is None
|
||||
assert "woo_fts" == existing_db.table("woo").detect_fts()
|
||||
assert "woo_fts" == existing_db.table("woo_fts").detect_fts()
|
||||
assert "woo2_fts" == existing_db.table("woo2").detect_fts()
|
||||
assert "woo2_fts" == existing_db.table("woo2_fts").detect_fts()
|
||||
assert existing_db.table("foo").detect_fts() is None
|
||||
|
||||
|
||||
@pytest.mark.parametrize("reverse_order", (True, False))
|
||||
|
|
@ -51,14 +52,14 @@ def test_detect_fts_similar_tables(fresh_db, reverse_order):
|
|||
if reverse_order:
|
||||
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"
|
||||
)
|
||||
fresh_db[table2].insert({"title": "Hello"}).enable_fts(
|
||||
fresh_db.table(table2).insert({"title": "Hello"}).enable_fts(
|
||||
["title"], fts_version="FTS4"
|
||||
)
|
||||
assert fresh_db[table1].detect_fts() == "{}_fts".format(table1)
|
||||
assert fresh_db[table2].detect_fts() == "{}_fts".format(table2)
|
||||
assert fresh_db.table(table1).detect_fts() == f"{table1}_fts"
|
||||
assert fresh_db.table(table2).detect_fts() == f"{table2}_fts"
|
||||
|
||||
|
||||
def test_tables(existing_db):
|
||||
|
|
@ -76,26 +77,34 @@ def test_views(fresh_db):
|
|||
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):
|
||||
assert existing_db["foo"].count == 3
|
||||
assert existing_db["foo"].count_where() == 3
|
||||
assert existing_db["foo"].execute_count() == 3
|
||||
assert existing_db.table("foo").count == 3
|
||||
assert existing_db.table("foo").count_where() == 3
|
||||
assert existing_db.table("foo").execute_count() == 3
|
||||
|
||||
|
||||
def test_count_where(existing_db):
|
||||
assert existing_db["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 != ?", ["two"]) == 2
|
||||
assert existing_db.table("foo").count_where("text != :t", {"t": "two"}) == 2
|
||||
|
||||
|
||||
def test_columns(existing_db):
|
||||
table = existing_db["foo"]
|
||||
table = existing_db.table("foo")
|
||||
assert [{"name": "text", "type": "TEXT"}] == [
|
||||
{"name": col.name, "type": col.type} for col in table.columns
|
||||
]
|
||||
|
||||
|
||||
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):
|
||||
|
|
@ -103,9 +112,9 @@ def test_database_schema(existing_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 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):
|
||||
|
|
@ -124,7 +133,7 @@ def test_indexes(fresh_db):
|
|||
columns=["c2", "c3"],
|
||||
),
|
||||
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):
|
||||
|
|
@ -133,7 +142,7 @@ def test_xindexes(fresh_db):
|
|||
create index Gosh_c1 on Gosh(c1);
|
||||
create index Gosh_c2c3 on Gosh(c2, c3 desc);
|
||||
""")
|
||||
assert fresh_db["Gosh"].xindexes == [
|
||||
assert fresh_db.table("Gosh").xindexes == [
|
||||
XIndex(
|
||||
name="Gosh_c2c3",
|
||||
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(
|
||||
"column,expected_table_guess",
|
||||
(
|
||||
|
|
@ -165,30 +199,55 @@ def test_xindexes(fresh_db):
|
|||
def test_guess_foreign_table(fresh_db, column, expected_table_guess):
|
||||
fresh_db.create_table("authors", {"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(
|
||||
"pk,expected", ((None, ["rowid"]), ("id", ["id"]), (["id", "id2"], ["id", "id2"]))
|
||||
)
|
||||
def test_pks(fresh_db, pk, expected):
|
||||
fresh_db["foo"].insert_all([{"id": 1, "id2": 2}], pk=pk)
|
||||
assert expected == fresh_db["foo"].pks
|
||||
fresh_db.table("foo").insert_all([{"id": 1, "id2": 2}], pk=pk)
|
||||
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):
|
||||
assert [] == fresh_db.triggers
|
||||
authors = fresh_db["authors"]
|
||||
authors = fresh_db.table("authors")
|
||||
authors.insert_all(
|
||||
[
|
||||
{"name": "Frank Herbert", "famous_works": "Dune"},
|
||||
{"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_dict == {}
|
||||
assert fresh_db["other"].triggers == []
|
||||
assert fresh_db.table("other").triggers == []
|
||||
assert fresh_db.triggers_dict == {}
|
||||
authors.enable_fts(
|
||||
["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["authors"].triggers
|
||||
(t.name, t.table) for t in fresh_db.table("authors").triggers
|
||||
}
|
||||
expected_triggers = {
|
||||
"authors_ai": (
|
||||
|
|
@ -220,13 +279,13 @@ def test_triggers_and_triggers_dict(fresh_db):
|
|||
),
|
||||
}
|
||||
assert authors.triggers_dict == expected_triggers
|
||||
assert fresh_db["other"].triggers == []
|
||||
assert fresh_db["other"].triggers_dict == {}
|
||||
assert fresh_db.table("other").triggers == []
|
||||
assert fresh_db.table("other").triggers_dict == {}
|
||||
assert fresh_db.triggers_dict == expected_triggers
|
||||
|
||||
|
||||
def test_has_counts_triggers(fresh_db):
|
||||
authors = fresh_db["authors"]
|
||||
authors = fresh_db.table("authors")
|
||||
authors.insert({"name": "Frank Herbert"})
|
||||
assert not authors.has_counts_triggers
|
||||
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):
|
||||
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):
|
||||
fresh_db["rowid_table"].insert({"name": "Cleo"})
|
||||
fresh_db["regular_table"].insert({"id": 1, "name": "Cleo"}, pk="id")
|
||||
assert fresh_db["rowid_table"].use_rowid
|
||||
assert not fresh_db["regular_table"].use_rowid
|
||||
fresh_db.table("rowid_table").insert({"name": "Cleo"})
|
||||
fresh_db.table("regular_table").insert({"id": 1, "name": "Cleo"}, pk="id")
|
||||
assert fresh_db.table("rowid_table").use_rowid
|
||||
assert not fresh_db.table("regular_table").use_rowid
|
||||
|
||||
|
||||
@pytest.mark.skipif(
|
||||
|
|
@ -301,7 +360,7 @@ def test_use_rowid(fresh_db):
|
|||
)
|
||||
def test_table_strict(fresh_db, create_table, expected_strict):
|
||||
fresh_db.execute(create_table)
|
||||
table = fresh_db["t"]
|
||||
table = fresh_db.table("t")
|
||||
assert table.strict == expected_strict
|
||||
|
||||
|
||||
|
|
@ -311,28 +370,54 @@ def test_table_strict(fresh_db, create_table, expected_strict):
|
|||
1,
|
||||
1.3,
|
||||
"foo",
|
||||
"O'Brien",
|
||||
True,
|
||||
b"binary",
|
||||
),
|
||||
)
|
||||
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}
|
||||
)
|
||||
default_values = fresh_db["default_values"].default_values
|
||||
default_values = fresh_db.table("default_values").default_values
|
||||
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):
|
||||
# PRIMARY KEY (a, b) declared against columns stored in order (b, a) -
|
||||
# pks must follow the declaration order, which is what SQLite uses to
|
||||
# resolve implicit foreign key references and compound pk lookups
|
||||
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):
|
||||
fresh_db.execute("create table t (a text, b text, c text, primary key (b, a))")
|
||||
fresh_db["t"].transform(drop={"c"})
|
||||
assert fresh_db["t"].pks == ["b", "a"]
|
||||
assert 'PRIMARY KEY ("b", "a")' in fresh_db["t"].schema
|
||||
fresh_db.table("t").transform(drop={"c"})
|
||||
assert fresh_db.table("t").pks == ["b", "a"]
|
||||
assert 'PRIMARY KEY ("b", "a")' in fresh_db.table("t").schema
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ Tests for list-based iteration in insert_all and upsert_all
|
|||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
from sqlite_utils import Database
|
||||
|
||||
|
||||
|
|
@ -18,9 +19,9 @@ def test_insert_all_list_mode_basic():
|
|||
yield [2, "Bob", 25]
|
||||
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 rows[0] == {"id": 1, "name": "Alice", "age": 30}
|
||||
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 [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"]
|
||||
rows = list(db["scores"].rows)
|
||||
assert db.table("scores").pks == ["id"]
|
||||
rows = list(db.table("scores").rows)
|
||||
assert len(rows) == 2
|
||||
|
||||
|
||||
|
|
@ -53,7 +54,7 @@ def test_upsert_all_list_mode():
|
|||
yield [1, "Alice", 100]
|
||||
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
|
||||
def upsert_data():
|
||||
|
|
@ -61,9 +62,9 @@ def test_upsert_all_list_mode():
|
|||
yield [1, "Alice", 150] # Update existing
|
||||
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 rows[0] == {"id": 1, "name": "Alice", "value": 150}
|
||||
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 [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 rows[0]["score"] == 95.5
|
||||
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"]
|
||||
|
||||
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():
|
||||
|
|
@ -110,7 +111,7 @@ def test_list_mode_error_mixed_types():
|
|||
yield {"id": 1, "name": "Alice"} # Should be a list, not dict
|
||||
|
||||
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():
|
||||
|
|
@ -121,9 +122,9 @@ def test_list_mode_empty_after_headers():
|
|||
yield ["id", "name", "age"]
|
||||
# No data rows
|
||||
|
||||
result = db["people"].insert_all(data_generator())
|
||||
result = db.table("people").insert_all(data_generator())
|
||||
assert result is not None
|
||||
assert not db["people"].exists()
|
||||
assert not db.table("people").exists()
|
||||
|
||||
|
||||
def test_list_mode_batch_processing():
|
||||
|
|
@ -135,7 +136,7 @@ def test_list_mode_batch_processing():
|
|||
for i in range(1000):
|
||||
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]
|
||||
assert count == 1000
|
||||
|
|
@ -151,9 +152,9 @@ def test_list_mode_shorter_rows():
|
|||
yield [2, "Bob"] # Missing age and 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[1] == {"id": 2, "name": "Bob", "age": None, "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},
|
||||
]
|
||||
|
||||
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 rows[0] == {"id": 1, "name": "Alice", "age": 30}
|
||||
|
||||
|
|
@ -188,9 +189,9 @@ def test_insert_all_tuple_mode_basic():
|
|||
yield (2, "Bob", 25)
|
||||
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 rows[0] == {"id": 1, "name": "Alice", "age": 30}
|
||||
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 (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 rows[0] == {"id": 1, "name": "Alice", "age": 30}
|
||||
assert rows[1] == {"id": 2, "name": "Bob", "age": 25}
|
||||
|
|
@ -230,7 +231,7 @@ def test_upsert_all_tuple_mode():
|
|||
yield (1, "Alice", 100)
|
||||
yield (2, "Bob", 200)
|
||||
|
||||
db["data"].insert_all(initial_data(), pk="id")
|
||||
db.table("data").insert_all(initial_data(), pk="id")
|
||||
|
||||
# Upsert with tuples
|
||||
def upsert_data():
|
||||
|
|
@ -238,9 +239,9 @@ def test_upsert_all_tuple_mode():
|
|||
yield (1, "Alice", 150) # Update existing
|
||||
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 rows[0] == {"id": 1, "name": "Alice", "value": 150}
|
||||
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 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[1] == {"id": 2, "name": "Bob", "age": None, "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)
|
||||
|
||||
# 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
|
||||
def upsert_data():
|
||||
yield ["id", "name", "value"]
|
||||
yield [1, "Alice", 150] # Update existing
|
||||
|
||||
table = db["data"]
|
||||
table = db.table("data")
|
||||
table.upsert_all(upsert_data(), pk="id")
|
||||
|
||||
# Verify the data was updated
|
||||
rows = list(db["data"].rows)
|
||||
rows = list(db.table("data").rows)
|
||||
assert rows == [{"id": 1, "name": "Alice", "value": 150}]
|
||||
|
||||
# Verify last_pk is populated correctly
|
||||
|
|
|
|||
|
|
@ -1,9 +1,10 @@
|
|||
from sqlite_utils.db import Index
|
||||
import pytest
|
||||
|
||||
from sqlite_utils.db import Index
|
||||
|
||||
|
||||
def test_lookup_new_table(fresh_db):
|
||||
species = fresh_db["species"]
|
||||
species = fresh_db.table("species")
|
||||
palm_id = species.lookup({"name": "Palm"})
|
||||
oak_id = species.lookup({"name": "Oak"})
|
||||
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):
|
||||
species = fresh_db["species"]
|
||||
species = fresh_db.table("species")
|
||||
palm_id = species.lookup({"name": "Palm", "type": "Tree"})
|
||||
oak_id = species.lookup({"name": "Oak", "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):
|
||||
species = fresh_db["species"]
|
||||
species = fresh_db.table("species")
|
||||
id = species.lookup({"name": "Palm", "type": "Tree"}, {"first_seen": "2020-01-01"})
|
||||
assert species.get(id) == {
|
||||
"id": 1,
|
||||
|
|
@ -89,9 +90,9 @@ def test_lookup_with_extra_values(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")
|
||||
species = fresh_db["species"]
|
||||
species = fresh_db.table("species")
|
||||
id = species.lookup(
|
||||
{"name": "Palm", "type": "Tree"},
|
||||
{
|
||||
|
|
@ -155,15 +156,15 @@ def test_lookup_with_extra_insert_parameters(fresh_db):
|
|||
|
||||
@pytest.mark.parametrize("strict", (False, True))
|
||||
def test_lookup_new_table_strict(fresh_db, strict):
|
||||
fresh_db["species"].lookup({"name": "Palm"}, strict=strict)
|
||||
assert fresh_db["species"].strict == strict or not fresh_db.supports_strict
|
||||
fresh_db.table("species").lookup({"name": "Palm"}, strict=strict)
|
||||
assert fresh_db.table("species").strict == strict or not fresh_db.supports_strict
|
||||
|
||||
|
||||
def test_lookup_null_value_idempotent(fresh_db):
|
||||
# https://github.com/simonw/sqlite-utils/issues/186
|
||||
# Repeated lookups of a null value should return the same row,
|
||||
# not insert a duplicate row each time
|
||||
species = fresh_db["species"]
|
||||
species = fresh_db.table("species")
|
||||
first_id = species.lookup({"name": None})
|
||||
second_id = species.lookup({"name": None})
|
||||
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):
|
||||
species = fresh_db["species"]
|
||||
species = fresh_db.table("species")
|
||||
palm_id = species.lookup({"name": "Palm", "type": None})
|
||||
oak_id = species.lookup({"name": "Oak", "type": "Tree"})
|
||||
assert palm_id == species.lookup({"name": "Palm", "type": None})
|
||||
|
|
|
|||
|
|
@ -1,47 +1,48 @@
|
|||
from sqlite_utils.db import ForeignKey, NoObviousTable
|
||||
import pytest
|
||||
|
||||
from sqlite_utils.db import ForeignKey, NoObviousTable
|
||||
|
||||
|
||||
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(
|
||||
"humans", {"id": 1, "name": "Natalie D"}, pk="id"
|
||||
)
|
||||
assert {"dogs_humans", "humans", "dogs"} == set(fresh_db.table_names())
|
||||
humans = fresh_db["humans"]
|
||||
dogs_humans = fresh_db["dogs_humans"]
|
||||
humans = fresh_db.table("humans")
|
||||
dogs_humans = fresh_db.table("dogs_humans")
|
||||
assert [{"id": 1, "name": "Natalie D"}] == list(humans.rows)
|
||||
assert [{"humans_id": 1, "dogs_id": 1}] == list(dogs_humans.rows)
|
||||
|
||||
|
||||
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(
|
||||
"humans", {"id": 1, "name": "Natalie D"}, pk="id"
|
||||
)
|
||||
dogs.update(1).m2m(
|
||||
"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": 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": 2, "dogs_id": 1},
|
||||
]
|
||||
|
||||
|
||||
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(
|
||||
"humans",
|
||||
[{"id": 1, "name": "Natalie D"}, {"id": 2, "name": "Simon W"}],
|
||||
pk="id",
|
||||
)
|
||||
assert {"dogs", "humans", "dogs_humans"} == set(fresh_db.table_names())
|
||||
humans = fresh_db["humans"]
|
||||
dogs_humans = fresh_db["dogs_humans"]
|
||||
humans = fresh_db.table("humans")
|
||||
dogs_humans = fresh_db.table("dogs_humans")
|
||||
assert [{"humans_id": 1, "dogs_id": 1}, {"humans_id": 2, "dogs_id": 1}] == list(
|
||||
dogs_humans.rows
|
||||
)
|
||||
|
|
@ -65,10 +66,9 @@ def test_insert_m2m_iterable(fresh_db):
|
|||
iterable_records = ({"id": 1, "name": "Phineas"}, {"id": 2, "name": "Ferb"})
|
||||
|
||||
def iterable():
|
||||
for record in iterable_records:
|
||||
yield record
|
||||
yield from iterable_records
|
||||
|
||||
platypuses = fresh_db["platypuses"]
|
||||
platypuses = fresh_db.table("platypuses")
|
||||
platypuses.insert({"id": 1, "name": "Perry"}, pk="id").m2m(
|
||||
"humans",
|
||||
iterable(),
|
||||
|
|
@ -76,8 +76,8 @@ def test_insert_m2m_iterable(fresh_db):
|
|||
)
|
||||
|
||||
assert {"platypuses", "humans", "humans_platypuses"} == set(fresh_db.table_names())
|
||||
humans = fresh_db["humans"]
|
||||
humans_platypuses = fresh_db["humans_platypuses"]
|
||||
humans = fresh_db.table("humans")
|
||||
humans_platypuses = fresh_db.table("humans_platypuses")
|
||||
assert [
|
||||
{"humans_id": 1, "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 dogs.count == 1
|
||||
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):
|
||||
people = fresh_db.table("people", pk="id")
|
||||
people.insert({"name": "Wahyu"}).m2m("tags", lookup={"tag": "Coworker"})
|
||||
people_tags = fresh_db["people_tags"]
|
||||
tags = fresh_db["tags"]
|
||||
people_tags = fresh_db.table("people_tags")
|
||||
tags = fresh_db.table("tags")
|
||||
assert people_tags.exists()
|
||||
assert tags.exists()
|
||||
assert [
|
||||
|
|
@ -150,9 +150,9 @@ def test_m2m_explicit_table_name_argument(fresh_db):
|
|||
people.insert({"name": "Wahyu"}).m2m(
|
||||
"tags", lookup={"tag": "Coworker"}, m2m_table="tagged"
|
||||
)
|
||||
assert fresh_db["tags"].exists
|
||||
assert fresh_db["tagged"].exists
|
||||
assert not fresh_db["people_tags"].exists()
|
||||
assert fresh_db.table("tags").exists
|
||||
assert fresh_db.table("tagged").exists
|
||||
assert not fresh_db.table("people_tags").exists()
|
||||
|
||||
|
||||
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
|
||||
# and use that if it exists.
|
||||
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(
|
||||
"tagged",
|
||||
{"people_id": int, "tags_id": int},
|
||||
foreign_keys=["people_id", "tags_id"],
|
||||
)
|
||||
people.insert({"name": "Wahyu"}).m2m("tags", lookup={"tag": "Coworker"})
|
||||
assert fresh_db["tags"].exists()
|
||||
assert fresh_db["tagged"].exists()
|
||||
assert not fresh_db["people_tags"].exists()
|
||||
assert not fresh_db["tags_people"].exists()
|
||||
assert [{"people_id": 1, "tags_id": 1}] == list(fresh_db["tagged"].rows)
|
||||
assert fresh_db.table("tags").exists()
|
||||
assert fresh_db.table("tagged").exists()
|
||||
assert not fresh_db.table("people_tags").exists()
|
||||
assert not fresh_db.table("tags_people").exists()
|
||||
assert [{"people_id": 1, "tags_id": 1}] == list(fresh_db.table("tagged").rows)
|
||||
|
||||
|
||||
def test_requires_explicit_m2m_table_if_multiple_options(fresh_db):
|
||||
# If the code scans for m2m tables and finds more than one candidate
|
||||
# it should require that the m2m_table=x argument is used
|
||||
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(
|
||||
"tagged",
|
||||
{"people_id": int, "tags_id": int},
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import pytest
|
||||
|
||||
import sqlite_utils
|
||||
from sqlite_utils import Migrations
|
||||
|
||||
|
|
@ -9,11 +10,11 @@ def migrations():
|
|||
|
||||
@migrations()
|
||||
def m001(db):
|
||||
db["dogs"].insert({"name": "Cleo"})
|
||||
db.table("dogs").insert({"name": "Cleo"})
|
||||
|
||||
@migrations()
|
||||
def m002(db):
|
||||
db["cats"].create({"name": str})
|
||||
db.table("cats").create({"name": str})
|
||||
db.execute("insert into dogs (name) values ('Pancakes')")
|
||||
|
||||
return migrations
|
||||
|
|
@ -27,11 +28,11 @@ def migrations_not_ordered_alphabetically():
|
|||
|
||||
@migrations()
|
||||
def m002(db):
|
||||
db["dogs"].insert({"name": "Cleo"})
|
||||
db.table("dogs").insert({"name": "Cleo"})
|
||||
|
||||
@migrations()
|
||||
def m001(db):
|
||||
db["cats"].create({"name": str})
|
||||
db.table("cats").create({"name": str})
|
||||
db.execute("insert into dogs (name) values ('Pancakes')")
|
||||
|
||||
return migrations
|
||||
|
|
@ -43,7 +44,7 @@ def migrations2():
|
|||
|
||||
@migrations()
|
||||
def m001(db):
|
||||
db["dogs2"].insert({"name": "Cleo"})
|
||||
db.table("dogs2").insert({"name": "Cleo"})
|
||||
|
||||
return migrations
|
||||
|
||||
|
|
@ -95,7 +96,7 @@ def test_applied_at_is_a_string(migrations):
|
|||
def test_failing_migration_rolls_back(migrations):
|
||||
@migrations()
|
||||
def m003(db):
|
||||
db["birds"].create({"name": str})
|
||||
db.table("birds").create({"name": str})
|
||||
db.execute("insert into dogs (name) values ('Dozer')")
|
||||
raise ValueError("boom")
|
||||
|
||||
|
|
@ -104,7 +105,7 @@ def test_failing_migration_rolls_back(migrations):
|
|||
migrations.apply(db)
|
||||
# m001 and m002 committed before the failure and stay applied
|
||||
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"]
|
||||
# Everything m003 did was rolled back and it is still pending
|
||||
assert [m.name for m in migrations.pending(db)] == ["m003"]
|
||||
|
|
@ -116,11 +117,11 @@ def test_rerun_after_failure_applies_each_migration_once():
|
|||
|
||||
@migrations()
|
||||
def m001(db):
|
||||
db["dogs"].insert({"name": "Cleo"})
|
||||
db.table("dogs").insert({"name": "Cleo"})
|
||||
|
||||
@migrations()
|
||||
def m002(db):
|
||||
db["dogs"].insert({"name": "Pancakes"})
|
||||
db.table("dogs").insert({"name": "Pancakes"})
|
||||
if state["fail"]:
|
||||
raise ValueError("boom")
|
||||
|
||||
|
|
@ -130,7 +131,7 @@ def test_rerun_after_failure_applies_each_migration_once():
|
|||
state["fail"] = False
|
||||
migrations.apply(db)
|
||||
# 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"]
|
||||
|
||||
|
||||
|
|
@ -141,7 +142,7 @@ def test_non_transactional_migration_allows_vacuum(tmpdir):
|
|||
|
||||
@migrations()
|
||||
def m001(db):
|
||||
db["dogs"].insert({"name": "Cleo"})
|
||||
db.table("dogs").insert({"name": "Cleo"})
|
||||
|
||||
@migrations(transactional=False)
|
||||
def m002(db):
|
||||
|
|
@ -154,8 +155,7 @@ def test_non_transactional_migration_allows_vacuum(tmpdir):
|
|||
|
||||
def test_apply_composes_inside_outer_transaction(migrations):
|
||||
db = sqlite_utils.Database(memory=True)
|
||||
with pytest.raises(ZeroDivisionError):
|
||||
with db.atomic():
|
||||
with pytest.raises(ZeroDivisionError), db.atomic():
|
||||
migrations.apply(db)
|
||||
raise ZeroDivisionError
|
||||
# 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):
|
||||
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["_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)
|
||||
assert db["_sqlite_migrations"].pks == ["id"]
|
||||
assert db.table("_sqlite_migrations").pks == ["id"]
|
||||
|
||||
|
||||
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 "already been applied" in str(ex.value)
|
||||
# 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):
|
||||
|
|
@ -238,9 +240,9 @@ def test_stop_before_applied_migration_errors_before_any_apply(migrations):
|
|||
|
||||
@only_second()
|
||||
def m002(db):
|
||||
db["cats"].create({"name": str})
|
||||
db.table("cats").create({"name": str})
|
||||
|
||||
only_second.apply(db) # m002 applied, m001 still pending
|
||||
with pytest.raises(ValueError):
|
||||
migrations.apply(db, stop_before="m002")
|
||||
assert not db["dogs"].exists()
|
||||
assert not db.table("dogs").exists()
|
||||
|
|
|
|||
154
tests/test_mutator_transactions.py
Normal file
154
tests/test_mutator_transactions.py
Normal 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
|
||||
|
|
@ -1,9 +1,12 @@
|
|||
from click.testing import CliRunner
|
||||
import click
|
||||
import importlib
|
||||
import pytest
|
||||
import sqlite3
|
||||
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():
|
||||
|
|
@ -11,7 +14,7 @@ def _supports_pragma_function_list():
|
|||
try:
|
||||
db.execute("select * from pragma_function_list()")
|
||||
return True
|
||||
except Exception:
|
||||
except sqlite3.DatabaseError:
|
||||
return False
|
||||
finally:
|
||||
db.close()
|
||||
|
|
|
|||
|
|
@ -1,11 +1,12 @@
|
|||
import pytest
|
||||
import types
|
||||
|
||||
import pytest
|
||||
|
||||
from sqlite_utils.utils import sqlite3
|
||||
|
||||
|
||||
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")
|
||||
assert isinstance(results, types.GeneratorType)
|
||||
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):
|
||||
fresh_db["dogs"].insert({"name": "Cleo"})
|
||||
fresh_db.table("dogs").insert({"name": "Cleo"})
|
||||
with pytest.raises(ValueError) as ex:
|
||||
fresh_db.query("update dogs set name = 'Cleopaws'")
|
||||
assert "execute()" in str(ex.value)
|
||||
# The rejected update was rolled back, and no transaction is left open
|
||||
assert not fresh_db.conn.in_transaction
|
||||
assert [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):
|
||||
|
|
@ -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):
|
||||
fresh_db["dogs"].insert({"name": "Cleo"})
|
||||
fresh_db.table("dogs").insert({"name": "Cleo"})
|
||||
fresh_db.begin()
|
||||
fresh_db.execute("insert into dogs (name) values ('Pancakes')")
|
||||
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
|
||||
assert fresh_db.conn.in_transaction
|
||||
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(
|
||||
|
|
@ -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
|
||||
# keyword check - previously it committed the caller's open
|
||||
# transaction before the ValueError was raised
|
||||
fresh_db["dogs"].insert({"name": "Cleo"})
|
||||
fresh_db.table("dogs").insert({"name": "Cleo"})
|
||||
fresh_db.begin()
|
||||
fresh_db.execute("insert into dogs (name) values ('Pancakes')")
|
||||
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
|
||||
assert fresh_db.conn.in_transaction
|
||||
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"])
|
||||
|
|
@ -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
|
||||
# '; COMMIT' slipped past the check and committed the caller's open
|
||||
# transaction before raising OperationalError
|
||||
fresh_db["dogs"].insert({"name": "Cleo"})
|
||||
fresh_db.table("dogs").insert({"name": "Cleo"})
|
||||
fresh_db.begin()
|
||||
fresh_db.execute("insert into dogs (name) values ('Pancakes')")
|
||||
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
|
||||
assert fresh_db.conn.in_transaction
|
||||
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):
|
||||
|
|
@ -189,12 +190,12 @@ def test_first_keyword(sql, expected):
|
|||
reason="RETURNING requires SQLite 3.35.0 or higher",
|
||||
)
|
||||
def test_query_insert_returning(fresh_db):
|
||||
fresh_db["dogs"].insert({"name": "Cleo"})
|
||||
fresh_db.table("dogs").insert({"name": "Cleo"})
|
||||
rows = list(
|
||||
fresh_db.query("insert into dogs (name) values ('Pancakes') returning name")
|
||||
)
|
||||
assert rows == [{"name": "Pancakes"}]
|
||||
assert fresh_db["dogs"].count == 2
|
||||
assert fresh_db.table("dogs").count == 2
|
||||
|
||||
|
||||
@pytest.mark.skipif(
|
||||
|
|
@ -206,7 +207,7 @@ def test_query_insert_returning_commits_without_iteration(tmpdir):
|
|||
|
||||
path = str(tmpdir / "test.db")
|
||||
db = Database(path)
|
||||
db["dogs"].insert({"name": "Cleo"})
|
||||
db.table("dogs").insert({"name": "Cleo"})
|
||||
# Never iterate over the results
|
||||
db.query("insert into dogs (name) values ('Pancakes') returning name")
|
||||
assert not db.conn.in_transaction
|
||||
|
|
@ -226,7 +227,7 @@ def test_query_insert_returning_partial_iteration_still_commits(tmpdir):
|
|||
|
||||
path = str(tmpdir / "test.db")
|
||||
db = Database(path)
|
||||
db["dogs"].insert({"name": "Cleo"})
|
||||
db.table("dogs").insert({"name": "Cleo"})
|
||||
row = next(
|
||||
db.query(
|
||||
"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",
|
||||
)
|
||||
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()
|
||||
rows = list(
|
||||
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
|
||||
assert fresh_db.conn.in_transaction
|
||||
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):
|
||||
# https://github.com/simonw/sqlite-utils/issues/624
|
||||
fresh_db["one"].insert({"id": 1, "value": "left"})
|
||||
fresh_db["two"].insert({"id": 2, "value": "right"})
|
||||
fresh_db.table("one").insert({"id": 1, "value": "left"})
|
||||
fresh_db.table("two").insert({"id": 2, "value": "right"})
|
||||
rows = list(
|
||||
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):
|
||||
# Like db.query() but returns a list, included for backwards compatibility
|
||||
# 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") == [
|
||||
{"id": 1, "bar": 2}
|
||||
]
|
||||
|
|
|
|||
|
|
@ -1,12 +1,14 @@
|
|||
import json
|
||||
|
||||
import pytest
|
||||
|
||||
from sqlite_utils import recipes
|
||||
from sqlite_utils.utils import sqlite3
|
||||
import json
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
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": 2, "dt": "6th October 2019 00:05:06"},
|
||||
|
|
@ -19,8 +21,8 @@ def dates_db(fresh_db):
|
|||
|
||||
|
||||
def test_parsedate(dates_db):
|
||||
dates_db["example"].convert("dt", recipes.parsedate)
|
||||
assert list(dates_db["example"].rows) == [
|
||||
dates_db.table("example").convert("dt", recipes.parsedate)
|
||||
assert list(dates_db.table("example").rows) == [
|
||||
{"id": 1, "dt": "2019-10-05"},
|
||||
{"id": 2, "dt": "2019-10-06"},
|
||||
{"id": 3, "dt": ""},
|
||||
|
|
@ -29,8 +31,8 @@ def test_parsedate(dates_db):
|
|||
|
||||
|
||||
def test_parsedatetime(dates_db):
|
||||
dates_db["example"].convert("dt", recipes.parsedatetime)
|
||||
assert list(dates_db["example"].rows) == [
|
||||
dates_db.table("example").convert("dt", recipes.parsedatetime)
|
||||
assert list(dates_db.table("example").rows) == [
|
||||
{"id": 1, "dt": "2019-10-05T12:04:00"},
|
||||
{"id": 2, "dt": "2019-10-06T00:05:06"},
|
||||
{"id": 3, "dt": ""},
|
||||
|
|
@ -48,16 +50,16 @@ def test_parsedatetime(dates_db):
|
|||
),
|
||||
)
|
||||
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"},
|
||||
],
|
||||
pk="id",
|
||||
)
|
||||
fresh_db["example"].convert(
|
||||
fresh_db.table("example").convert(
|
||||
"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},
|
||||
]
|
||||
|
||||
|
|
@ -66,7 +68,7 @@ def test_dayfirst_yearfirst(fresh_db, recipe, kwargs, expected):
|
|||
@pytest.mark.parametrize("fn", ("parsedate", "parsedatetime"))
|
||||
def test_dateparse_errors_raises(fresh_db, fn):
|
||||
"""Test that invalid dates raise errors when errors=None"""
|
||||
fresh_db["example"].insert_all(
|
||||
fresh_db.table("example").insert_all(
|
||||
[
|
||||
{"id": 1, "dt": "invalid"},
|
||||
],
|
||||
|
|
@ -74,30 +76,32 @@ def test_dateparse_errors_raises(fresh_db, fn):
|
|||
)
|
||||
# Exception in SQLite callback surfaces as 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("errors", (recipes.SET_NULL, recipes.IGNORE))
|
||||
def test_dateparse_errors_handled(fresh_db, fn, errors):
|
||||
"""Test error handling modes for invalid dates"""
|
||||
fresh_db["example"].insert_all(
|
||||
fresh_db.table("example").insert_all(
|
||||
[
|
||||
{"id": 1, "dt": "invalid"},
|
||||
],
|
||||
pk="id",
|
||||
)
|
||||
fresh_db["example"].convert(
|
||||
fresh_db.table("example").convert(
|
||||
"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"}]
|
||||
assert rows == expected
|
||||
|
||||
|
||||
@pytest.mark.parametrize("delimiter", [None, ";", "-"])
|
||||
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": 2, "tags": (delimiter or ",").join(["bar", "baz"])},
|
||||
|
|
@ -112,8 +116,8 @@ def test_jsonsplit(fresh_db, delimiter):
|
|||
else:
|
||||
fn = recipes.jsonsplit
|
||||
|
||||
fresh_db["example"].convert("tags", fn)
|
||||
assert list(fresh_db["example"].rows) == [
|
||||
fresh_db.table("example").convert("tags", fn)
|
||||
assert list(fresh_db.table("example").rows) == [
|
||||
{"id": 1, "tags": '["foo", "bar"]'},
|
||||
{"id": 2, "tags": '["bar", "baz"]'},
|
||||
]
|
||||
|
|
@ -128,7 +132,7 @@ def test_jsonsplit(fresh_db, delimiter):
|
|||
),
|
||||
)
|
||||
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"},
|
||||
],
|
||||
|
|
@ -142,5 +146,5 @@ def test_jsonsplit_type(fresh_db, type, expected):
|
|||
else:
|
||||
fn = recipes.jsonsplit
|
||||
|
||||
fresh_db["example"].convert("records", fn)
|
||||
assert json.loads(fresh_db["example"].get(1)["records"]) == expected
|
||||
fresh_db.table("example").convert("records", fn)
|
||||
assert json.loads(fresh_db.table("example").get(1)["records"]) == expected
|
||||
|
|
|
|||
|
|
@ -1,8 +1,10 @@
|
|||
from sqlite_utils import Database
|
||||
import sqlite3
|
||||
import pathlib
|
||||
import sqlite3
|
||||
|
||||
import pytest
|
||||
|
||||
from sqlite_utils import Database
|
||||
|
||||
|
||||
def test_recreate_ignored_for_in_memory():
|
||||
# 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)
|
||||
if create_file_first:
|
||||
db = Database(filepath)
|
||||
db["t1"].insert({"foo": "bar"})
|
||||
db.table("t1").insert({"foo": "bar"})
|
||||
assert ["t1"] == db.table_names()
|
||||
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()
|
||||
|
|
|
|||
|
|
@ -86,21 +86,21 @@ def test_register_function_deterministic_tries_again_if_exception_raised(fresh_d
|
|||
|
||||
def test_register_function_replace(fresh_db):
|
||||
@fresh_db.register_function()
|
||||
def one():
|
||||
def one(): # pyright: ignore[reportRedeclaration]
|
||||
return "one"
|
||||
|
||||
assert "one" == fresh_db.execute("select one()").fetchone()[0]
|
||||
|
||||
# This will silently fail to replaec the function
|
||||
@fresh_db.register_function()
|
||||
def one(): # noqa
|
||||
def one(): # pyright: ignore[reportRedeclaration]
|
||||
return "two"
|
||||
|
||||
assert "one" == fresh_db.execute("select one()").fetchone()[0]
|
||||
|
||||
# This will replace it
|
||||
@fresh_db.register_function(replace=True)
|
||||
def one(): # noqa
|
||||
def one(): # pyright: ignore[reportRedeclaration]
|
||||
return "two"
|
||||
|
||||
assert "two" == fresh_db.execute("select one()").fetchone()[0]
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import pytest
|
|||
|
||||
def test_rows(existing_db):
|
||||
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):
|
||||
table = fresh_db["dogs"]
|
||||
table = fresh_db.table("dogs")
|
||||
table.insert_all(
|
||||
[
|
||||
{"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):
|
||||
table = fresh_db["dogs"]
|
||||
table = fresh_db.table("dogs")
|
||||
table.insert_all(
|
||||
[
|
||||
{"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]),
|
||||
(0, 3, [1, 2, 3]),
|
||||
(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):
|
||||
table = fresh_db["rows"]
|
||||
table = fresh_db.table("rows")
|
||||
table.insert_all([{"id": id} for id in range(1, 101)], pk="id")
|
||||
assert table.count == 100
|
||||
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):
|
||||
table = fresh_db["rowid_table"]
|
||||
table = fresh_db.table("rowid_table")
|
||||
table.insert_all({"number": i + 10} for i in range(3))
|
||||
pks_and_rows = list(table.pks_and_rows_where())
|
||||
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):
|
||||
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")
|
||||
pks_and_rows = list(table.pks_and_rows_where())
|
||||
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):
|
||||
table = fresh_db["compound_pk_table"]
|
||||
table = fresh_db.table("compound_pk_table")
|
||||
table.insert_all(
|
||||
({"type": "number", "number": i, "plusone": i + 1} for i in range(3)),
|
||||
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):
|
||||
# https://github.com/simonw/sqlite-utils/issues/624
|
||||
fresh_db["t"].insert({"id": 1, "name": "Cleo"})
|
||||
rows = list(fresh_db["t"].rows_where(select="id, id, name"))
|
||||
fresh_db.table("t").insert({"id": 1, "name": "Cleo"})
|
||||
rows = list(fresh_db.table("t").rows_where(select="id, id, name"))
|
||||
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
|
||||
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")
|
||||
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:
|
||||
pass # SQLite 3.36+: no such column: rowid
|
||||
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):
|
||||
# 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["t"].insert({"a": "A", "b": "B"})
|
||||
pks_and_rows = list(fresh_db["t"].pks_and_rows_where())
|
||||
fresh_db.table("t").insert({"a": "A", "b": "B"})
|
||||
pks_and_rows = list(fresh_db.table("t").pks_and_rows_where())
|
||||
assert pks_and_rows == [(("A", "B"), {"b": "B", "a": "A"})]
|
||||
|
|
|
|||
|
|
@ -1,7 +1,9 @@
|
|||
from sqlite_utils.utils import rows_from_file, Format, RowError
|
||||
from io import BytesIO, StringIO
|
||||
|
||||
import pytest
|
||||
|
||||
from sqlite_utils.utils import Format, RowError, rows_from_file
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"input,expected_format",
|
||||
|
|
@ -18,6 +20,13 @@ def test_rows_from_file_detect_format(input, expected_format):
|
|||
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(
|
||||
"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):
|
||||
try:
|
||||
rows, format = rows_from_file(
|
||||
rows, _format = rows_from_file(
|
||||
BytesIO(b"id,name\r\n1,Cleo,oops"),
|
||||
format=Format.CSV,
|
||||
ignore_extras=ignore_extras,
|
||||
|
|
|
|||
|
|
@ -1,7 +1,9 @@
|
|||
from sqlite_utils import cli, Database
|
||||
from click.testing import CliRunner
|
||||
import pathlib
|
||||
|
||||
import pytest
|
||||
from click.testing import CliRunner
|
||||
|
||||
from sqlite_utils import Database, cli
|
||||
|
||||
sniff_dir = pathlib.Path(__file__).parent / "sniff"
|
||||
|
||||
|
|
@ -17,7 +19,7 @@ def test_sniff(tmpdir, filepath):
|
|||
)
|
||||
assert result.exit_code == 0, result.stdout
|
||||
db = Database(db_path)
|
||||
assert list(db["creatures"].rows) == [
|
||||
assert list(db.table("creatures").rows) == [
|
||||
{"id": "1", "species": "dog", "name": "Cleo", "age": "5"},
|
||||
{"id": "2", "species": "dog", "name": "Pancakes", "age": "4"},
|
||||
{"id": "3", "species": "cat", "name": "Mozie", "age": "8"},
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
import pytest
|
||||
from collections import OrderedDict
|
||||
|
||||
import pytest
|
||||
|
||||
from sqlite_utils.utils import suggest_column_types
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -52,6 +52,7 @@ def test_with_tracer():
|
|||
|
||||
assert len(collected) == 4
|
||||
assert collected == [
|
||||
(
|
||||
(
|
||||
"SELECT name FROM sqlite_master\n"
|
||||
" WHERE rootpage = 0\n"
|
||||
|
|
@ -62,7 +63,8 @@ def test_with_tracer():
|
|||
" tbl_name = :table\n"
|
||||
" AND sql LIKE '%VIRTUAL TABLE%USING FTS%'\n"
|
||||
" )\n"
|
||||
" )",
|
||||
" )"
|
||||
),
|
||||
{
|
||||
"like": "%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 sql from sqlite_master where name = ?", ("dogs_fts",)),
|
||||
(
|
||||
(
|
||||
'with "original" as (\n'
|
||||
" select\n"
|
||||
|
|
@ -86,7 +89,8 @@ def test_with_tracer():
|
|||
"where\n"
|
||||
' "dogs_fts" match :query\n'
|
||||
"order by\n"
|
||||
' "dogs_fts".rank',
|
||||
' "dogs_fts".rank'
|
||||
),
|
||||
{"query": "Cleopaws"},
|
||||
),
|
||||
]
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -7,14 +7,14 @@ from sqlite_utils.db import NotFoundError
|
|||
|
||||
|
||||
def test_update_rowid_table(fresh_db):
|
||||
table = fresh_db["table"]
|
||||
table = fresh_db.table("table")
|
||||
rowid = table.insert({"foo": "bar"}).last_pk
|
||||
table.update(rowid, {"foo": "baz"})
|
||||
assert [{"foo": "baz"}] == list(table.rows)
|
||||
|
||||
|
||||
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
|
||||
assert 5 == pk
|
||||
table.update(pk, {"foo": "baz"})
|
||||
|
|
@ -22,7 +22,7 @@ def test_update_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
|
||||
assert (5, 3) == pk
|
||||
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):
|
||||
table = fresh_db["table"]
|
||||
table.insert({"id1": 5, "id2": 3, "v": 1}, pk=pk).last_pk
|
||||
table = fresh_db.table("table")
|
||||
table.insert({"id1": 5, "id2": 3, "v": 1}, pk=pk)
|
||||
with pytest.raises(NotFoundError):
|
||||
table.update(update_pk, {"v": 2})
|
||||
|
||||
|
||||
def test_update_alter(fresh_db):
|
||||
table = fresh_db["table"]
|
||||
table = fresh_db.table("table")
|
||||
rowid = table.insert({"foo": "bar"}).last_pk
|
||||
table.update(rowid, {"new_col": 1.2}, alter=True)
|
||||
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):
|
||||
# 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
|
||||
table.update(rowid, {"new_col[abc]": 1.2}, alter=True)
|
||||
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):
|
||||
fresh_db["test"].insert({"id": 1, "data": ""}, pk="id")
|
||||
fresh_db["test"].update(1, {"data": data_structure})
|
||||
fresh_db.table("test").insert({"id": 1, "data": ""}, pk="id")
|
||||
fresh_db.table("test").update(1, {"data": data_structure})
|
||||
row = fresh_db.execute("select id, data from test").fetchone()
|
||||
assert row[0] == 1
|
||||
assert data_structure == json.loads(row[1])
|
||||
|
|
|
|||
|
|
@ -1,20 +1,21 @@
|
|||
from sqlite_utils.db import PrimaryKeyRequired
|
||||
from sqlite_utils import Database
|
||||
import pytest
|
||||
|
||||
from sqlite_utils import Database
|
||||
from sqlite_utils.db import PrimaryKeyRequired
|
||||
|
||||
|
||||
@pytest.mark.parametrize("use_old_upsert", (False, True))
|
||||
def test_upsert(use_old_upsert):
|
||||
db = Database(memory=True, use_old_upsert=use_old_upsert)
|
||||
table = db["table"]
|
||||
table.insert({"id": 1, "name": "Cleo"}, pk="id")
|
||||
table = db.table("table")
|
||||
table.insert_all([{"id": 1, "name": "Cleo"}], pk="id", replace=True)
|
||||
table.upsert({"id": 1, "age": 5}, pk="id", alter=True)
|
||||
assert list(table.rows) == [{"id": 1, "name": "Cleo", "age": 5}]
|
||||
assert table.last_pk == 1
|
||||
|
||||
|
||||
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, "age": 5}, {"id": 2, "age": 5}], pk="id", alter=True)
|
||||
assert list(table.rows) == [
|
||||
|
|
@ -25,7 +26,7 @@ def test_upsert_all(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")
|
||||
assert list(table.rows) == [{"name": "Cleo"}]
|
||||
assert table.pks == ["name"]
|
||||
|
|
@ -33,16 +34,16 @@ def test_upsert_all_single_column(fresh_db):
|
|||
|
||||
def test_upsert_all_not_null(fresh_db):
|
||||
# https://github.com/simonw/sqlite-utils/issues/538
|
||||
fresh_db["comments"].upsert_all(
|
||||
fresh_db.table("comments").upsert_all(
|
||||
[{"id": 1, "name": "Cleo"}],
|
||||
pk="id",
|
||||
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):
|
||||
table = fresh_db["table"]
|
||||
table = fresh_db.table("table")
|
||||
with pytest.raises(PrimaryKeyRequired):
|
||||
table.upsert_all([{"id": 1, "name": "Cleo"}])
|
||||
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))
|
||||
def test_upsert_empty_record_errors(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")
|
||||
with pytest.raises(PrimaryKeyRequired):
|
||||
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))
|
||||
def test_upsert_missing_pk_value_errors(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")
|
||||
# Records that omit the pk column entirely
|
||||
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):
|
||||
table = fresh_db["table"]
|
||||
table = fresh_db.table("table")
|
||||
table.insert({"a": "x", "b": "y", "v": 1}, pk=("a", "b"))
|
||||
# Missing one component of the detected compound primary key
|
||||
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)
|
||||
)
|
||||
""")
|
||||
table = db["summary"]
|
||||
table = db.table("summary")
|
||||
table.upsert(
|
||||
{
|
||||
"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):
|
||||
table = fresh_db["table"]
|
||||
table = fresh_db.table("table")
|
||||
table.upsert({"foo": "bar"}, hash_id="pk")
|
||||
assert [{"pk": "a5e744d0164540d33b1d7ea616c28f2fa97e754a", "foo": "bar"}] == list(
|
||||
table.rows
|
||||
|
|
@ -143,7 +144,7 @@ def test_upsert_with_hash_id(fresh_db):
|
|||
|
||||
@pytest.mark.parametrize("hash_id", (None, "custom_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"))
|
||||
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):
|
||||
table = fresh_db["table"]
|
||||
table = fresh_db.table("table")
|
||||
table.upsert_all(
|
||||
[
|
||||
{"species": "dog", "id": 1, "name": "Cleo", "age": 4},
|
||||
|
|
|
|||
|
|
@ -1,8 +1,10 @@
|
|||
from sqlite_utils import utils
|
||||
import csv
|
||||
import io
|
||||
|
||||
import pytest
|
||||
|
||||
from sqlite_utils import utils
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"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
|
||||
csv.field_size_limit(utils.ORIGINAL_CSV_FIELD_SIZE_LIMIT)
|
||||
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"))
|
||||
# Using rows_from_file should error
|
||||
with pytest.raises(csv.Error):
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import pytest
|
||||
|
||||
from sqlite_utils import Database
|
||||
from sqlite_utils.db import TransactionError
|
||||
|
||||
|
|
@ -11,13 +12,13 @@ def db_path_tmpdir(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 "delete" == db.journal_mode
|
||||
assert "test.db-wal" not in [f.basename for f in tmpdir.listdir()]
|
||||
db.enable_wal()
|
||||
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()]
|
||||
db.disable_wal()
|
||||
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):
|
||||
db, path, tmpdir = db_path_tmpdir
|
||||
db["test"].insert({"id": 1}, pk="id")
|
||||
with pytest.raises(TransactionError):
|
||||
with db.atomic():
|
||||
db["test"].insert({"id": 2}, pk="id")
|
||||
db, _path, _tmpdir = db_path_tmpdir
|
||||
db.table("test").insert({"id": 1}, pk="id")
|
||||
with pytest.raises(TransactionError), db.atomic():
|
||||
db.table("test").insert({"id": 2}, pk="id")
|
||||
db.enable_wal()
|
||||
# The atomic() block must have rolled back cleanly and the
|
||||
# journal mode must be unchanged
|
||||
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):
|
||||
db, path, tmpdir = db_path_tmpdir
|
||||
db, _path, _tmpdir = db_path_tmpdir
|
||||
db.enable_wal()
|
||||
db["test"].insert({"id": 1}, pk="id")
|
||||
with pytest.raises(TransactionError):
|
||||
with db.atomic():
|
||||
db["test"].insert({"id": 2}, pk="id")
|
||||
db.table("test").insert({"id": 1}, pk="id")
|
||||
with pytest.raises(TransactionError), db.atomic():
|
||||
db.table("test").insert({"id": 2}, pk="id")
|
||||
db.disable_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):
|
||||
db, path, tmpdir = db_path_tmpdir
|
||||
db, _path, _tmpdir = db_path_tmpdir
|
||||
previous_isolation_level = db.conn.isolation_level
|
||||
assert previous_isolation_level is not None
|
||||
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):
|
||||
# Calling enable_wal() when WAL is already enabled is a no-op,
|
||||
# so it is fine inside a transaction
|
||||
db, path, tmpdir = db_path_tmpdir
|
||||
db, _path, _tmpdir = db_path_tmpdir
|
||||
db.enable_wal()
|
||||
with db.atomic():
|
||||
db["test"].insert({"id": 1}, pk="id")
|
||||
db.table("test").insert({"id": 1}, pk="id")
|
||||
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):
|
||||
# Setting isolation_level commits any pending transaction as a side
|
||||
# effect, silently breaking the caller's rollback guarantee - so
|
||||
# entering autocommit mode with a transaction open is an error
|
||||
db, path, tmpdir = db_path_tmpdir
|
||||
db["test"].insert({"id": 1}, pk="id")
|
||||
db, _path, _tmpdir = db_path_tmpdir
|
||||
db.table("test").insert({"id": 1}, pk="id")
|
||||
db.begin()
|
||||
db.execute("insert into test (id) values (2)")
|
||||
with pytest.raises(TransactionError):
|
||||
with db.ensure_autocommit_on():
|
||||
with pytest.raises(TransactionError), db.ensure_autocommit_on():
|
||||
pass
|
||||
# The transaction is still open and can still be rolled back
|
||||
assert db.conn.in_transaction
|
||||
db.rollback()
|
||||
assert [r["id"] for r in db["test"].rows] == [1]
|
||||
assert [r["id"] for r in db.table("test").rows] == [1]
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue