From c791a310477712000a39f05728291b34494c1564 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 21 Dec 2025 00:32:25 +0000 Subject: [PATCH 1/3] Make square braces only return tables, not views Breaking change for 4.0: db["name"] now only returns Table objects, never View objects. This improves type safety since views lack methods like .insert(). Use db.view("view_name") to access views explicitly. Closes #699 --- sqlite_utils/cli.py | 8 +++++--- sqlite_utils/db.py | 10 +++++----- tests/test_cli.py | 8 +++++--- tests/test_create.py | 4 ++-- tests/test_fts.py | 2 +- tests/test_tracer.py | 1 - 6 files changed, 18 insertions(+), 15 deletions(-) diff --git a/sqlite_utils/cli.py b/sqlite_utils/cli.py index 9b9ee20e..65deb303 100644 --- a/sqlite_utils/cli.py +++ b/sqlite_utils/cli.py @@ -12,6 +12,7 @@ BadMultiValues, DescIndex, NoTable, + NoView, quote_identifier, ) from sqlite_utils.plugins import pm, get_plugins @@ -1796,9 +1797,10 @@ def drop_view(path, view, ignore, load_extension): _register_db_for_cleanup(db) _load_extensions(db, load_extension) try: - db[view].drop(ignore=ignore) - except OperationalError: - raise click.ClickException('View "{}" does not exist'.format(view)) + db.view(view).drop(ignore=ignore) + except NoView: + if not ignore: + raise click.ClickException('View "{}" does not exist'.format(view)) @cli.command() diff --git a/sqlite_utils/db.py b/sqlite_utils/db.py index aacdc893..b0ee014b 100644 --- a/sqlite_utils/db.py +++ b/sqlite_utils/db.py @@ -445,15 +445,15 @@ def tracer( finally: self._tracer = prev_tracer - def __getitem__(self, table_name: str) -> Union["Table", "View"]: + def __getitem__(self, table_name: str) -> "Table": """ ``db[table_name]`` returns a :class:`.Table` object for the table with the specified name. If the table does not exist yet it will be created the first time data is inserted into it. + Use ``db.view(view_name)`` to access views. + :param table_name: The name of the table """ - if table_name in self.view_names(): - return self.view(table_name) return self.table(table_name) def __repr__(self) -> str: @@ -1206,9 +1206,9 @@ def create_view( return self elif replace: # If SQL is the same, do nothing - if create_sql == self[name].schema: + if create_sql == self.view(name).schema: return self - self[name].drop() + self.view(name).drop() self.execute(create_sql) return self diff --git a/tests/test_cli.py b/tests/test_cli.py index 40c3595c..eb7ee30f 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -1367,7 +1367,8 @@ def test_create_view(): ) assert result.exit_code == 0 assert ( - 'CREATE VIEW "version" AS select sqlite_version()' == db["version"].schema + 'CREATE VIEW "version" AS select sqlite_version()' + == db.view("version").schema ) @@ -1404,7 +1405,7 @@ def test_create_view_ignore(): assert result.exit_code == 0 assert ( 'CREATE VIEW "version" AS select sqlite_version() + 1' - == db["version"].schema + == db.view("version").schema ) @@ -1425,7 +1426,8 @@ def test_create_view_replace(): ) assert result.exit_code == 0 assert ( - 'CREATE VIEW "version" AS select sqlite_version()' == db["version"].schema + 'CREATE VIEW "version" AS select sqlite_version()' + == db.view("version").schema ) diff --git a/tests/test_create.py b/tests/test_create.py index ea09c459..18292164 100644 --- a/tests/test_create.py +++ b/tests/test_create.py @@ -1082,7 +1082,7 @@ def test_drop(fresh_db): def test_drop_view(fresh_db): fresh_db.create_view("foo_view", "select 1") assert ["foo_view"] == fresh_db.view_names() - assert None is fresh_db["foo_view"].drop() + assert None is fresh_db.view("foo_view").drop() assert [] == fresh_db.view_names() @@ -1093,7 +1093,7 @@ def test_drop_ignore(fresh_db): # Testing view is harder, we need to create it in order # to get a View object, then drop it twice fresh_db.create_view("foo_view", "select 1") - view = fresh_db["foo_view"] + view = fresh_db.view("foo_view") assert isinstance(view, View) view.drop() with pytest.raises(sqlite3.OperationalError): diff --git a/tests/test_fts.py b/tests/test_fts.py index 9c635ffa..b80af6ac 100644 --- a/tests/test_fts.py +++ b/tests/test_fts.py @@ -424,7 +424,7 @@ def test_enable_fts_error_message_on_views(): db = Database(memory=True) db.create_view("hello", "select 1 + 1") with pytest.raises(NotImplementedError) as e: - db["hello"].enable_fts() # type: ignore[call-arg] + db.view("hello").enable_fts() # type: ignore[call-arg] assert e.value.args[0] == "enable_fts() is supported on tables but not on views" diff --git a/tests/test_tracer.py b/tests/test_tracer.py index ac490c58..ab55e8c2 100644 --- a/tests/test_tracer.py +++ b/tests/test_tracer.py @@ -15,7 +15,6 @@ def test_tracer(): ("select name from sqlite_master where type = 'view'", None), ("select name from sqlite_master where type = 'table'", None), ("select name from sqlite_master where type = 'view'", None), - ("select name from sqlite_master where type = 'view'", None), ("select name from sqlite_master where type = 'table'", None), ("select name from sqlite_master where type = 'view'", None), ('CREATE TABLE "dogs" (\n "name" TEXT\n);\n ', None), From 147fd7e48b8e9323aff03c05d4203df9966e7051 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 21 Dec 2025 00:47:10 +0000 Subject: [PATCH 2/3] Update docs: db[] only returns tables, not views Updated python-api.rst to clarify that dictionary-style syntax db["name"] only returns Table objects. Use db.view("name") for views. --- docs/python-api.rst | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/docs/python-api.rst b/docs/python-api.rst index 267591ac..acc2515e 100644 --- a/docs/python-api.rst +++ b/docs/python-api.rst @@ -282,11 +282,13 @@ Using this factory function allows you to set :ref:`python_api_table_configurati The ``db.table()`` method will always return a :ref:`reference_db_table` instance, or raise a ``sqlite_utils.db.NoTable`` exception if the table name is actually a SQL view. -You can also access tables or views using dictionary-style syntax, like this: +You can also access tables using dictionary-style syntax, like this: .. code-block:: python - table_or_view = db["my_table_or_view_name"] + table = db["my_table_name"] + +This is equivalent to calling ``db.table("my_table_name")``. It will raise a ``sqlite_utils.db.NoTable`` exception if the name refers to a view rather than a table. If a table accessed using either of these methods does not yet exist, it will be created the first time you attempt to insert or upsert data into it. From 1d584f0c607329379752c202d46c264a63085e6d Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 21 Dec 2025 01:09:30 +0000 Subject: [PATCH 3/3] Add upgrading.rst with 4.0 breaking changes documentation Documents all breaking changes in 4.0 for both the Python library and CLI: - db["name"] only returns tables, not views - Default float type changed to REAL - convert() no longer skips False values - Table schemas use double quotes - Upsert uses modern SQLite syntax - Type detection now default for CSV/TSV import - sqlite-utils tui moved to plugin - Python 3.10+ required --- docs/index.rst | 1 + docs/upgrading.rst | 134 +++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 135 insertions(+) create mode 100644 docs/upgrading.rst diff --git a/docs/index.rst b/docs/index.rst index 27a22c33..530004ff 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -41,3 +41,4 @@ Contents cli-reference contributing changelog + upgrading diff --git a/docs/upgrading.rst b/docs/upgrading.rst new file mode 100644 index 00000000..dd9df51a --- /dev/null +++ b/docs/upgrading.rst @@ -0,0 +1,134 @@ +.. _upgrading: + +===================== + Upgrading to 4.0 +===================== + +sqlite-utils 4.0 includes several breaking changes. This page describes what has changed and how to update your code. + +Python library changes +====================== + +db["name"] only returns tables +------------------------------ + +In previous versions, ``db["table_or_view_name"]`` would return either a :ref:`Table ` or :ref:`View ` object depending on what existed in the database. + +In 4.0, this syntax **only returns Table objects**. Attempting to use it with a view name will raise a ``sqlite_utils.db.NoTable`` exception. + +**Before (3.x):** + +.. code-block:: python + + # This could return either a Table or View + obj = db["my_view"] + obj.drop() + +**After (4.0):** + +.. code-block:: python + + # Use db.view() explicitly for views + view = db.view("my_view") + view.drop() + + # db["name"] now only works with tables + table = db["my_table"] + +This change improves type safety since views lack methods like ``.insert()`` that are available on tables. + +db.table() raises NoTable for views +----------------------------------- + +The ``db.table(name)`` method now raises ``sqlite_utils.db.NoTable`` if the name refers to a view. Use ``db.view(name)`` instead. + +Default floating point type is REAL +----------------------------------- + +When inserting data with auto-detected column types, floating point values now create columns with type ``REAL`` instead of ``FLOAT``. ``REAL`` is the correct SQLite affinity for floating point values. + +This affects the schema of newly created tables but does not change how data is stored or queried. + +convert() no longer skips False values +-------------------------------------- + +The ``table.convert()`` method previously skipped rows where the column value evaluated to ``False`` (including ``0``, empty strings, and ``None``). This behavior has been removed. + +**Before (3.x):** + +.. code-block:: python + + # Rows with falsey values were skipped by default + # --skip-false was needed to process all rows + table.convert("column", lambda x: x.upper(), skip_false=False) + +**After (4.0):** + +.. code-block:: python + + # All rows are now processed, including those with falsey values + table.convert("column", lambda x: x.upper() if x else x) + +Table schemas use double quotes +------------------------------- + +Tables created by sqlite-utils now use ``"double-quotes"`` for table and column names in the schema instead of ``[square-braces]``. Both are valid SQL, but double quotes are the SQL standard. + +This only affects how the schema is written. Existing tables are not modified. + +Upsert uses modern SQLite syntax +-------------------------------- + +Upsert operations now use SQLite's ``INSERT ... ON CONFLICT SET`` syntax on SQLite versions 3.24.0 and later. The previous implementation used ``INSERT OR IGNORE`` followed by ``UPDATE``. + +To use the old behavior, pass ``use_old_upsert=True`` to the ``Database()`` constructor: + +.. code-block:: python + + db = Database("my.db", use_old_upsert=True) + +CLI changes +=========== + +Type detection is now the default +--------------------------------- + +When importing CSV or TSV data with the ``insert`` or ``upsert`` commands, sqlite-utils now automatically detects column types. Previously all columns were treated as ``TEXT`` unless ``--detect-types`` was passed. + +**Before (3.x):** + +.. code-block:: bash + + # Types were detected only with --detect-types + sqlite-utils insert data.db mytable data.csv --csv --detect-types + +**After (4.0):** + +.. code-block:: bash + + # Types are detected by default + sqlite-utils insert data.db mytable data.csv --csv + + # Use --no-detect-types to treat all columns as TEXT + sqlite-utils insert data.db mytable data.csv --csv --no-detect-types + +The ``SQLITE_UTILS_DETECT_TYPES`` environment variable has been removed. + +convert --skip-false removed +---------------------------- + +The ``--skip-false`` option for ``sqlite-utils convert`` has been removed. All rows are now processed regardless of whether the column value is falsey. + +sqlite-utils tui is now a plugin +-------------------------------- + +The ``sqlite-utils tui`` command has been moved to a separate plugin. Install it with: + +.. code-block:: bash + + sqlite-utils install sqlite-utils-tui + +Python version requirements +=========================== + +sqlite-utils 4.0 requires Python 3.10 or higher. Python 3.8 and 3.9 are no longer supported.