sqlite-utils 3.38a0__tar.gz → 3.39__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (77) hide show
  1. {sqlite_utils-3.38a0/sqlite_utils.egg-info → sqlite_utils-3.39}/PKG-INFO +18 -7
  2. {sqlite_utils-3.38a0 → sqlite_utils-3.39}/docs/changelog.rst +21 -0
  3. {sqlite_utils-3.38a0 → sqlite_utils-3.39}/docs/cli-reference.rst +4 -4
  4. {sqlite_utils-3.38a0 → sqlite_utils-3.39}/docs/cli.rst +18 -2
  5. {sqlite_utils-3.38a0 → sqlite_utils-3.39}/docs/python-api.rst +9 -3
  6. {sqlite_utils-3.38a0 → sqlite_utils-3.39}/setup.py +5 -6
  7. {sqlite_utils-3.38a0 → sqlite_utils-3.39}/sqlite_utils/cli.py +35 -9
  8. {sqlite_utils-3.38a0 → sqlite_utils-3.39}/sqlite_utils/db.py +47 -4
  9. {sqlite_utils-3.38a0 → sqlite_utils-3.39/sqlite_utils.egg-info}/PKG-INFO +18 -7
  10. {sqlite_utils-3.38a0 → sqlite_utils-3.39}/sqlite_utils.egg-info/requires.txt +2 -1
  11. {sqlite_utils-3.38a0 → sqlite_utils-3.39}/tests/test_cli.py +76 -3
  12. {sqlite_utils-3.38a0 → sqlite_utils-3.39}/tests/test_cli_bulk.py +26 -0
  13. {sqlite_utils-3.38a0 → sqlite_utils-3.39}/tests/test_cli_memory.py +16 -0
  14. {sqlite_utils-3.38a0 → sqlite_utils-3.39}/tests/test_create.py +7 -1
  15. {sqlite_utils-3.38a0 → sqlite_utils-3.39}/tests/test_fts.py +27 -0
  16. {sqlite_utils-3.38a0 → sqlite_utils-3.39}/tests/test_recipes.py +1 -0
  17. {sqlite_utils-3.38a0 → sqlite_utils-3.39}/tests/test_transform.py +123 -1
  18. {sqlite_utils-3.38a0 → sqlite_utils-3.39}/LICENSE +0 -0
  19. {sqlite_utils-3.38a0 → sqlite_utils-3.39}/MANIFEST.in +0 -0
  20. {sqlite_utils-3.38a0 → sqlite_utils-3.39}/README.md +0 -0
  21. {sqlite_utils-3.38a0 → sqlite_utils-3.39}/docs/contributing.rst +0 -0
  22. {sqlite_utils-3.38a0 → sqlite_utils-3.39}/docs/index.rst +0 -0
  23. {sqlite_utils-3.38a0 → sqlite_utils-3.39}/docs/installation.rst +0 -0
  24. {sqlite_utils-3.38a0 → sqlite_utils-3.39}/docs/plugins.rst +0 -0
  25. {sqlite_utils-3.38a0 → sqlite_utils-3.39}/docs/reference.rst +0 -0
  26. {sqlite_utils-3.38a0 → sqlite_utils-3.39}/setup.cfg +0 -0
  27. {sqlite_utils-3.38a0 → sqlite_utils-3.39}/sqlite_utils/__init__.py +0 -0
  28. {sqlite_utils-3.38a0 → sqlite_utils-3.39}/sqlite_utils/__main__.py +0 -0
  29. {sqlite_utils-3.38a0 → sqlite_utils-3.39}/sqlite_utils/hookspecs.py +0 -0
  30. {sqlite_utils-3.38a0 → sqlite_utils-3.39}/sqlite_utils/plugins.py +0 -0
  31. {sqlite_utils-3.38a0 → sqlite_utils-3.39}/sqlite_utils/py.typed +0 -0
  32. {sqlite_utils-3.38a0 → sqlite_utils-3.39}/sqlite_utils/recipes.py +0 -0
  33. {sqlite_utils-3.38a0 → sqlite_utils-3.39}/sqlite_utils/utils.py +0 -0
  34. {sqlite_utils-3.38a0 → sqlite_utils-3.39}/sqlite_utils.egg-info/SOURCES.txt +0 -0
  35. {sqlite_utils-3.38a0 → sqlite_utils-3.39}/sqlite_utils.egg-info/dependency_links.txt +0 -0
  36. {sqlite_utils-3.38a0 → sqlite_utils-3.39}/sqlite_utils.egg-info/entry_points.txt +0 -0
  37. {sqlite_utils-3.38a0 → sqlite_utils-3.39}/sqlite_utils.egg-info/not-zip-safe +0 -0
  38. {sqlite_utils-3.38a0 → sqlite_utils-3.39}/sqlite_utils.egg-info/top_level.txt +0 -0
  39. {sqlite_utils-3.38a0 → sqlite_utils-3.39}/tests/__init__.py +0 -0
  40. {sqlite_utils-3.38a0 → sqlite_utils-3.39}/tests/conftest.py +0 -0
  41. {sqlite_utils-3.38a0 → sqlite_utils-3.39}/tests/test_analyze.py +0 -0
  42. {sqlite_utils-3.38a0 → sqlite_utils-3.39}/tests/test_analyze_tables.py +0 -0
  43. {sqlite_utils-3.38a0 → sqlite_utils-3.39}/tests/test_attach.py +0 -0
  44. {sqlite_utils-3.38a0 → sqlite_utils-3.39}/tests/test_cli_convert.py +0 -0
  45. {sqlite_utils-3.38a0 → sqlite_utils-3.39}/tests/test_cli_insert.py +0 -0
  46. {sqlite_utils-3.38a0 → sqlite_utils-3.39}/tests/test_column_affinity.py +0 -0
  47. {sqlite_utils-3.38a0 → sqlite_utils-3.39}/tests/test_constructor.py +0 -0
  48. {sqlite_utils-3.38a0 → sqlite_utils-3.39}/tests/test_conversions.py +0 -0
  49. {sqlite_utils-3.38a0 → sqlite_utils-3.39}/tests/test_convert.py +0 -0
  50. {sqlite_utils-3.38a0 → sqlite_utils-3.39}/tests/test_create_view.py +0 -0
  51. {sqlite_utils-3.38a0 → sqlite_utils-3.39}/tests/test_default_value.py +0 -0
  52. {sqlite_utils-3.38a0 → sqlite_utils-3.39}/tests/test_delete.py +0 -0
  53. {sqlite_utils-3.38a0 → sqlite_utils-3.39}/tests/test_docs.py +0 -0
  54. {sqlite_utils-3.38a0 → sqlite_utils-3.39}/tests/test_duplicate.py +0 -0
  55. {sqlite_utils-3.38a0 → sqlite_utils-3.39}/tests/test_enable_counts.py +0 -0
  56. {sqlite_utils-3.38a0 → sqlite_utils-3.39}/tests/test_extract.py +0 -0
  57. {sqlite_utils-3.38a0 → sqlite_utils-3.39}/tests/test_extracts.py +0 -0
  58. {sqlite_utils-3.38a0 → sqlite_utils-3.39}/tests/test_get.py +0 -0
  59. {sqlite_utils-3.38a0 → sqlite_utils-3.39}/tests/test_gis.py +0 -0
  60. {sqlite_utils-3.38a0 → sqlite_utils-3.39}/tests/test_hypothesis.py +0 -0
  61. {sqlite_utils-3.38a0 → sqlite_utils-3.39}/tests/test_insert_files.py +0 -0
  62. {sqlite_utils-3.38a0 → sqlite_utils-3.39}/tests/test_introspect.py +0 -0
  63. {sqlite_utils-3.38a0 → sqlite_utils-3.39}/tests/test_lookup.py +0 -0
  64. {sqlite_utils-3.38a0 → sqlite_utils-3.39}/tests/test_m2m.py +0 -0
  65. {sqlite_utils-3.38a0 → sqlite_utils-3.39}/tests/test_plugins.py +0 -0
  66. {sqlite_utils-3.38a0 → sqlite_utils-3.39}/tests/test_query.py +0 -0
  67. {sqlite_utils-3.38a0 → sqlite_utils-3.39}/tests/test_recreate.py +0 -0
  68. {sqlite_utils-3.38a0 → sqlite_utils-3.39}/tests/test_register_function.py +0 -0
  69. {sqlite_utils-3.38a0 → sqlite_utils-3.39}/tests/test_rows.py +0 -0
  70. {sqlite_utils-3.38a0 → sqlite_utils-3.39}/tests/test_rows_from_file.py +0 -0
  71. {sqlite_utils-3.38a0 → sqlite_utils-3.39}/tests/test_sniff.py +0 -0
  72. {sqlite_utils-3.38a0 → sqlite_utils-3.39}/tests/test_suggest_column_types.py +0 -0
  73. {sqlite_utils-3.38a0 → sqlite_utils-3.39}/tests/test_tracer.py +0 -0
  74. {sqlite_utils-3.38a0 → sqlite_utils-3.39}/tests/test_update.py +0 -0
  75. {sqlite_utils-3.38a0 → sqlite_utils-3.39}/tests/test_upsert.py +0 -0
  76. {sqlite_utils-3.38a0 → sqlite_utils-3.39}/tests/test_utils.py +0 -0
  77. {sqlite_utils-3.38a0 → sqlite_utils-3.39}/tests/test_wal.py +0 -0
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.1
1
+ Metadata-Version: 2.4
2
2
  Name: sqlite-utils
3
- Version: 3.38a0
3
+ Version: 3.39
4
4
  Summary: CLI tool and Python library for manipulating SQLite databases
5
5
  Home-page: https://github.com/simonw/sqlite-utils
6
6
  Author: Simon Willison
@@ -15,22 +15,21 @@ Classifier: Intended Audience :: Developers
15
15
  Classifier: Intended Audience :: Science/Research
16
16
  Classifier: Intended Audience :: End Users/Desktop
17
17
  Classifier: Topic :: Database
18
- Classifier: License :: OSI Approved :: Apache Software License
19
- Classifier: Programming Language :: Python :: 3.8
20
- Classifier: Programming Language :: Python :: 3.9
21
18
  Classifier: Programming Language :: Python :: 3.10
22
19
  Classifier: Programming Language :: Python :: 3.11
23
20
  Classifier: Programming Language :: Python :: 3.12
24
21
  Classifier: Programming Language :: Python :: 3.13
25
- Requires-Python: >=3.8
22
+ Classifier: Programming Language :: Python :: 3.14
23
+ Requires-Python: >=3.10
26
24
  Description-Content-Type: text/markdown
27
25
  License-File: LICENSE
28
26
  Requires-Dist: sqlite-fts4
29
- Requires-Dist: click
27
+ Requires-Dist: click>=8.3.1
30
28
  Requires-Dist: click-default-group>=1.2.3
31
29
  Requires-Dist: tabulate
32
30
  Requires-Dist: python-dateutil
33
31
  Requires-Dist: pluggy
32
+ Requires-Dist: pip
34
33
  Provides-Extra: test
35
34
  Requires-Dist: pytest; extra == "test"
36
35
  Requires-Dist: black>=24.1.1; extra == "test"
@@ -54,6 +53,18 @@ Provides-Extra: flake8
54
53
  Requires-Dist: flake8; extra == "flake8"
55
54
  Provides-Extra: tui
56
55
  Requires-Dist: trogon; extra == "tui"
56
+ Dynamic: author
57
+ Dynamic: classifier
58
+ Dynamic: description
59
+ Dynamic: description-content-type
60
+ Dynamic: home-page
61
+ Dynamic: license
62
+ Dynamic: license-file
63
+ Dynamic: project-url
64
+ Dynamic: provides-extra
65
+ Dynamic: requires-dist
66
+ Dynamic: requires-python
67
+ Dynamic: summary
57
68
 
58
69
  # sqlite-utils
59
70
 
@@ -4,6 +4,27 @@
4
4
  Changelog
5
5
  ===========
6
6
 
7
+ .. _v3_39:
8
+
9
+ 3.39 (2025-11-24)
10
+ -----------------
11
+
12
+ - Fixed a bug with ``sqlite-utils install`` when the tool had been installed using ``uv``. (:issue:`687`)
13
+ - The ```--functions``` argument now optionally accepts a path to a Python file as an alternative to a string full of code, and can be specified multiple times - see :ref:`cli_query_functions`. (:issue:`659`)
14
+ - ``sqlite-utils`` now requires on Python 3.10 or higher.
15
+
16
+ `sqlite-utils 4.0a1 <https://sqlite-utils.datasette.io/en/latest/changelog.html#a1-2025-11-23>`__ is now available as an alpha with some `minor breaking changes <https://simonwillison.net/2025/Nov/24/sqlite-utils-40a1/>`__.
17
+
18
+ .. _v3_38:
19
+
20
+ 3.38 (2024-11-23)
21
+ -----------------
22
+
23
+ - Plugins can now reuse the implementation of the ``sqlite-utils memory`` CLI command with the new ``return_db=True`` parameter. (:issue:`643`)
24
+ - ``table.transform()`` now recreates indexes after transforming a table. A new ``sqlite_utils.db.TransformError`` exception is raised if these indexes cannot be recreated due to conflicting changes to the table such as a column rename. Thanks, `Mat Miller <https://github.com/matdmiller>`__. (:issue:`633`)
25
+ - ``table.search()`` now accepts a ``include_rank=True`` parameter, causing the resulting rows to have a ``rank`` column showing the calculated relevance score. Thanks, `liunux4odoo <https://github.com/liunux4odoo>`__. (`#628 <https://github.com/simonw/sqlite-utils/pull/628>`__)
26
+ - Fixed an error that occurred when creating a strict table with at least one floating point column. These ``FLOAT`` columns are now correctly created as ``REAL`` as well, but only for strict tables. (:issue:`644`)
27
+
7
28
  .. _v3_37:
8
29
 
9
30
  3.37 (2024-07-18)
@@ -132,7 +132,7 @@ See :ref:`cli_query`.
132
132
  -r, --raw Raw output, first column of first row
133
133
  --raw-lines Raw output, first column of each row
134
134
  -p, --param <TEXT TEXT>... Named :parameters for SQL query
135
- --functions TEXT Python code defining one or more custom SQL
135
+ --functions TEXT Python code or file path defining custom SQL
136
136
  functions
137
137
  --load-extension TEXT Path to SQLite extension, with optional
138
138
  :entrypoint
@@ -175,7 +175,7 @@ See :ref:`cli_memory`.
175
175
  sqlite-utils memory animals.csv --schema
176
176
 
177
177
  Options:
178
- --functions TEXT Python code defining one or more custom SQL
178
+ --functions TEXT Python code or file path defining custom SQL
179
179
  functions
180
180
  --attach <TEXT FILE>... Additional databases to attach - specify alias and
181
181
  filepath
@@ -375,7 +375,7 @@ See :ref:`cli_bulk`.
375
375
 
376
376
  Options:
377
377
  --batch-size INTEGER Commit every X records
378
- --functions TEXT Python code defining one or more custom SQL functions
378
+ --functions TEXT Python code or file path defining custom SQL functions
379
379
  --flatten Flatten nested JSON objects, so {"a": {"b": 1}} becomes
380
380
  {"a_b": 1}
381
381
  --nl Expect newline-delimited JSON
@@ -1497,7 +1497,7 @@ See :ref:`cli_spatialite`.
1497
1497
  paths. To load it from a specific path, use --load-extension.
1498
1498
 
1499
1499
  Options:
1500
- -t, --type [POINT|LINESTRING|POLYGON|MULTIPOINT|MULTILINESTRING|MULTIPOLYGON|GEOMETRYCOLLECTION|GEOMETRY]
1500
+ -t, --type [point|linestring|polygon|multipoint|multilinestring|multipolygon|geometrycollection|geometry]
1501
1501
  Specify a geometry type for this column.
1502
1502
  [default: GEOMETRY]
1503
1503
  --srid INTEGER Spatial Reference ID. See
@@ -368,6 +368,22 @@ This example defines a function which extracts the domain from a URL:
368
368
 
369
369
  Every callable object defined in the block will be registered as a SQL function with the same name, with the exception of functions with names that begin with an underscore.
370
370
 
371
+ You can also pass the path to a Python file containing function definitions:
372
+
373
+ .. code-block:: bash
374
+
375
+ sqlite-utils query sites.db "select url, domain(url) from urls" --functions functions.py
376
+
377
+ The ``--functions`` option can be used multiple times to load functions from multiple sources:
378
+
379
+ .. code-block:: bash
380
+
381
+ sqlite-utils query sites.db "select url, domain(url), extract_path(url) from urls" \
382
+ --functions domain_funcs.py \
383
+ --functions 'def extract_path(url):
384
+ from urllib.parse import urlparse
385
+ return urlparse(url).path'
386
+
371
387
  .. _cli_query_extensions:
372
388
 
373
389
  SQLite extensions
@@ -1128,7 +1144,7 @@ You can insert binary data into a BLOB column by first encoding it using base64
1128
1144
  Inserting newline-delimited JSON
1129
1145
  --------------------------------
1130
1146
 
1131
- You can also import `newline-delimited JSON <http://ndjson.org/>`__ using the ``--nl`` option:
1147
+ You can also import newline-delimited JSON (see `JSON Lines <https://jsonlines.org/>`__) using the ``--nl`` option:
1132
1148
 
1133
1149
  .. code-block:: bash
1134
1150
 
@@ -2796,7 +2812,7 @@ To enable this feature you will need to install the ``trogon`` dependency. You c
2796
2812
 
2797
2813
  .. code-block:: bash
2798
2814
 
2799
- sqite-utils install trogon
2815
+ sqlite-utils install trogon
2800
2816
 
2801
2817
  Once installed, running the ``sqlite-utils tui`` command will launch the TUI interface:
2802
2818
 
@@ -1249,6 +1249,9 @@ If you pass a Python type, it will be mapped to SQLite types as shown here::
1249
1249
  np.float32: "FLOAT"
1250
1250
  np.float64: "FLOAT"
1251
1251
 
1252
+ .. note::
1253
+ In sqlite-utils 3.x ``FLOAT`` is used for floating point columns when the correct column type is actually ``REAL``. If you specify ``strict=True`` tables created in strict mode will use the correct column type of ``REAL`` instead. We plan to change this behavior in ``sqlite-utils`` 4.x to always use ``REAL``, but this will represent a minor breaking change and so is being held for the next major release, see issue :issue:`645`.
1254
+
1252
1255
  You can also add a column that is a foreign key reference to another table using the ``fk`` parameter:
1253
1256
 
1254
1257
  .. code-block:: python
@@ -1402,6 +1405,8 @@ To keep the original table around instead of dropping it, pass the ``keep_table=
1402
1405
 
1403
1406
  table.transform(types={"age": int}, keep_table="original_table")
1404
1407
 
1408
+ 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.
1409
+
1405
1410
  .. _python_api_transform_alter_column_types:
1406
1411
 
1407
1412
  Altering column types
@@ -2309,6 +2314,9 @@ The ``.search()`` method also accepts the following optional parameters:
2309
2314
  ``where_args`` dictionary
2310
2315
  Arguments to use for ``:param`` placeholders in the extra WHERE clause
2311
2316
 
2317
+ ``include_rank`` bool
2318
+ If set a ``rank`` column will be included with the BM25 ranking score - for FTS5 tables only.
2319
+
2312
2320
  ``quote`` bool
2313
2321
  Apply :ref:`FTS quoting rules <python_api_quote_fts>` to the search query, disabling advanced query syntax in a way that avoids surprising errors.
2314
2322
 
@@ -2703,7 +2711,7 @@ By default, the name of the Python function will be used as the name of the SQL
2703
2711
 
2704
2712
  print(db.execute('select rev("hello")').fetchone()[0])
2705
2713
 
2706
- Python 3.8 added the ability to register `deterministic SQLite functions <https://sqlite.org/deterministic.html>`__, allowing you to indicate that a function will return the exact same result for any given inputs and hence allowing SQLite to apply some performance optimizations. You can mark a function as deterministic using ``deterministic=True``, like this:
2714
+ If a function will return the exact same result for any given inputs you can register it as a `deterministic SQLite function <https://sqlite.org/deterministic.html>`__ allowing SQLite to apply some performance optimizations:
2707
2715
 
2708
2716
  .. code-block:: python
2709
2717
 
@@ -2711,8 +2719,6 @@ Python 3.8 added the ability to register `deterministic SQLite functions <https:
2711
2719
  def reverse_string(s):
2712
2720
  return "".join(reversed(list(s)))
2713
2721
 
2714
- If you run this on a version of Python prior to 3.8 your code will still work, but the ``deterministic=True`` parameter will be ignored.
2715
-
2716
2722
  By default registering a function with the same name and number of arguments will have no effect - the ``Database`` instance keeps track of functions that have already been registered and skips registering them if ``@db.register_function`` is called a second time.
2717
2723
 
2718
2724
  If you want to deliberately replace the registered function with a new implementation, use the ``replace=True`` argument:
@@ -2,7 +2,7 @@ from setuptools import setup, find_packages
2
2
  import io
3
3
  import os
4
4
 
5
- VERSION = "3.38a0"
5
+ VERSION = "3.39"
6
6
 
7
7
 
8
8
  def get_long_description():
@@ -25,11 +25,12 @@ setup(
25
25
  package_data={"sqlite_utils": ["py.typed"]},
26
26
  install_requires=[
27
27
  "sqlite-fts4",
28
- "click",
28
+ "click>=8.3.1",
29
29
  "click-default-group>=1.2.3",
30
30
  "tabulate",
31
31
  "python-dateutil",
32
32
  "pluggy",
33
+ "pip",
33
34
  ],
34
35
  extras_require={
35
36
  "test": ["pytest", "black>=24.1.1", "hypothesis", "cogapp"],
@@ -64,20 +65,18 @@ setup(
64
65
  "Issues": "https://github.com/simonw/sqlite-utils/issues",
65
66
  "CI": "https://github.com/simonw/sqlite-utils/actions",
66
67
  },
67
- python_requires=">=3.8",
68
+ python_requires=">=3.10",
68
69
  classifiers=[
69
70
  "Development Status :: 5 - Production/Stable",
70
71
  "Intended Audience :: Developers",
71
72
  "Intended Audience :: Science/Research",
72
73
  "Intended Audience :: End Users/Desktop",
73
74
  "Topic :: Database",
74
- "License :: OSI Approved :: Apache Software License",
75
- "Programming Language :: Python :: 3.8",
76
- "Programming Language :: Python :: 3.9",
77
75
  "Programming Language :: Python :: 3.10",
78
76
  "Programming Language :: Python :: 3.11",
79
77
  "Programming Language :: Python :: 3.12",
80
78
  "Programming Language :: Python :: 3.13",
79
+ "Programming Language :: Python :: 3.14",
81
80
  ],
82
81
  # Needed to bundle py.typed so mypy can see it:
83
82
  zip_safe=False,
@@ -1,7 +1,7 @@
1
1
  import base64
2
2
  import click
3
3
  from click_default_group import DefaultGroup # type: ignore
4
- from datetime import datetime
4
+ from datetime import datetime, timezone
5
5
  import hashlib
6
6
  import pathlib
7
7
  from runpy import run_module
@@ -962,7 +962,7 @@ def insert_upsert_implementation(
962
962
  db = sqlite_utils.Database(path)
963
963
  _load_extensions(db, load_extension)
964
964
  if functions:
965
- _register_functions(db, functions)
965
+ _register_functions_from_multiple(db, functions)
966
966
  if (delimiter or quotechar or sniff or no_headers) and not tsv:
967
967
  csv = True
968
968
  if (nl + csv + tsv) >= 2:
@@ -1370,7 +1370,9 @@ def upsert(
1370
1370
  @click.argument("file", type=click.File("rb"), required=True)
1371
1371
  @click.option("--batch-size", type=int, default=100, help="Commit every X records")
1372
1372
  @click.option(
1373
- "--functions", help="Python code defining one or more custom SQL functions"
1373
+ "--functions",
1374
+ help="Python code or file path defining custom SQL functions",
1375
+ multiple=True,
1374
1376
  )
1375
1377
  @import_options
1376
1378
  @load_extension_option
@@ -1759,7 +1761,9 @@ def drop_view(path, view, ignore, load_extension):
1759
1761
  help="Named :parameters for SQL query",
1760
1762
  )
1761
1763
  @click.option(
1762
- "--functions", help="Python code defining one or more custom SQL functions"
1764
+ "--functions",
1765
+ help="Python code or file path defining custom SQL functions",
1766
+ multiple=True,
1763
1767
  )
1764
1768
  @load_extension_option
1765
1769
  def query(
@@ -1796,7 +1800,7 @@ def query(
1796
1800
  db.register_fts4_bm25()
1797
1801
 
1798
1802
  if functions:
1799
- _register_functions(db, functions)
1803
+ _register_functions_from_multiple(db, functions)
1800
1804
 
1801
1805
  _execute_query(
1802
1806
  db,
@@ -1824,7 +1828,9 @@ def query(
1824
1828
  )
1825
1829
  @click.argument("sql")
1826
1830
  @click.option(
1827
- "--functions", help="Python code defining one or more custom SQL functions"
1831
+ "--functions",
1832
+ help="Python code or file path defining custom SQL functions",
1833
+ multiple=True,
1828
1834
  )
1829
1835
  @click.option(
1830
1836
  "--attach",
@@ -1996,7 +2002,7 @@ def memory(
1996
2002
  db.register_fts4_bm25()
1997
2003
 
1998
2004
  if functions:
1999
- _register_functions(db, functions)
2005
+ _register_functions_from_multiple(db, functions)
2000
2006
 
2001
2007
  if return_db:
2002
2008
  return db
@@ -3203,8 +3209,12 @@ FILE_COLUMNS = {
3203
3209
  "ctime": lambda p: p.stat().st_ctime,
3204
3210
  "mtime_int": lambda p: int(p.stat().st_mtime),
3205
3211
  "ctime_int": lambda p: int(p.stat().st_ctime),
3206
- "mtime_iso": lambda p: datetime.utcfromtimestamp(p.stat().st_mtime).isoformat(),
3207
- "ctime_iso": lambda p: datetime.utcfromtimestamp(p.stat().st_ctime).isoformat(),
3212
+ "mtime_iso": lambda p: datetime.fromtimestamp(p.stat().st_mtime, timezone.utc)
3213
+ .replace(tzinfo=None)
3214
+ .isoformat(),
3215
+ "ctime_iso": lambda p: datetime.fromtimestamp(p.stat().st_ctime, timezone.utc)
3216
+ .replace(tzinfo=None)
3217
+ .isoformat(),
3208
3218
  "size": lambda p: p.stat().st_size,
3209
3219
  "stem": lambda p: p.stem,
3210
3220
  "suffix": lambda p: p.suffix,
@@ -3281,6 +3291,13 @@ def _load_extensions(db, load_extension):
3281
3291
 
3282
3292
  def _register_functions(db, functions):
3283
3293
  # Register any Python functions as SQL functions:
3294
+ # Check if this is a file path
3295
+ if "\n" not in functions and functions.endswith(".py"):
3296
+ try:
3297
+ functions = pathlib.Path(functions).read_text()
3298
+ except FileNotFoundError:
3299
+ raise click.ClickException("File not found: {}".format(functions))
3300
+
3284
3301
  sqlite3.enable_callback_tracebacks(True)
3285
3302
  globals = {}
3286
3303
  try:
@@ -3291,3 +3308,12 @@ def _register_functions(db, functions):
3291
3308
  for name, value in globals.items():
3292
3309
  if callable(value) and not name.startswith("_"):
3293
3310
  db.register_function(value, name=name)
3311
+
3312
+
3313
+ def _register_functions_from_multiple(db, functions_list):
3314
+ """Register functions from multiple --functions arguments."""
3315
+ if not functions_list:
3316
+ return
3317
+ for functions in functions_list:
3318
+ if isinstance(functions, str) and functions.strip():
3319
+ _register_functions(db, functions)
@@ -156,6 +156,10 @@ XIndexColumn = namedtuple(
156
156
  Trigger = namedtuple("Trigger", ("name", "table", "sql"))
157
157
 
158
158
 
159
+ class TransformError(Exception):
160
+ pass
161
+
162
+
159
163
  ForeignKeyIndicator = Union[
160
164
  str,
161
165
  ForeignKey,
@@ -233,36 +237,43 @@ if pd:
233
237
 
234
238
  class AlterError(Exception):
235
239
  "Error altering table"
240
+
236
241
  pass
237
242
 
238
243
 
239
244
  class NoObviousTable(Exception):
240
245
  "Could not tell which table this operation refers to"
246
+
241
247
  pass
242
248
 
243
249
 
244
250
  class NoTable(Exception):
245
251
  "Specified table does not exist"
252
+
246
253
  pass
247
254
 
248
255
 
249
256
  class BadPrimaryKey(Exception):
250
257
  "Table does not have a single obvious primary key"
258
+
251
259
  pass
252
260
 
253
261
 
254
262
  class NotFoundError(Exception):
255
263
  "Record not found"
264
+
256
265
  pass
257
266
 
258
267
 
259
268
  class PrimaryKeyRequired(Exception):
260
269
  "Primary key needs to be specified"
270
+
261
271
  pass
262
272
 
263
273
 
264
274
  class InvalidColumns(Exception):
265
275
  "Specified columns do not exist"
276
+
266
277
  pass
267
278
 
268
279
 
@@ -364,7 +375,7 @@ class Database:
364
375
  pm.hook.prepare_connection(conn=self.conn)
365
376
  self.strict = strict
366
377
 
367
- def close(self):
378
+ def close(self) -> None:
368
379
  "Close the SQLite connection, and the underlying database file"
369
380
  self.conn.close()
370
381
 
@@ -934,10 +945,15 @@ class Database:
934
945
  other_column=foreign_keys_by_column[column_name].other_column,
935
946
  )
936
947
  )
948
+ column_type_str = COLUMN_TYPE_MAPPING[column_type]
949
+ # Special case for strict tables to map FLOAT to REAL
950
+ # Refs https://github.com/simonw/sqlite-utils/issues/644
951
+ if strict and column_type_str == "FLOAT":
952
+ column_type_str = "REAL"
937
953
  column_defs.append(
938
954
  " [{column_name}] {column_type}{column_extras}".format(
939
955
  column_name=column_name,
940
- column_type=COLUMN_TYPE_MAPPING[column_type],
956
+ column_type=column_type_str,
941
957
  column_extras=(
942
958
  (" " + " ".join(column_extras)) if column_extras else ""
943
959
  ),
@@ -1972,6 +1988,30 @@ class Table(Queryable):
1972
1988
  sqls.append(
1973
1989
  "ALTER TABLE [{}] RENAME TO [{}];".format(new_table_name, self.name)
1974
1990
  )
1991
+ # Re-add existing indexes
1992
+ for index in self.indexes:
1993
+ if index.origin != "pk":
1994
+ index_sql = self.db.execute(
1995
+ """SELECT sql FROM sqlite_master WHERE type = 'index' AND name = :index_name;""",
1996
+ {"index_name": index.name},
1997
+ ).fetchall()[0][0]
1998
+ if index_sql is None:
1999
+ raise TransformError(
2000
+ f"Index '{index.name}' on table '{self.name}' does not have a "
2001
+ "CREATE INDEX statement. You must manually drop this index prior to running this "
2002
+ "transformation and manually recreate the new index after running this transformation."
2003
+ )
2004
+ if keep_table:
2005
+ sqls.append(f"DROP INDEX IF EXISTS [{index.name}];")
2006
+ for col in index.columns:
2007
+ if col in rename.keys() or col in drop:
2008
+ raise TransformError(
2009
+ f"Index '{index.name}' column '{col}' is not in updated table '{self.name}'. "
2010
+ f"You must manually drop this index prior to running this transformation "
2011
+ f"and manually recreate the new index after running this transformation. "
2012
+ f"The original index sql statement is: `{index_sql}`. No changes have been applied to this table."
2013
+ )
2014
+ sqls.append(index_sql)
1975
2015
  return sqls
1976
2016
 
1977
2017
  def extract(
@@ -2650,6 +2690,7 @@ class Table(Queryable):
2650
2690
  offset: Optional[int] = None,
2651
2691
  where: Optional[str] = None,
2652
2692
  where_args: Optional[Union[Iterable, dict]] = None,
2693
+ include_rank: bool = False,
2653
2694
  quote: bool = False,
2654
2695
  ) -> Generator[dict, None, None]:
2655
2696
  """
@@ -2663,6 +2704,7 @@ class Table(Queryable):
2663
2704
  :param offset: Optional integer SQL offset.
2664
2705
  :param where: Extra SQL fragment for the WHERE clause
2665
2706
  :param where_args: Arguments to use for :param placeholders in the extra WHERE clause
2707
+ :param include_rank: Select the search rank column in the final query
2666
2708
  :param quote: Apply quoting to disable any special characters in the search query
2667
2709
 
2668
2710
  See :ref:`python_api_fts_search`.
@@ -2682,6 +2724,7 @@ class Table(Queryable):
2682
2724
  limit=limit,
2683
2725
  offset=offset,
2684
2726
  where=where,
2727
+ include_rank=include_rank,
2685
2728
  ),
2686
2729
  args,
2687
2730
  )
@@ -3167,7 +3210,7 @@ class Table(Queryable):
3167
3210
  :param not_null: Set of strings specifying columns that should be ``NOT NULL``.
3168
3211
  :param defaults: Dictionary specifying default values for specific columns.
3169
3212
  :param hash_id: Name of a column to create and use as a primary key, where the
3170
- value of thet primary key will be derived as a SHA1 hash of the other column values
3213
+ value of that primary key will be derived as a SHA1 hash of the other column values
3171
3214
  in the record. ``hash_id="id"`` is a common column name used for this.
3172
3215
  :param alter: Boolean, should any missing columns be added automatically?
3173
3216
  :param ignore: Boolean, if a record already exists with this primary key, ignore this insert.
@@ -3816,7 +3859,7 @@ def jsonify_if_needed(value):
3816
3859
 
3817
3860
 
3818
3861
  def resolve_extracts(
3819
- extracts: Optional[Union[Dict[str, str], List[str], Tuple[str]]]
3862
+ extracts: Optional[Union[Dict[str, str], List[str], Tuple[str]]],
3820
3863
  ) -> dict:
3821
3864
  if extracts is None:
3822
3865
  extracts = {}
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.1
1
+ Metadata-Version: 2.4
2
2
  Name: sqlite-utils
3
- Version: 3.38a0
3
+ Version: 3.39
4
4
  Summary: CLI tool and Python library for manipulating SQLite databases
5
5
  Home-page: https://github.com/simonw/sqlite-utils
6
6
  Author: Simon Willison
@@ -15,22 +15,21 @@ Classifier: Intended Audience :: Developers
15
15
  Classifier: Intended Audience :: Science/Research
16
16
  Classifier: Intended Audience :: End Users/Desktop
17
17
  Classifier: Topic :: Database
18
- Classifier: License :: OSI Approved :: Apache Software License
19
- Classifier: Programming Language :: Python :: 3.8
20
- Classifier: Programming Language :: Python :: 3.9
21
18
  Classifier: Programming Language :: Python :: 3.10
22
19
  Classifier: Programming Language :: Python :: 3.11
23
20
  Classifier: Programming Language :: Python :: 3.12
24
21
  Classifier: Programming Language :: Python :: 3.13
25
- Requires-Python: >=3.8
22
+ Classifier: Programming Language :: Python :: 3.14
23
+ Requires-Python: >=3.10
26
24
  Description-Content-Type: text/markdown
27
25
  License-File: LICENSE
28
26
  Requires-Dist: sqlite-fts4
29
- Requires-Dist: click
27
+ Requires-Dist: click>=8.3.1
30
28
  Requires-Dist: click-default-group>=1.2.3
31
29
  Requires-Dist: tabulate
32
30
  Requires-Dist: python-dateutil
33
31
  Requires-Dist: pluggy
32
+ Requires-Dist: pip
34
33
  Provides-Extra: test
35
34
  Requires-Dist: pytest; extra == "test"
36
35
  Requires-Dist: black>=24.1.1; extra == "test"
@@ -54,6 +53,18 @@ Provides-Extra: flake8
54
53
  Requires-Dist: flake8; extra == "flake8"
55
54
  Provides-Extra: tui
56
55
  Requires-Dist: trogon; extra == "tui"
56
+ Dynamic: author
57
+ Dynamic: classifier
58
+ Dynamic: description
59
+ Dynamic: description-content-type
60
+ Dynamic: home-page
61
+ Dynamic: license
62
+ Dynamic: license-file
63
+ Dynamic: project-url
64
+ Dynamic: provides-extra
65
+ Dynamic: requires-dist
66
+ Dynamic: requires-python
67
+ Dynamic: summary
57
68
 
58
69
  # sqlite-utils
59
70
 
@@ -1,9 +1,10 @@
1
1
  sqlite-fts4
2
- click
2
+ click>=8.3.1
3
3
  click-default-group>=1.2.3
4
4
  tabulate
5
5
  python-dateutil
6
6
  pluggy
7
+ pip
7
8
 
8
9
  [docs]
9
10
  furo
@@ -806,6 +806,77 @@ def test_hidden_functions_are_hidden(db_path):
806
806
  assert "_two" not in functions
807
807
 
808
808
 
809
+ def test_query_functions_from_file(db_path, tmp_path):
810
+ # Create a temporary file with function definitions
811
+ functions_file = tmp_path / "my_functions.py"
812
+ functions_file.write_text(TEST_FUNCTIONS)
813
+
814
+ result = CliRunner().invoke(
815
+ cli.cli,
816
+ [
817
+ db_path,
818
+ "select zero(), one(1), two(1, 2)",
819
+ "--functions",
820
+ str(functions_file),
821
+ ],
822
+ )
823
+ assert result.exit_code == 0
824
+ assert json.loads(result.output.strip()) == [
825
+ {"zero()": 0, "one(1)": 1, "two(1, 2)": 3}
826
+ ]
827
+
828
+
829
+ def test_query_functions_file_not_found(db_path):
830
+ result = CliRunner().invoke(
831
+ cli.cli,
832
+ [
833
+ db_path,
834
+ "select zero()",
835
+ "--functions",
836
+ "nonexistent.py",
837
+ ],
838
+ )
839
+ assert result.exit_code == 1
840
+ assert "File not found: nonexistent.py" in result.output
841
+
842
+
843
+ def test_query_functions_multiple_invocations(db_path):
844
+ # Test using --functions multiple times
845
+ result = CliRunner().invoke(
846
+ cli.cli,
847
+ [
848
+ db_path,
849
+ "select triple(2), quadruple(2)",
850
+ "--functions",
851
+ "def triple(x):\n return x * 3",
852
+ "--functions",
853
+ "def quadruple(x):\n return x * 4",
854
+ ],
855
+ )
856
+ assert result.exit_code == 0
857
+ assert json.loads(result.output.strip()) == [{"triple(2)": 6, "quadruple(2)": 8}]
858
+
859
+
860
+ def test_query_functions_file_and_inline(db_path, tmp_path):
861
+ # Test combining file and inline code
862
+ functions_file = tmp_path / "file_funcs.py"
863
+ functions_file.write_text("def triple(x):\n return x * 3")
864
+
865
+ result = CliRunner().invoke(
866
+ cli.cli,
867
+ [
868
+ db_path,
869
+ "select triple(2), quadruple(2)",
870
+ "--functions",
871
+ str(functions_file),
872
+ "--functions",
873
+ "def quadruple(x):\n return x * 4",
874
+ ],
875
+ )
876
+ assert result.exit_code == 0
877
+ assert json.loads(result.output.strip()) == [{"triple(2)": 6, "quadruple(2)": 8}]
878
+
879
+
809
880
  LOREM_IPSUM_COMPRESSED = (
810
881
  b"x\x9c\xed\xd1\xcdq\x03!\x0c\x05\xe0\xbb\xabP\x01\x1eW\x91\xdc|M\x01\n\xc8\x8e"
811
882
  b"f\xf83H\x1e\x97\x1f\x91M\x8e\xe9\xe0\xdd\x96\x05\x84\xf4\xbek\x9fRI\xc7\xf2J"
@@ -916,7 +987,7 @@ def test_query_json_with_json_cols(db_path):
916
987
 
917
988
  @pytest.mark.parametrize(
918
989
  "content,is_binary",
919
- [(b"\x00\x0Fbinary", True), ("this is text", False), (1, False), (1.5, False)],
990
+ [(b"\x00\x0fbinary", True), ("this is text", False), (1, False), (1.5, False)],
920
991
  )
921
992
  def test_query_raw(db_path, content, is_binary):
922
993
  Database(db_path)["files"].insert({"content": content})
@@ -931,7 +1002,7 @@ def test_query_raw(db_path, content, is_binary):
931
1002
 
932
1003
  @pytest.mark.parametrize(
933
1004
  "content,is_binary",
934
- [(b"\x00\x0Fbinary", True), ("this is text", False), (1, False), (1.5, False)],
1005
+ [(b"\x00\x0fbinary", True), ("this is text", False), (1, False), (1.5, False)],
935
1006
  )
936
1007
  def test_query_raw_lines(db_path, content, is_binary):
937
1008
  Database(db_path)["files"].insert_all({"content": content} for _ in range(3))
@@ -2416,11 +2487,13 @@ def test_create_table_strict(strict):
2416
2487
  db = Database("test.db")
2417
2488
  result = runner.invoke(
2418
2489
  cli.cli,
2419
- ["create-table", "test.db", "items", "id", "integer"]
2490
+ ["create-table", "test.db", "items", "id", "integer", "w", "float"]
2420
2491
  + (["--strict"] if strict else []),
2421
2492
  )
2422
2493
  assert result.exit_code == 0
2423
2494
  assert db["items"].strict == strict or not db.supports_strict
2495
+ # Should have a floating point column
2496
+ assert db["items"].columns_dict == {"id": int, "w": float}
2424
2497
 
2425
2498
 
2426
2499
  @pytest.mark.parametrize("method", ("insert", "upsert"))
@@ -45,6 +45,32 @@ def test_cli_bulk(test_db_and_path):
45
45
  ] == list(db["example"].rows)
46
46
 
47
47
 
48
+ def test_cli_bulk_multiple_functions(test_db_and_path):
49
+ db, db_path = test_db_and_path
50
+ result = CliRunner().invoke(
51
+ cli.cli,
52
+ [
53
+ "bulk",
54
+ db_path,
55
+ "insert into example (id, name) values (:id, myupper(mylower(:name)))",
56
+ "-",
57
+ "--nl",
58
+ "--functions",
59
+ "myupper = lambda s: s.upper()",
60
+ "--functions",
61
+ "mylower = lambda s: s.lower()",
62
+ ],
63
+ input='{"id": 3, "name": "ThReE"}\n{"id": 4, "name": "FoUr"}\n',
64
+ )
65
+ assert result.exit_code == 0, result.output
66
+ assert [
67
+ {"id": 1, "name": "One"},
68
+ {"id": 2, "name": "Two"},
69
+ {"id": 3, "name": "THREE"},
70
+ {"id": 4, "name": "FOUR"},
71
+ ] == list(db["example"].rows)
72
+
73
+
48
74
  def test_cli_bulk_batch_size(test_db_and_path):
49
75
  db, db_path = test_db_and_path
50
76
  proc = subprocess.Popen(
@@ -307,6 +307,22 @@ def test_memory_functions():
307
307
  assert result.output.strip() == '[{"hello()": "Hello"}]'
308
308
 
309
309
 
310
+ def test_memory_functions_multiple():
311
+ result = CliRunner().invoke(
312
+ cli.cli,
313
+ [
314
+ "memory",
315
+ "select triple(2), quadruple(2)",
316
+ "--functions",
317
+ "def triple(x):\n return x * 3",
318
+ "--functions",
319
+ "def quadruple(x):\n return x * 4",
320
+ ],
321
+ )
322
+ assert result.exit_code == 0
323
+ assert result.output.strip() == '[{"triple(2)": 6, "quadruple(2)": 8}]'
324
+
325
+
310
326
  def test_memory_return_db(tmpdir):
311
327
  # https://github.com/simonw/sqlite-utils/issues/643
312
328
  from sqlite_utils.cli import cli
@@ -1350,8 +1350,14 @@ def test_insert_upsert_strict(fresh_db, method_name, strict):
1350
1350
 
1351
1351
  @pytest.mark.parametrize("strict", (False, True))
1352
1352
  def test_create_table_strict(fresh_db, strict):
1353
- table = fresh_db.create_table("t", {"id": int}, strict=strict)
1353
+ table = fresh_db.create_table("t", {"id": int, "f": float}, strict=strict)
1354
1354
  assert table.strict == strict or not fresh_db.supports_strict
1355
+ expected_schema = "CREATE TABLE [t] (\n" " [id] INTEGER,\n" " [f] FLOAT\n" ")"
1356
+ if strict and not fresh_db.supports_strict:
1357
+ return
1358
+ if strict:
1359
+ expected_schema = "CREATE TABLE [t] (\n [id] INTEGER,\n [f] REAL\n) STRICT"
1360
+ assert table.schema == expected_schema
1355
1361
 
1356
1362
 
1357
1363
  @pytest.mark.parametrize("strict", (False, True))
@@ -1,6 +1,7 @@
1
1
  import pytest
2
2
  from sqlite_utils import Database
3
3
  from sqlite_utils.utils import sqlite3
4
+ from unittest.mock import ANY
4
5
 
5
6
  search_records = [
6
7
  {
@@ -126,6 +127,32 @@ def test_search_where_args_disallows_query(fresh_db):
126
127
  )
127
128
 
128
129
 
130
+ def test_search_include_rank(fresh_db):
131
+ table = fresh_db["t"]
132
+ table.insert_all(search_records)
133
+ table.enable_fts(["text", "country"], fts_version="FTS5")
134
+ results = list(table.search("are", include_rank=True))
135
+ assert results == [
136
+ {
137
+ "rowid": 1,
138
+ "text": "tanuki are running tricksters",
139
+ "country": "Japan",
140
+ "not_searchable": "foo",
141
+ "rank": ANY,
142
+ },
143
+ {
144
+ "rowid": 2,
145
+ "text": "racoons are biting trash pandas",
146
+ "country": "USA",
147
+ "not_searchable": "bar",
148
+ "rank": ANY,
149
+ },
150
+ ]
151
+ assert isinstance(results[0]["rank"], float)
152
+ assert isinstance(results[1]["rank"], float)
153
+ assert results[0]["rank"] < results[1]["rank"]
154
+
155
+
129
156
  def test_enable_fts_table_names_containing_spaces(fresh_db):
130
157
  table = fresh_db["test"]
131
158
  table.insert({"column with spaces": "in its name"})
@@ -64,6 +64,7 @@ def test_dayfirst_yearfirst(fresh_db, recipe, kwargs, expected):
64
64
 
65
65
  @pytest.mark.parametrize("fn", ("parsedate", "parsedatetime"))
66
66
  @pytest.mark.parametrize("errors", (None, recipes.SET_NULL, recipes.IGNORE))
67
+ @pytest.mark.filterwarnings("ignore::pytest.PytestUnraisableExceptionWarning")
67
68
  def test_dateparse_errors(fresh_db, fn, errors):
68
69
  fresh_db["example"].insert_all(
69
70
  [
@@ -1,4 +1,4 @@
1
- from sqlite_utils.db import ForeignKey
1
+ from sqlite_utils.db import ForeignKey, TransformError
2
2
  from sqlite_utils.utils import OperationalError
3
3
  import pytest
4
4
 
@@ -539,3 +539,125 @@ def test_transform_strict(fresh_db, strict):
539
539
  assert dogs.strict == strict or not fresh_db.supports_strict
540
540
  dogs.transform(not_null={"name"})
541
541
  assert dogs.strict == strict or not fresh_db.supports_strict
542
+
543
+
544
+ @pytest.mark.parametrize(
545
+ "indexes, transform_params",
546
+ [
547
+ ([["name"]], {"types": {"age": str}}),
548
+ ([["name"], ["age", "breed"]], {"types": {"age": str}}),
549
+ ([], {"types": {"age": str}}),
550
+ ([["name"]], {"types": {"age": str}, "keep_table": "old_dogs"}),
551
+ ],
552
+ )
553
+ def test_transform_indexes(fresh_db, indexes, transform_params):
554
+ # https://github.com/simonw/sqlite-utils/issues/633
555
+ # New table should have same indexes as old table after transformation
556
+ dogs = fresh_db["dogs"]
557
+ dogs.insert({"id": 1, "name": "Cleo", "age": 5, "breed": "Labrador"}, pk="id")
558
+
559
+ for index in indexes:
560
+ dogs.create_index(index)
561
+
562
+ indexes_before_transform = dogs.indexes
563
+
564
+ dogs.transform(**transform_params)
565
+
566
+ assert sorted(
567
+ [
568
+ {k: v for k, v in idx._asdict().items() if k != "seq"}
569
+ for idx in dogs.indexes
570
+ ],
571
+ key=lambda x: x["name"],
572
+ ) == sorted(
573
+ [
574
+ {k: v for k, v in idx._asdict().items() if k != "seq"}
575
+ for idx in indexes_before_transform
576
+ ],
577
+ key=lambda x: x["name"],
578
+ ), f"Indexes before transform: {indexes_before_transform}\nIndexes after transform: {dogs.indexes}"
579
+ if "keep_table" in transform_params:
580
+ assert all(
581
+ index.origin == "pk"
582
+ for index in fresh_db[transform_params["keep_table"]].indexes
583
+ )
584
+
585
+
586
+ def test_transform_retains_indexes_with_foreign_keys(fresh_db):
587
+ dogs = fresh_db["dogs"]
588
+ owners = fresh_db["owners"]
589
+
590
+ dogs.insert({"id": 1, "name": "Cleo", "owner_id": 1}, pk="id")
591
+ owners.insert({"id": 1, "name": "Alice"}, pk="id")
592
+
593
+ dogs.create_index(["name"])
594
+
595
+ indexes_before_transform = dogs.indexes
596
+
597
+ fresh_db.add_foreign_keys([("dogs", "owner_id", "owners", "id")]) # calls transform
598
+
599
+ assert sorted(
600
+ [
601
+ {k: v for k, v in idx._asdict().items() if k != "seq"}
602
+ for idx in dogs.indexes
603
+ ],
604
+ key=lambda x: x["name"],
605
+ ) == sorted(
606
+ [
607
+ {k: v for k, v in idx._asdict().items() if k != "seq"}
608
+ for idx in indexes_before_transform
609
+ ],
610
+ key=lambda x: x["name"],
611
+ ), f"Indexes before transform: {indexes_before_transform}\nIndexes after transform: {dogs.indexes}"
612
+
613
+
614
+ @pytest.mark.parametrize(
615
+ "transform_params",
616
+ [
617
+ {"rename": {"age": "dog_age"}},
618
+ {"drop": ["age"]},
619
+ ],
620
+ )
621
+ def test_transform_with_indexes_errors(fresh_db, transform_params):
622
+ # Should error with a compound (name, age) index if age is renamed or dropped
623
+ dogs = fresh_db["dogs"]
624
+ dogs.insert({"id": 1, "name": "Cleo", "age": 5}, pk="id")
625
+
626
+ dogs.create_index(["name", "age"])
627
+
628
+ with pytest.raises(TransformError) as excinfo:
629
+ dogs.transform(**transform_params)
630
+
631
+ assert (
632
+ "Index 'idx_dogs_name_age' column 'age' is not in updated table 'dogs'. "
633
+ "You must manually drop this index prior to running this transformation"
634
+ in str(excinfo.value)
635
+ )
636
+
637
+
638
+ def test_transform_with_unique_constraint_implicit_index(fresh_db):
639
+ dogs = fresh_db["dogs"]
640
+ # Create a table with a UNIQUE constraint on 'name', which creates an implicit index
641
+ fresh_db.execute(
642
+ """
643
+ CREATE TABLE dogs (
644
+ id INTEGER PRIMARY KEY,
645
+ name TEXT UNIQUE,
646
+ age INTEGER
647
+ );
648
+ """
649
+ )
650
+ dogs.insert({"id": 1, "name": "Cleo", "age": 5})
651
+
652
+ # Attempt to transform the table without modifying 'name'
653
+ with pytest.raises(TransformError) as excinfo:
654
+ dogs.transform(types={"age": str})
655
+
656
+ assert (
657
+ "Index 'sqlite_autoindex_dogs_1' on table 'dogs' does not have a CREATE INDEX statement."
658
+ in str(excinfo.value)
659
+ )
660
+ assert (
661
+ "You must manually drop this index prior to running this transformation and manually recreate the new index after running this transformation."
662
+ in str(excinfo.value)
663
+ )
File without changes
File without changes
File without changes
File without changes