sqlite-utils 3.38__tar.gz → 4.0a0__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.38/sqlite_utils.egg-info → sqlite_utils-4.0a0}/PKG-INFO +15 -6
  2. {sqlite_utils-3.38 → sqlite_utils-4.0a0}/docs/changelog.rst +12 -1
  3. {sqlite_utils-3.38 → sqlite_utils-4.0a0}/docs/cli-reference.rst +0 -18
  4. {sqlite_utils-3.38 → sqlite_utils-4.0a0}/docs/cli.rst +2 -28
  5. {sqlite_utils-3.38 → sqlite_utils-4.0a0}/docs/python-api.rst +8 -3
  6. {sqlite_utils-3.38 → sqlite_utils-4.0a0}/setup.py +2 -4
  7. {sqlite_utils-3.38 → sqlite_utils-4.0a0}/sqlite_utils/cli.py +3 -10
  8. {sqlite_utils-3.38 → sqlite_utils-4.0a0}/sqlite_utils/db.py +187 -106
  9. {sqlite_utils-3.38 → sqlite_utils-4.0a0/sqlite_utils.egg-info}/PKG-INFO +15 -6
  10. {sqlite_utils-3.38 → sqlite_utils-4.0a0}/sqlite_utils.egg-info/requires.txt +0 -3
  11. {sqlite_utils-3.38 → sqlite_utils-4.0a0}/tests/test_cli.py +5 -8
  12. {sqlite_utils-3.38 → sqlite_utils-4.0a0}/tests/test_create.py +5 -3
  13. {sqlite_utils-3.38 → sqlite_utils-4.0a0}/tests/test_plugins.py +15 -1
  14. {sqlite_utils-3.38 → sqlite_utils-4.0a0}/tests/test_tracer.py +1 -1
  15. {sqlite_utils-3.38 → sqlite_utils-4.0a0}/tests/test_upsert.py +5 -2
  16. {sqlite_utils-3.38 → sqlite_utils-4.0a0}/LICENSE +0 -0
  17. {sqlite_utils-3.38 → sqlite_utils-4.0a0}/MANIFEST.in +0 -0
  18. {sqlite_utils-3.38 → sqlite_utils-4.0a0}/README.md +0 -0
  19. {sqlite_utils-3.38 → sqlite_utils-4.0a0}/docs/contributing.rst +0 -0
  20. {sqlite_utils-3.38 → sqlite_utils-4.0a0}/docs/index.rst +0 -0
  21. {sqlite_utils-3.38 → sqlite_utils-4.0a0}/docs/installation.rst +0 -0
  22. {sqlite_utils-3.38 → sqlite_utils-4.0a0}/docs/plugins.rst +0 -0
  23. {sqlite_utils-3.38 → sqlite_utils-4.0a0}/docs/reference.rst +0 -0
  24. {sqlite_utils-3.38 → sqlite_utils-4.0a0}/setup.cfg +0 -0
  25. {sqlite_utils-3.38 → sqlite_utils-4.0a0}/sqlite_utils/__init__.py +0 -0
  26. {sqlite_utils-3.38 → sqlite_utils-4.0a0}/sqlite_utils/__main__.py +0 -0
  27. {sqlite_utils-3.38 → sqlite_utils-4.0a0}/sqlite_utils/hookspecs.py +0 -0
  28. {sqlite_utils-3.38 → sqlite_utils-4.0a0}/sqlite_utils/plugins.py +0 -0
  29. {sqlite_utils-3.38 → sqlite_utils-4.0a0}/sqlite_utils/py.typed +0 -0
  30. {sqlite_utils-3.38 → sqlite_utils-4.0a0}/sqlite_utils/recipes.py +0 -0
  31. {sqlite_utils-3.38 → sqlite_utils-4.0a0}/sqlite_utils/utils.py +0 -0
  32. {sqlite_utils-3.38 → sqlite_utils-4.0a0}/sqlite_utils.egg-info/SOURCES.txt +0 -0
  33. {sqlite_utils-3.38 → sqlite_utils-4.0a0}/sqlite_utils.egg-info/dependency_links.txt +0 -0
  34. {sqlite_utils-3.38 → sqlite_utils-4.0a0}/sqlite_utils.egg-info/entry_points.txt +0 -0
  35. {sqlite_utils-3.38 → sqlite_utils-4.0a0}/sqlite_utils.egg-info/not-zip-safe +0 -0
  36. {sqlite_utils-3.38 → sqlite_utils-4.0a0}/sqlite_utils.egg-info/top_level.txt +0 -0
  37. {sqlite_utils-3.38 → sqlite_utils-4.0a0}/tests/__init__.py +0 -0
  38. {sqlite_utils-3.38 → sqlite_utils-4.0a0}/tests/conftest.py +0 -0
  39. {sqlite_utils-3.38 → sqlite_utils-4.0a0}/tests/test_analyze.py +0 -0
  40. {sqlite_utils-3.38 → sqlite_utils-4.0a0}/tests/test_analyze_tables.py +0 -0
  41. {sqlite_utils-3.38 → sqlite_utils-4.0a0}/tests/test_attach.py +0 -0
  42. {sqlite_utils-3.38 → sqlite_utils-4.0a0}/tests/test_cli_bulk.py +0 -0
  43. {sqlite_utils-3.38 → sqlite_utils-4.0a0}/tests/test_cli_convert.py +0 -0
  44. {sqlite_utils-3.38 → sqlite_utils-4.0a0}/tests/test_cli_insert.py +0 -0
  45. {sqlite_utils-3.38 → sqlite_utils-4.0a0}/tests/test_cli_memory.py +0 -0
  46. {sqlite_utils-3.38 → sqlite_utils-4.0a0}/tests/test_column_affinity.py +0 -0
  47. {sqlite_utils-3.38 → sqlite_utils-4.0a0}/tests/test_constructor.py +0 -0
  48. {sqlite_utils-3.38 → sqlite_utils-4.0a0}/tests/test_conversions.py +0 -0
  49. {sqlite_utils-3.38 → sqlite_utils-4.0a0}/tests/test_convert.py +0 -0
  50. {sqlite_utils-3.38 → sqlite_utils-4.0a0}/tests/test_create_view.py +0 -0
  51. {sqlite_utils-3.38 → sqlite_utils-4.0a0}/tests/test_default_value.py +0 -0
  52. {sqlite_utils-3.38 → sqlite_utils-4.0a0}/tests/test_delete.py +0 -0
  53. {sqlite_utils-3.38 → sqlite_utils-4.0a0}/tests/test_docs.py +0 -0
  54. {sqlite_utils-3.38 → sqlite_utils-4.0a0}/tests/test_duplicate.py +0 -0
  55. {sqlite_utils-3.38 → sqlite_utils-4.0a0}/tests/test_enable_counts.py +0 -0
  56. {sqlite_utils-3.38 → sqlite_utils-4.0a0}/tests/test_extract.py +0 -0
  57. {sqlite_utils-3.38 → sqlite_utils-4.0a0}/tests/test_extracts.py +0 -0
  58. {sqlite_utils-3.38 → sqlite_utils-4.0a0}/tests/test_fts.py +0 -0
  59. {sqlite_utils-3.38 → sqlite_utils-4.0a0}/tests/test_get.py +0 -0
  60. {sqlite_utils-3.38 → sqlite_utils-4.0a0}/tests/test_gis.py +0 -0
  61. {sqlite_utils-3.38 → sqlite_utils-4.0a0}/tests/test_hypothesis.py +0 -0
  62. {sqlite_utils-3.38 → sqlite_utils-4.0a0}/tests/test_insert_files.py +0 -0
  63. {sqlite_utils-3.38 → sqlite_utils-4.0a0}/tests/test_introspect.py +0 -0
  64. {sqlite_utils-3.38 → sqlite_utils-4.0a0}/tests/test_lookup.py +0 -0
  65. {sqlite_utils-3.38 → sqlite_utils-4.0a0}/tests/test_m2m.py +0 -0
  66. {sqlite_utils-3.38 → sqlite_utils-4.0a0}/tests/test_query.py +0 -0
  67. {sqlite_utils-3.38 → sqlite_utils-4.0a0}/tests/test_recipes.py +0 -0
  68. {sqlite_utils-3.38 → sqlite_utils-4.0a0}/tests/test_recreate.py +0 -0
  69. {sqlite_utils-3.38 → sqlite_utils-4.0a0}/tests/test_register_function.py +0 -0
  70. {sqlite_utils-3.38 → sqlite_utils-4.0a0}/tests/test_rows.py +0 -0
  71. {sqlite_utils-3.38 → sqlite_utils-4.0a0}/tests/test_rows_from_file.py +0 -0
  72. {sqlite_utils-3.38 → sqlite_utils-4.0a0}/tests/test_sniff.py +0 -0
  73. {sqlite_utils-3.38 → sqlite_utils-4.0a0}/tests/test_suggest_column_types.py +0 -0
  74. {sqlite_utils-3.38 → sqlite_utils-4.0a0}/tests/test_transform.py +0 -0
  75. {sqlite_utils-3.38 → sqlite_utils-4.0a0}/tests/test_update.py +0 -0
  76. {sqlite_utils-3.38 → sqlite_utils-4.0a0}/tests/test_utils.py +0 -0
  77. {sqlite_utils-3.38 → sqlite_utils-4.0a0}/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.38
3
+ Version: 4.0a0
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
@@ -16,13 +16,12 @@ Classifier: Intended Audience :: Science/Research
16
16
  Classifier: Intended Audience :: End Users/Desktop
17
17
  Classifier: Topic :: Database
18
18
  Classifier: License :: OSI Approved :: Apache Software License
19
- Classifier: Programming Language :: Python :: 3.8
20
19
  Classifier: Programming Language :: Python :: 3.9
21
20
  Classifier: Programming Language :: Python :: 3.10
22
21
  Classifier: Programming Language :: Python :: 3.11
23
22
  Classifier: Programming Language :: Python :: 3.12
24
23
  Classifier: Programming Language :: Python :: 3.13
25
- Requires-Python: >=3.8
24
+ Requires-Python: >=3.9
26
25
  Description-Content-Type: text/markdown
27
26
  License-File: LICENSE
28
27
  Requires-Dist: sqlite-fts4
@@ -52,8 +51,18 @@ Requires-Dist: types-pluggy; extra == "mypy"
52
51
  Requires-Dist: data-science-types; extra == "mypy"
53
52
  Provides-Extra: flake8
54
53
  Requires-Dist: flake8; extra == "flake8"
55
- Provides-Extra: tui
56
- Requires-Dist: trogon; extra == "tui"
54
+ Dynamic: author
55
+ Dynamic: classifier
56
+ Dynamic: description
57
+ Dynamic: description-content-type
58
+ Dynamic: home-page
59
+ Dynamic: license
60
+ Dynamic: license-file
61
+ Dynamic: project-url
62
+ Dynamic: provides-extra
63
+ Dynamic: requires-dist
64
+ Dynamic: requires-python
65
+ Dynamic: summary
57
66
 
58
67
  # sqlite-utils
59
68
 
@@ -4,6 +4,17 @@
4
4
  Changelog
5
5
  ===========
6
6
 
7
+ .. _v4_0a0:
8
+
9
+ 4.0a0 (2025-05-08)
10
+ ------------------
11
+
12
+ - Upsert operations now use SQLite's ``INSERT ... ON CONFLICT SET`` syntax on all SQLite versions later than 3.23.1. This is a very slight breaking change for apps that depend on the previous ``INSERT OR IGNORE`` followed by ``UPDATE`` behavior. (:issue:`652`)
13
+ - Python library users can opt-in to the previous implementation by passing ``use_old_upsert=True`` to the ``Database()`` constructor, see :ref:`python_api_old_upsert`.
14
+ - Dropped support for Python 3.8, added support for Python 3.13. (:issue:`646`)
15
+ - ``sqlite-utils tui`` is now provided by the `sqlite-utils-tui <https://github.com/simonw/sqlite-utils-tui>`__ plugin. (:issue:`648`)
16
+ - Test suite now also runs against SQLite 3.23.1, the last version (from 2018-04-10) before the new ``INSERT ... ON CONFLICT SET`` syntax was added. (:issue:`654`)
17
+
7
18
  .. _v3_38:
8
19
 
9
20
  3.38 (2024-11-23)
@@ -111,7 +122,7 @@ This release introduces a new :ref:`plugin system <plugins>`. Read more about th
111
122
  3.32 (2023-05-21)
112
123
  -----------------
113
124
 
114
- - New experimental ``sqlite-utils tui`` interface for interactively building command-line invocations, powered by `Trogon <https://github.com/Textualize/trogon>`__. This requires an optional dependency, installed using ``sqlite-utils install trogon``. There is a screenshot :ref:`in the documentation <cli_tui>`. (:issue:`545`)
125
+ - New experimental ``sqlite-utils tui`` interface for interactively building command-line invocations, powered by `Trogon <https://github.com/Textualize/trogon>`__. This requires an optional dependency, installed using ``sqlite-utils install trogon``. (:issue:`545`)
115
126
  - ``sqlite-utils analyze-tables`` command (:ref:`documentation <cli_analyze_tables>`) now has a ``--common-limit 20`` option for changing the number of common/least-common values shown for each column. (:issue:`544`)
116
127
  - ``sqlite-utils analyze-tables --no-most`` and ``--no-least`` options for disabling calculation of most-common and least-common values.
117
128
  - If a column contains only ``null`` values, ``analyze-tables`` will no longer attempt to calculate the most common and least common values for that column. (:issue:`547`)
@@ -65,7 +65,6 @@ This page lists the ``--help`` for every ``sqlite-utils`` CLI sub-command.
65
65
  "create-spatial-index": "cli_spatialite_indexes",
66
66
  "install": "cli_install",
67
67
  "uninstall": "cli_uninstall",
68
- "tui": "cli_tui",
69
68
  }
70
69
  commands.sort(key = lambda command: go_first.index(command) if command in go_first else 999)
71
70
  cog.out("\n")
@@ -1045,23 +1044,6 @@ disable-fts
1045
1044
  -h, --help Show this message and exit.
1046
1045
 
1047
1046
 
1048
- .. _cli_ref_tui:
1049
-
1050
- tui
1051
- ===
1052
-
1053
- See :ref:`cli_tui`.
1054
-
1055
- ::
1056
-
1057
- Usage: sqlite-utils tui [OPTIONS]
1058
-
1059
- Open Textual TUI.
1060
-
1061
- Options:
1062
- -h, --help Show this message and exit.
1063
-
1064
-
1065
1047
  .. _cli_ref_optimize:
1066
1048
 
1067
1049
  optimize
@@ -1128,7 +1128,7 @@ You can insert binary data into a BLOB column by first encoding it using base64
1128
1128
  Inserting newline-delimited JSON
1129
1129
  --------------------------------
1130
1130
 
1131
- You can also import `newline-delimited JSON <http://ndjson.org/>`__ using the ``--nl`` option:
1131
+ You can also import newline-delimited JSON (see `JSON Lines <https://jsonlines.org/>`__) using the ``--nl`` option:
1132
1132
 
1133
1133
  .. code-block:: bash
1134
1134
 
@@ -1285,7 +1285,7 @@ You can set the ``SQLITE_UTILS_DETECT_TYPES`` environment variable if you want `
1285
1285
 
1286
1286
  If a CSV or TSV file includes empty cells, like this one:
1287
1287
 
1288
- .. code-block:: csv
1288
+ ::
1289
1289
 
1290
1290
  name,age,weight
1291
1291
  Cleo,6,
@@ -2782,29 +2782,3 @@ You can uninstall packages that were installed using ``sqlite-utils install`` wi
2782
2782
  sqlite-utils uninstall beautifulsoup4
2783
2783
 
2784
2784
  Use ``-y`` to skip the request for confirmation.
2785
-
2786
- .. _cli_tui:
2787
-
2788
- Experimental TUI
2789
- ================
2790
-
2791
- A TUI is a "text user interface" (or "terminal user interface") - a keyboard and mouse driven graphical interface running in your terminal.
2792
-
2793
- ``sqlite-utils`` has experimental support for a TUI for building command-line invocations, built on top of the `Trogon <https://github.com/Textualize/trogon/>`__ TUI library.
2794
-
2795
- To enable this feature you will need to install the ``trogon`` dependency. You can do that like so:
2796
-
2797
- .. code-block:: bash
2798
-
2799
- sqlite-utils install trogon
2800
-
2801
- Once installed, running the ``sqlite-utils tui`` command will launch the TUI interface:
2802
-
2803
- .. code-block:: bash
2804
-
2805
- sqlite-utils tui
2806
-
2807
- You can then construct a command by selecting options from the menus, and execute it using ``Ctrl+R``.
2808
-
2809
- .. image:: _static/img/tui.png
2810
- :alt: A TUI interface for sqlite-utils - the left column shows a list of commands, while the right panel has a form for constructing arguments to the add-column command.
@@ -927,6 +927,13 @@ An ``upsert_all()`` method is also available, which behaves like ``insert_all()`
927
927
  .. note::
928
928
  ``.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.
929
929
 
930
+ .. _python_api_old_upsert:
931
+
932
+ Alternative upserts using INSERT OR IGNORE
933
+ ------------------------------------------
934
+
935
+ Upserts use ``INSERT INTO ... ON CONFLICT SET``. Prior to ``sqlite-utils 4.0`` these used a sequence of ``INSERT OR IGNORE`` followed by an ``UPDATE``. This older method is still used for SQLite 3.23.1 and earlier. You can force the older implementation by passing ``use_old_upsert=True`` to the ``Database()`` constructor.
936
+
930
937
  .. _python_api_convert:
931
938
 
932
939
  Converting data in columns
@@ -2711,7 +2718,7 @@ By default, the name of the Python function will be used as the name of the SQL
2711
2718
 
2712
2719
  print(db.execute('select rev("hello")').fetchone()[0])
2713
2720
 
2714
- 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:
2721
+ 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:
2715
2722
 
2716
2723
  .. code-block:: python
2717
2724
 
@@ -2719,8 +2726,6 @@ Python 3.8 added the ability to register `deterministic SQLite functions <https:
2719
2726
  def reverse_string(s):
2720
2727
  return "".join(reversed(list(s)))
2721
2728
 
2722
- 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.
2723
-
2724
2729
  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.
2725
2730
 
2726
2731
  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.38"
5
+ VERSION = "4.0a0"
6
6
 
7
7
 
8
8
  def get_long_description():
@@ -50,7 +50,6 @@ setup(
50
50
  "data-science-types",
51
51
  ],
52
52
  "flake8": ["flake8"],
53
- "tui": ["trogon"],
54
53
  },
55
54
  entry_points="""
56
55
  [console_scripts]
@@ -64,7 +63,7 @@ setup(
64
63
  "Issues": "https://github.com/simonw/sqlite-utils/issues",
65
64
  "CI": "https://github.com/simonw/sqlite-utils/actions",
66
65
  },
67
- python_requires=">=3.8",
66
+ python_requires=">=3.9",
68
67
  classifiers=[
69
68
  "Development Status :: 5 - Production/Stable",
70
69
  "Intended Audience :: Developers",
@@ -72,7 +71,6 @@ setup(
72
71
  "Intended Audience :: End Users/Desktop",
73
72
  "Topic :: Database",
74
73
  "License :: OSI Approved :: Apache Software License",
75
- "Programming Language :: Python :: 3.8",
76
74
  "Programming Language :: Python :: 3.9",
77
75
  "Programming Language :: Python :: 3.10",
78
76
  "Programming Language :: Python :: 3.11",
@@ -35,11 +35,6 @@ from .utils import (
35
35
  TypeTracker,
36
36
  )
37
37
 
38
- try:
39
- import trogon # type: ignore
40
- except ImportError:
41
- trogon = None
42
-
43
38
 
44
39
  CONTEXT_SETTINGS = dict(help_option_names=["-h", "--help"])
45
40
 
@@ -127,10 +122,6 @@ def cli():
127
122
  pass
128
123
 
129
124
 
130
- if trogon is not None:
131
- cli = trogon.tui()(cli)
132
-
133
-
134
125
  @cli.command()
135
126
  @click.argument(
136
127
  "path",
@@ -1107,7 +1098,9 @@ def insert_upsert_implementation(
1107
1098
  if (
1108
1099
  isinstance(e, OperationalError)
1109
1100
  and e.args
1110
- and "has no column named" in e.args[0]
1101
+ and (
1102
+ "has no column named" in e.args[0] or "no such column" in e.args[0]
1103
+ )
1111
1104
  ):
1112
1105
  raise click.ClickException(
1113
1106
  "{}\n\nTry using --alter to add additional columns".format(
@@ -237,37 +237,30 @@ if pd:
237
237
 
238
238
  class AlterError(Exception):
239
239
  "Error altering table"
240
- pass
241
240
 
242
241
 
243
242
  class NoObviousTable(Exception):
244
243
  "Could not tell which table this operation refers to"
245
- pass
246
244
 
247
245
 
248
246
  class NoTable(Exception):
249
247
  "Specified table does not exist"
250
- pass
251
248
 
252
249
 
253
250
  class BadPrimaryKey(Exception):
254
251
  "Table does not have a single obvious primary key"
255
- pass
256
252
 
257
253
 
258
254
  class NotFoundError(Exception):
259
255
  "Record not found"
260
- pass
261
256
 
262
257
 
263
258
  class PrimaryKeyRequired(Exception):
264
259
  "Primary key needs to be specified"
265
- pass
266
260
 
267
261
 
268
262
  class InvalidColumns(Exception):
269
263
  "Specified columns do not exist"
270
- pass
271
264
 
272
265
 
273
266
  class DescIndex(str):
@@ -311,6 +304,8 @@ class Database:
311
304
  ``sql, parameters`` every time a SQL query is executed
312
305
  :param use_counts_table: set to ``True`` to use a cached counts table, if available. See
313
306
  :ref:`python_api_cached_table_counts`
307
+ :param use_old_upsert: set to ``True`` to force the older upsert implementation. See
308
+ :ref:`python_api_old_upsert`
314
309
  :param strict: Apply STRICT mode to all created tables (unless overridden)
315
310
  """
316
311
 
@@ -327,10 +322,12 @@ class Database:
327
322
  tracer: Optional[Callable] = None,
328
323
  use_counts_table: bool = False,
329
324
  execute_plugins: bool = True,
325
+ use_old_upsert: bool = False,
330
326
  strict: bool = False,
331
327
  ):
332
328
  self.memory_name = None
333
329
  self.memory = False
330
+ self.use_old_upsert = use_old_upsert
334
331
  assert (filename_or_conn is not None and (not memory and not memory_name)) or (
335
332
  filename_or_conn is None and (memory or memory_name)
336
333
  ), "Either specify a filename_or_conn or pass memory=True"
@@ -678,16 +675,46 @@ class Database:
678
675
  @property
679
676
  def supports_strict(self) -> bool:
680
677
  "Does this database support STRICT mode?"
681
- try:
678
+ if not hasattr(self, "_supports_strict"):
679
+ try:
680
+ table_name = "t{}".format(secrets.token_hex(16))
681
+ with self.conn:
682
+ self.conn.execute(
683
+ "create table {} (name text) strict".format(table_name)
684
+ )
685
+ self.conn.execute("drop table {}".format(table_name))
686
+ self._supports_strict = True
687
+ except Exception:
688
+ self._supports_strict = False
689
+ return self._supports_strict
690
+
691
+ @property
692
+ def supports_on_conflict(self) -> bool:
693
+ # SQLite's upsert is implemented as INSERT INTO ... ON CONFLICT DO ...
694
+ if not hasattr(self, "_supports_on_conflict"):
682
695
  table_name = "t{}".format(secrets.token_hex(16))
683
- with self.conn:
684
- self.conn.execute(
685
- "create table {} (name text) strict".format(table_name)
686
- )
687
- self.conn.execute("drop table {}".format(table_name))
688
- return True
689
- except Exception:
690
- return False
696
+ try:
697
+ with self.conn:
698
+ self.conn.execute(
699
+ "create table {} (id integer primary key, name text)".format(
700
+ table_name
701
+ )
702
+ )
703
+ self.conn.execute(
704
+ "insert into {} (id, name) values (1, 'one')".format(table_name)
705
+ )
706
+ self.conn.execute(
707
+ (
708
+ "insert into {} (id, name) values (1, 'two') "
709
+ "on conflict do update set name = 'two'"
710
+ ).format(table_name)
711
+ )
712
+ self._supports_on_conflict = True
713
+ except Exception:
714
+ self._supports_on_conflict = False
715
+ finally:
716
+ self.conn.execute("drop table if exists {}".format(table_name))
717
+ return self._supports_on_conflict
691
718
 
692
719
  @property
693
720
  def sqlite_version(self) -> Tuple[int, ...]:
@@ -2973,13 +3000,19 @@ class Table(Queryable):
2973
3000
  replace,
2974
3001
  ignore,
2975
3002
  ):
2976
- # values is the list of insert data that is passed to the
2977
- # .execute() method - but some of them may be replaced by
2978
- # new primary keys if we are extracting any columns.
2979
- values = []
3003
+ """
3004
+ Given a list ``chunk`` of records that should be written to *this* table,
3005
+ return a list of ``(sql, parameters)`` 2-tuples which, when executed in
3006
+ order, perform the desired INSERT / UPSERT / REPLACE operation.
3007
+ """
2980
3008
  if hash_id_columns and hash_id is None:
2981
3009
  hash_id = "id"
3010
+
2982
3011
  extracts = resolve_extracts(extracts)
3012
+
3013
+ # Build a row-list ready for executemany-style flattening
3014
+ values = []
3015
+
2983
3016
  for record in chunk:
2984
3017
  record_values = []
2985
3018
  for key in all_columns:
@@ -2999,76 +3032,103 @@ class Table(Queryable):
2999
3032
  record_values.append(value)
3000
3033
  values.append(record_values)
3001
3034
 
3002
- queries_and_params = []
3003
- if upsert:
3004
- if isinstance(pk, str):
3005
- pks = [pk]
3006
- else:
3007
- pks = pk
3008
- self.last_pk = None
3009
- for record_values in values:
3010
- record = dict(zip(all_columns, record_values))
3011
- placeholders = list(pks)
3012
- # Need to populate not-null columns too, or INSERT OR IGNORE ignores
3013
- # them since it ignores the resulting integrity errors
3014
- if not_null:
3015
- placeholders.extend(not_null)
3016
- sql = "INSERT OR IGNORE INTO [{table}]({cols}) VALUES({placeholders});".format(
3017
- table=self.name,
3018
- cols=", ".join(["[{}]".format(p) for p in placeholders]),
3019
- placeholders=", ".join(["?" for p in placeholders]),
3020
- )
3021
- queries_and_params.append(
3022
- (sql, [record[col] for col in pks] + ["" for _ in (not_null or [])])
3023
- )
3024
- # UPDATE [book] SET [name] = 'Programming' WHERE [id] = 1001;
3025
- set_cols = [col for col in all_columns if col not in pks]
3026
- if set_cols:
3027
- sql2 = "UPDATE [{table}] SET {pairs} WHERE {wheres}".format(
3028
- table=self.name,
3029
- pairs=", ".join(
3030
- "[{}] = {}".format(col, conversions.get(col, "?"))
3031
- for col in set_cols
3032
- ),
3033
- wheres=" AND ".join("[{}] = ?".format(pk) for pk in pks),
3034
- )
3035
- queries_and_params.append(
3036
- (
3037
- sql2,
3038
- [record[col] for col in set_cols]
3039
- + [record[pk] for pk in pks],
3035
+ columns_sql = ", ".join(f"[{c}]" for c in all_columns)
3036
+ placeholder_expr = ", ".join(conversions.get(c, "?") for c in all_columns)
3037
+ row_placeholders_sql = ", ".join(f"({placeholder_expr})" for _ in values)
3038
+ flat_params = list(itertools.chain.from_iterable(values))
3039
+
3040
+ # replace=True mean INSERT OR REPLACE INTO
3041
+ if replace:
3042
+ sql = (
3043
+ f"INSERT OR REPLACE INTO [{self.name}] "
3044
+ f"({columns_sql}) VALUES {row_placeholders_sql}"
3045
+ )
3046
+ return [(sql, flat_params)]
3047
+
3048
+ # If not an upsert it's an INSERT, maybe with OR IGNORE
3049
+ if not upsert:
3050
+ or_ignore = ""
3051
+ if ignore:
3052
+ or_ignore = " OR IGNORE"
3053
+ sql = (
3054
+ f"INSERT{or_ignore} INTO [{self.name}] "
3055
+ f"({columns_sql}) VALUES {row_placeholders_sql}"
3056
+ )
3057
+ return [(sql, flat_params)]
3058
+
3059
+ # Everything from here on is for upsert=True
3060
+ pk_cols = [pk] if isinstance(pk, str) else list(pk)
3061
+ non_pk_cols = [c for c in all_columns if c not in pk_cols]
3062
+ conflict_sql = ", ".join(f"[{c}]" for c in pk_cols)
3063
+
3064
+ if self.db.supports_on_conflict and not self.db.use_old_upsert:
3065
+ if non_pk_cols:
3066
+ # DO UPDATE
3067
+ assignments = []
3068
+ for c in non_pk_cols:
3069
+ if c in conversions:
3070
+ assignments.append(
3071
+ f"[{c}] = {conversions[c].replace('?', f'excluded.[{c}]')}"
3040
3072
  )
3041
- )
3042
- # We can populate .last_pk right here
3043
- if num_records_processed == 1:
3044
- self.last_pk = tuple(record[pk] for pk in pks)
3045
- if len(self.last_pk) == 1:
3046
- self.last_pk = self.last_pk[0]
3073
+ else:
3074
+ assignments.append(f"[{c}] = excluded.[{c}]")
3075
+ do_clause = "DO UPDATE SET " + ", ".join(assignments)
3076
+ else:
3077
+ # All columns are in the PK – nothing to update.
3078
+ do_clause = "DO NOTHING"
3047
3079
 
3080
+ sql = (
3081
+ f"INSERT INTO [{self.name}] ({columns_sql}) "
3082
+ f"VALUES {row_placeholders_sql} "
3083
+ f"ON CONFLICT({conflict_sql}) {do_clause}"
3084
+ )
3085
+ return [(sql, flat_params)]
3086
+
3087
+ # At this point we need compatibility UPSERT for SQLite < 3.24.0
3088
+ # (INSERT OR IGNORE + second UPDATE stage)
3089
+ queries_and_params = []
3090
+ if isinstance(pk, str):
3091
+ pks = [pk]
3048
3092
  else:
3049
- or_what = ""
3050
- if replace:
3051
- or_what = "OR REPLACE "
3052
- elif ignore:
3053
- or_what = "OR IGNORE "
3054
- sql = """
3055
- INSERT {or_what}INTO [{table}] ({columns}) VALUES {rows};
3056
- """.strip().format(
3057
- or_what=or_what,
3093
+ pks = pk
3094
+ self.last_pk = None
3095
+ for record_values in values:
3096
+ record = dict(zip(all_columns, record_values))
3097
+ placeholders = list(pks)
3098
+ # Need to populate not-null columns too, or INSERT OR IGNORE ignores
3099
+ # them since it ignores the resulting integrity errors
3100
+ if not_null:
3101
+ placeholders.extend(not_null)
3102
+ sql = "INSERT OR IGNORE INTO [{table}]({cols}) VALUES({placeholders});".format(
3058
3103
  table=self.name,
3059
- columns=", ".join("[{}]".format(c) for c in all_columns),
3060
- rows=", ".join(
3061
- "({placeholders})".format(
3062
- placeholders=", ".join(
3063
- [conversions.get(col, "?") for col in all_columns]
3064
- )
3065
- )
3066
- for record in chunk
3067
- ),
3104
+ cols=", ".join(["[{}]".format(p) for p in placeholders]),
3105
+ placeholders=", ".join(["?" for p in placeholders]),
3068
3106
  )
3069
- flat_values = list(itertools.chain(*values))
3070
- queries_and_params = [(sql, flat_values)]
3071
-
3107
+ queries_and_params.append(
3108
+ (sql, [record[col] for col in pks] + ["" for _ in (not_null or [])])
3109
+ )
3110
+ # UPDATE [book] SET [name] = 'Programming' WHERE [id] = 1001;
3111
+ set_cols = [col for col in all_columns if col not in pks]
3112
+ if set_cols:
3113
+ sql2 = "UPDATE [{table}] SET {pairs} WHERE {wheres}".format(
3114
+ table=self.name,
3115
+ pairs=", ".join(
3116
+ "[{}] = {}".format(col, conversions.get(col, "?"))
3117
+ for col in set_cols
3118
+ ),
3119
+ wheres=" AND ".join("[{}] = ?".format(pk) for pk in pks),
3120
+ )
3121
+ queries_and_params.append(
3122
+ (
3123
+ sql2,
3124
+ [record[col] for col in set_cols] + [record[pk] for pk in pks],
3125
+ )
3126
+ )
3127
+ # We can populate .last_pk right here
3128
+ if num_records_processed == 1:
3129
+ self.last_pk = tuple(record[pk] for pk in pks)
3130
+ if len(self.last_pk) == 1:
3131
+ self.last_pk = self.last_pk[0]
3072
3132
  return queries_and_params
3073
3133
 
3074
3134
  def insert_chunk(
@@ -3086,7 +3146,7 @@ class Table(Queryable):
3086
3146
  num_records_processed,
3087
3147
  replace,
3088
3148
  ignore,
3089
- ):
3149
+ ) -> Optional[sqlite3.Cursor]:
3090
3150
  queries_and_params = self.build_insert_queries_and_params(
3091
3151
  extracts,
3092
3152
  chunk,
@@ -3101,9 +3161,8 @@ class Table(Queryable):
3101
3161
  replace,
3102
3162
  ignore,
3103
3163
  )
3104
-
3164
+ result = None
3105
3165
  with self.db.conn:
3106
- result = None
3107
3166
  for query, params in queries_and_params:
3108
3167
  try:
3109
3168
  result = self.db.execute(query, params)
@@ -3132,7 +3191,7 @@ class Table(Queryable):
3132
3191
  ignore,
3133
3192
  )
3134
3193
 
3135
- self.insert_chunk(
3194
+ result = self.insert_chunk(
3136
3195
  alter,
3137
3196
  extracts,
3138
3197
  second_half,
@@ -3150,20 +3209,7 @@ class Table(Queryable):
3150
3209
 
3151
3210
  else:
3152
3211
  raise
3153
- if num_records_processed == 1 and not upsert:
3154
- self.last_rowid = result.lastrowid
3155
- self.last_pk = self.last_rowid
3156
- # self.last_rowid will be 0 if a "INSERT OR IGNORE" happened
3157
- if (hash_id or pk) and self.last_rowid:
3158
- row = list(self.rows_where("rowid = ?", [self.last_rowid]))[0]
3159
- if hash_id:
3160
- self.last_pk = row[hash_id]
3161
- elif isinstance(pk, str):
3162
- self.last_pk = row[pk]
3163
- else:
3164
- self.last_pk = tuple(row[p] for p in pk)
3165
-
3166
- return
3212
+ return result
3167
3213
 
3168
3214
  def insert(
3169
3215
  self,
@@ -3203,7 +3249,7 @@ class Table(Queryable):
3203
3249
  :param not_null: Set of strings specifying columns that should be ``NOT NULL``.
3204
3250
  :param defaults: Dictionary specifying default values for specific columns.
3205
3251
  :param hash_id: Name of a column to create and use as a primary key, where the
3206
- value of thet primary key will be derived as a SHA1 hash of the other column values
3252
+ value of that primary key will be derived as a SHA1 hash of the other column values
3207
3253
  in the record. ``hash_id="id"`` is a common column name used for this.
3208
3254
  :param alter: Boolean, should any missing columns be added automatically?
3209
3255
  :param ignore: Boolean, if a record already exists with this primary key, ignore this insert.
@@ -3283,6 +3329,7 @@ class Table(Queryable):
3283
3329
 
3284
3330
  if upsert and (not pk and not hash_id):
3285
3331
  raise PrimaryKeyRequired("upsert() requires a pk")
3332
+
3286
3333
  assert not (hash_id and pk), "Use either pk= or hash_id="
3287
3334
  if hash_id_columns and (hash_id is None):
3288
3335
  hash_id = "id"
@@ -3314,6 +3361,7 @@ class Table(Queryable):
3314
3361
  self.last_pk = None
3315
3362
  if truncate and self.exists():
3316
3363
  self.db.execute("DELETE FROM [{}];".format(self.name))
3364
+ result = None
3317
3365
  for chunk in chunks(itertools.chain([first_record], records), batch_size):
3318
3366
  chunk = list(chunk)
3319
3367
  num_records_processed += len(chunk)
@@ -3321,6 +3369,12 @@ class Table(Queryable):
3321
3369
  if not self.exists():
3322
3370
  # Use the first batch to derive the table names
3323
3371
  column_types = suggest_column_types(chunk)
3372
+ if extracts:
3373
+ for col in extracts:
3374
+ if col in column_types:
3375
+ column_types[col] = (
3376
+ int # This will be an integer foreign key
3377
+ )
3324
3378
  column_types.update(columns or {})
3325
3379
  self.create(
3326
3380
  column_types,
@@ -3348,7 +3402,7 @@ class Table(Queryable):
3348
3402
 
3349
3403
  first = False
3350
3404
 
3351
- self.insert_chunk(
3405
+ result = self.insert_chunk(
3352
3406
  alter,
3353
3407
  extracts,
3354
3408
  chunk,
@@ -3364,6 +3418,33 @@ class Table(Queryable):
3364
3418
  ignore,
3365
3419
  )
3366
3420
 
3421
+ # If we only handled a single row populate self.last_pk
3422
+ if num_records_processed == 1:
3423
+ # For an insert we need to use result.lastrowid
3424
+ if not upsert and result is not None:
3425
+ self.last_rowid = result.lastrowid
3426
+ if (hash_id or pk) and self.last_rowid:
3427
+ # Set self.last_pk to the pk(s) for that rowid
3428
+ row = list(self.rows_where("rowid = ?", [self.last_rowid]))[0]
3429
+ if hash_id:
3430
+ self.last_pk = row[hash_id]
3431
+ elif isinstance(pk, str):
3432
+ self.last_pk = row[pk]
3433
+ else:
3434
+ self.last_pk = tuple(row[p] for p in pk)
3435
+ else:
3436
+ self.last_pk = self.last_rowid
3437
+ else:
3438
+ # For an upsert use first_record from earlier
3439
+ if hash_id:
3440
+ self.last_pk = hash_record(first_record, hash_id_columns)
3441
+ else:
3442
+ self.last_pk = (
3443
+ first_record[pk]
3444
+ if isinstance(pk, str)
3445
+ else tuple(first_record[p] for p in pk)
3446
+ )
3447
+
3367
3448
  if analyze:
3368
3449
  self.analyze()
3369
3450
 
@@ -3852,7 +3933,7 @@ def jsonify_if_needed(value):
3852
3933
 
3853
3934
 
3854
3935
  def resolve_extracts(
3855
- extracts: Optional[Union[Dict[str, str], List[str], Tuple[str]]]
3936
+ extracts: Optional[Union[Dict[str, str], List[str], Tuple[str]]],
3856
3937
  ) -> dict:
3857
3938
  if extracts is None:
3858
3939
  extracts = {}
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.1
1
+ Metadata-Version: 2.4
2
2
  Name: sqlite-utils
3
- Version: 3.38
3
+ Version: 4.0a0
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
@@ -16,13 +16,12 @@ Classifier: Intended Audience :: Science/Research
16
16
  Classifier: Intended Audience :: End Users/Desktop
17
17
  Classifier: Topic :: Database
18
18
  Classifier: License :: OSI Approved :: Apache Software License
19
- Classifier: Programming Language :: Python :: 3.8
20
19
  Classifier: Programming Language :: Python :: 3.9
21
20
  Classifier: Programming Language :: Python :: 3.10
22
21
  Classifier: Programming Language :: Python :: 3.11
23
22
  Classifier: Programming Language :: Python :: 3.12
24
23
  Classifier: Programming Language :: Python :: 3.13
25
- Requires-Python: >=3.8
24
+ Requires-Python: >=3.9
26
25
  Description-Content-Type: text/markdown
27
26
  License-File: LICENSE
28
27
  Requires-Dist: sqlite-fts4
@@ -52,8 +51,18 @@ Requires-Dist: types-pluggy; extra == "mypy"
52
51
  Requires-Dist: data-science-types; extra == "mypy"
53
52
  Provides-Extra: flake8
54
53
  Requires-Dist: flake8; extra == "flake8"
55
- Provides-Extra: tui
56
- Requires-Dist: trogon; extra == "tui"
54
+ Dynamic: author
55
+ Dynamic: classifier
56
+ Dynamic: description
57
+ Dynamic: description-content-type
58
+ Dynamic: home-page
59
+ Dynamic: license
60
+ Dynamic: license-file
61
+ Dynamic: project-url
62
+ Dynamic: provides-extra
63
+ Dynamic: requires-dist
64
+ Dynamic: requires-python
65
+ Dynamic: summary
57
66
 
58
67
  # sqlite-utils
59
68
 
@@ -29,6 +29,3 @@ pytest
29
29
  black>=24.1.1
30
30
  hypothesis
31
31
  cogapp
32
-
33
- [tui]
34
- trogon
@@ -916,7 +916,7 @@ def test_query_json_with_json_cols(db_path):
916
916
 
917
917
  @pytest.mark.parametrize(
918
918
  "content,is_binary",
919
- [(b"\x00\x0Fbinary", True), ("this is text", False), (1, False), (1.5, False)],
919
+ [(b"\x00\x0fbinary", True), ("this is text", False), (1, False), (1.5, False)],
920
920
  )
921
921
  def test_query_raw(db_path, content, is_binary):
922
922
  Database(db_path)["files"].insert({"content": content})
@@ -931,7 +931,7 @@ def test_query_raw(db_path, content, is_binary):
931
931
 
932
932
  @pytest.mark.parametrize(
933
933
  "content,is_binary",
934
- [(b"\x00\x0Fbinary", True), ("this is text", False), (1, False), (1.5, False)],
934
+ [(b"\x00\x0fbinary", True), ("this is text", False), (1, False), (1.5, False)],
935
935
  )
936
936
  def test_query_raw_lines(db_path, content, is_binary):
937
937
  Database(db_path)["files"].insert_all({"content": content} for _ in range(3))
@@ -1116,11 +1116,8 @@ def test_upsert_alter(db_path, tmpdir):
1116
1116
  cli.cli, ["upsert", db_path, "dogs", json_path, "--pk", "id"]
1117
1117
  )
1118
1118
  assert result.exit_code == 1
1119
- assert (
1120
- "Error: no such column: age\n\n"
1121
- "sql = UPDATE [dogs] SET [age] = ? WHERE [id] = ?\n"
1122
- "parameters = [5, 1]"
1123
- ) == result.output.strip()
1119
+ # Could be one of two errors depending on SQLite version
1120
+ assert ("Try using --alter to add additional columns") in result.output.strip()
1124
1121
  # Should succeed with --alter
1125
1122
  result = CliRunner().invoke(
1126
1123
  cli.cli, ["upsert", db_path, "dogs", json_path, "--pk", "id", "--alter"]
@@ -2248,7 +2245,7 @@ def test_integer_overflow_error(tmpdir):
2248
2245
  assert result.exit_code == 1
2249
2246
  assert result.output == (
2250
2247
  "Error: Python int too large to convert to SQLite INTEGER\n\n"
2251
- "sql = INSERT INTO [items] ([bignumber]) VALUES (?);\n"
2248
+ "sql = INSERT INTO [items] ([bignumber]) VALUES (?)\n"
2252
2249
  "parameters = [34223049823094832094802398430298048240]\n"
2253
2250
  )
2254
2251
 
@@ -173,14 +173,16 @@ def test_create_table_from_example_with_compound_primary_keys(fresh_db):
173
173
  @pytest.mark.parametrize(
174
174
  "method_name", ("insert", "upsert", "insert_all", "upsert_all")
175
175
  )
176
- def test_create_table_with_custom_columns(fresh_db, method_name):
177
- table = fresh_db["dogs"]
176
+ @pytest.mark.parametrize("use_old_upsert", (False, True))
177
+ def test_create_table_with_custom_columns(method_name, use_old_upsert):
178
+ db = Database(memory=True, use_old_upsert=use_old_upsert)
179
+ table = db["dogs"]
178
180
  method = getattr(table, method_name)
179
181
  record = {"id": 1, "name": "Cleo", "age": "5"}
180
182
  if method_name.endswith("_all"):
181
183
  record = [record]
182
184
  method(record, pk="id", columns={"age": int, "weight": float})
183
- assert ["dogs"] == fresh_db.table_names()
185
+ assert ["dogs"] == db.table_names()
184
186
  expected_columns = [
185
187
  {"name": "id", "type": "INTEGER"},
186
188
  {"name": "name", "type": "TEXT"},
@@ -1,9 +1,19 @@
1
1
  from click.testing import CliRunner
2
2
  import click
3
3
  import importlib
4
+ import pytest
4
5
  from sqlite_utils import cli, Database, hookimpl, plugins
5
6
 
6
7
 
8
+ def _supports_pragma_function_list():
9
+ db = Database(memory=True)
10
+ try:
11
+ db.execute("select * from pragma_function_list()")
12
+ except Exception:
13
+ return False
14
+ return True
15
+
16
+
7
17
  def test_register_commands():
8
18
  importlib.reload(cli)
9
19
  assert plugins.get_plugins() == []
@@ -37,6 +47,10 @@ def test_register_commands():
37
47
  assert plugins.get_plugins() == []
38
48
 
39
49
 
50
+ @pytest.mark.skipif(
51
+ not _supports_pragma_function_list(),
52
+ reason="Needs SQLite version that supports pragma_function_list()",
53
+ )
40
54
  def test_prepare_connection():
41
55
  importlib.reload(cli)
42
56
  assert plugins.get_plugins() == []
@@ -54,7 +68,7 @@ def test_prepare_connection():
54
68
  return [
55
69
  row[0]
56
70
  for row in db.execute(
57
- "select distinct name from pragma_function_list order by 1"
71
+ "select distinct name from pragma_function_list() order by 1"
58
72
  ).fetchall()
59
73
  ]
60
74
 
@@ -18,7 +18,7 @@ def test_tracer():
18
18
  ("select name from sqlite_master where type = 'view'", None),
19
19
  ("CREATE TABLE [dogs] (\n [name] TEXT\n);\n ", None),
20
20
  ("select name from sqlite_master where type = 'view'", None),
21
- ("INSERT INTO [dogs] ([name]) VALUES (?);", ["Cleopaws"]),
21
+ ("INSERT INTO [dogs] ([name]) VALUES (?)", ["Cleopaws"]),
22
22
  ("select name from sqlite_master where type = 'view'", None),
23
23
  (
24
24
  "CREATE VIRTUAL TABLE [dogs_fts] USING FTS5 (\n [name],\n content=[dogs]\n)",
@@ -1,9 +1,12 @@
1
1
  from sqlite_utils.db import PrimaryKeyRequired
2
+ from sqlite_utils import Database
2
3
  import pytest
3
4
 
4
5
 
5
- def test_upsert(fresh_db):
6
- table = fresh_db["table"]
6
+ @pytest.mark.parametrize("use_old_upsert", (False, True))
7
+ def test_upsert(use_old_upsert):
8
+ db = Database(memory=True, use_old_upsert=use_old_upsert)
9
+ table = db["table"]
7
10
  table.insert({"id": 1, "name": "Cleo"}, pk="id")
8
11
  table.upsert({"id": 1, "age": 5}, pk="id", alter=True)
9
12
  assert list(table.rows) == [{"id": 1, "name": "Cleo", "age": 5}]
File without changes
File without changes
File without changes
File without changes
File without changes