Skip to content

Commit fcfccea

Browse files
committed
Support ANY column types for strict tables
Closes #790, #820
1 parent 57192ef commit fcfccea

13 files changed

Lines changed: 292 additions & 16 deletions

‎docs/changelog.rst‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ Unreleased
1010
----------
1111

1212
- New ``table.checks``, ``table.column_checks`` and ``table.table_checks`` introspection properties expose column-level and table-level ``CHECK`` constraints. (:issue:`834`)
13+
- New ``sqlite_utils.ANY`` marker type for creating and introspecting SQLite ``ANY`` columns. The Python API and CLI can create, add and transform these columns, and ``table.transform()`` and ``table.extract()`` now preserve ``ANY`` columns and their values in ``STRICT`` tables. (:issue:`790`)
1314
- ``table.transform()`` now preserves ``CHECK`` constraints, including comments within their expressions. Renaming a column rewrites identifier references in checks without changing string literals or function names. Dropping a column drops a check owned by that column, and raises ``TransformError`` if a remaining check depends on it. (:issue:`762`)
1415
- ``table.transform()`` now preserves comments immediately before or after column definitions. These comments move with the column if it is renamed or reordered, and are removed if the column is dropped. (:issue:`762`)
1516
- ``table.transform(rename=...)`` now preserves explicit indexes on renamed columns by dropping and recreating those indexes against the new column names. Previously this raised a ``TransformError``. (:issue:`822`)

‎docs/cli-reference.rst‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -494,7 +494,7 @@ See :ref:`cli_transform_table`.
494494

495495
Options:
496496
--type <TEXT CHOICE>... Change column type to INTEGER, TEXT, FLOAT,
497-
REAL or BLOB
497+
REAL, BLOB or ANY
498498
--drop TEXT Drop this column
499499
--rename <TEXT TEXT>... Rename this column to X
500500
-o, --column-order TEXT Reorder columns
@@ -963,7 +963,7 @@ See :ref:`cli_create_table`.
963963
height real \
964964
photo blob --pk id
965965

966-
Valid column types are text, integer, real, float and blob.
966+
Valid column types are text, integer, real, float, blob and any.
967967

968968
Options:
969969
--pk TEXT Column to use as primary key
@@ -1257,7 +1257,7 @@ See :ref:`cli_add_column`.
12571257
::
12581258

12591259
Usage: sqlite-utils add-column [OPTIONS] PATH TABLE COL_NAME
1260-
[integer|int|float|real|text|str|blob|bytes]
1260+
[integer|int|float|real|text|str|blob|bytes|any]
12611261

12621262
Add a column to the specified table
12631263

‎docs/cli.rst‎

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1390,7 +1390,14 @@ Use ``--type column-name type`` to override the type automatically chosen when t
13901390
13911391
This is useful for values such as ZIP codes, which may look like integers but should be stored as ``TEXT`` to preserve leading zeros.
13921392

1393-
The column type should be one of ``TEXT``, ``INTEGER``, ``FLOAT``, ``REAL`` or ``BLOB``. Column types are matched case-insensitively.
1393+
The column type should be one of ``TEXT``, ``INTEGER``, ``FLOAT``, ``REAL``, ``BLOB`` or ``ANY``. Column types are matched case-insensitively.
1394+
1395+
``ANY`` is especially useful with ``--strict``. An ``ANY`` column in a strict table preserves values without coercion, so text such as ``000123`` remains text instead of being converted to an integer:
1396+
1397+
.. code-block:: bash
1398+
1399+
sqlite-utils insert events.db events events.csv --csv --strict \
1400+
--type payload any
13941401
13951402
As with detected column types, ``--type`` only affects tables created by the command. If the table already exists, its existing column types are left unchanged.
13961403

@@ -2141,6 +2148,12 @@ You can create a table in `SQLite STRICT mode <https://www.sqlite.org/stricttabl
21412148
21422149
sqlite-utils create-table mydb.db mytable id integer name text --strict
21432150
2151+
Use the ``any`` type for a strict column that should accept integers, floating point values, text, binary data or null without coercion:
2152+
2153+
.. code-block:: bash
2154+
2155+
sqlite-utils create-table events.db events id integer payload any --strict
2156+
21442157
.. code-block:: bash
21452158
21462159
sqlite-utils tables mydb.db --schema -t
@@ -2223,7 +2236,7 @@ The ``transform`` command allows you to apply complex transformations to a table
22232236
Every option for this table (with the exception of ``--pk-none``) can be specified multiple times. The options are as follows:
22242237

22252238
``--type column-name new-type``
2226-
Change the type of the specified column. Valid types are ``integer``, ``text``, ``float``, ``blob``.
2239+
Change the type of the specified column. Valid types are ``integer``, ``text``, ``float``, ``real``, ``blob`` and ``any``.
22272240

22282241
``--drop column-name``
22292242
Drop the specified column.

‎docs/python-api.rst‎

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -828,6 +828,19 @@ You can pass ``strict=True`` to create a table in ``STRICT`` mode:
828828
"name": str,
829829
}, strict=True)
830830
831+
SQLite ``STRICT`` tables can use the ``ANY`` column type for values that should retain their exact SQLite storage class without coercion. Use the ``sqlite_utils.ANY`` marker type:
832+
833+
.. code-block:: python
834+
835+
import sqlite_utils
836+
837+
db.table("events").create({
838+
"id": int,
839+
"payload": sqlite_utils.ANY,
840+
}, pk="id", strict=True)
841+
842+
An ``ANY`` column can store integers, floating point values, text, binary data or ``None``. In a ``STRICT`` table a text value such as ``"000123"`` remains text with its leading zeroes intact. SQLite also accepts ``ANY`` columns in ordinary non-``STRICT`` tables, but those columns apply numeric affinity and would store that same value as the integer ``123``.
843+
831844
.. note::
832845
In the CLI: :ref:`sqlite-utils create-table <cli_create_table>`
833846

@@ -1569,7 +1582,7 @@ You can specify the ``col_type`` argument either using a SQLite type as a string
15691582

15701583
The ``col_type`` is optional - if you omit it the type of ``TEXT`` will be used.
15711584

1572-
SQLite types you can specify are ``"TEXT"``, ``"INTEGER"``, ``"FLOAT"``, ``"REAL"`` or ``"BLOB"``.
1585+
SQLite types you can specify are ``"TEXT"``, ``"INTEGER"``, ``"FLOAT"``, ``"REAL"``, ``"BLOB"`` or ``"ANY"``. You can use the ``sqlite_utils.ANY`` marker instead of the ``"ANY"`` string.
15731586

15741587
If you pass a Python type, it will be mapped to SQLite types as shown here::
15751588

@@ -1582,6 +1595,7 @@ If you pass a Python type, it will be mapped to SQLite types as shown here::
15821595
datetime.date: "TEXT"
15831596
datetime.time: "TEXT"
15841597
datetime.timedelta: "TEXT"
1598+
sqlite_utils.ANY: "ANY"
15851599

15861600
# If numpy is installed
15871601
np.int8: "INTEGER"
@@ -1831,6 +1845,8 @@ Pass ``strict=False`` to convert a strict table back to a regular non-strict tab
18311845
18321846
table.transform(strict=False)
18331847
1848+
If the table has ``ANY`` columns, converting it to non-strict mode can coerce text values that look numeric. For example, SQLite converts ``"000123"`` to the integer ``123`` when copying it into an ordinary ``ANY`` column. This is SQLite's documented distinction between `STRICT and ordinary ANY columns <https://www.sqlite.org/stricttables.html#the_any_datatype>`__.
1849+
18341850
The default is ``strict=None``, which preserves the table's existing strict mode.
18351851

18361852
Passing ``strict=True`` raises ``sqlite_utils.db.TransformError`` if the available SQLite version does not support strict tables.
@@ -2458,6 +2474,11 @@ The ``.columns_dict`` property returns a dictionary version of the columns with
24582474
>>> db.table("PlantType").columns_dict
24592475
{'id': <class 'int'>, 'value': <class 'str'>}
24602476
2477+
SQLite ``ANY`` columns are represented by the ``sqlite_utils.ANY`` marker type::
2478+
2479+
>>> db.table("events").columns_dict
2480+
{'id': <class 'int'>, 'payload': <class 'sqlite_utils.utils.ANY'>}
2481+
24612482
.. _python_api_introspection_default_values:
24622483
24632484
.default_values

‎sqlite_utils/__init__.py‎

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,13 @@
11
from .db import Database
22
from .hookspecs import hookimpl, hookspec
33
from .migrations import Migrations
4-
from .utils import suggest_column_types
4+
from .utils import ANY, suggest_column_types
55

6-
__all__ = ["Database", "Migrations", "hookimpl", "hookspec", "suggest_column_types"]
6+
__all__ = [
7+
"ANY",
8+
"Database",
9+
"Migrations",
10+
"hookimpl",
11+
"hookspec",
12+
"suggest_column_types",
13+
]

‎sqlite_utils/cli.py‎

Lines changed: 15 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -76,7 +76,7 @@ def _close_databases(ctx):
7676
pass
7777

7878

79-
VALID_COLUMN_TYPES = ("INTEGER", "TEXT", "FLOAT", "REAL", "BLOB")
79+
VALID_COLUMN_TYPES = ("INTEGER", "TEXT", "FLOAT", "REAL", "BLOB", "ANY")
8080

8181
UNICODE_ERROR = """
8282
{}
@@ -489,7 +489,17 @@ def dump(path, load_extension):
489489
@click.argument(
490490
"col_type",
491491
type=click.Choice(
492-
["integer", "int", "float", "real", "text", "str", "blob", "bytes"],
492+
[
493+
"integer",
494+
"int",
495+
"float",
496+
"real",
497+
"text",
498+
"str",
499+
"blob",
500+
"bytes",
501+
"any",
502+
],
493503
case_sensitive=False,
494504
),
495505
required=False,
@@ -1758,7 +1768,7 @@ def create_table(
17581768
height real \\
17591769
photo blob --pk id
17601770
1761-
Valid column types are text, integer, real, float and blob.
1771+
Valid column types are text, integer, real, float, blob and any.
17621772
"""
17631773
db = sqlite_utils.Database(path)
17641774
_register_db_for_cleanup(db)
@@ -2668,12 +2678,10 @@ def schema(
26682678
"--type",
26692679
type=(
26702680
str,
2671-
click.Choice(
2672-
["INTEGER", "TEXT", "FLOAT", "REAL", "BLOB"], case_sensitive=False
2673-
),
2681+
click.Choice(list(VALID_COLUMN_TYPES), case_sensitive=False),
26742682
),
26752683
multiple=True,
2676-
help="Change column type to INTEGER, TEXT, FLOAT, REAL or BLOB",
2684+
help="Change column type to INTEGER, TEXT, FLOAT, REAL, BLOB or ANY",
26772685
)
26782686
@click.option("--drop", type=str, multiple=True, help="Drop this column")
26792687
@click.option(

‎sqlite_utils/db.py‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@
3939
sql_ends_in_line_comment,
4040
)
4141
from .utils import (
42+
ANY,
4243
OperationalError,
4344
chunks,
4445
column_affinity,
@@ -366,6 +367,7 @@ def _iter_complete_sql_statements(sql: str) -> Generator[str, None, None]:
366367
decimal.Decimal: "REAL",
367368
None.__class__: "TEXT",
368369
uuid.UUID: "TEXT",
370+
ANY: "ANY",
369371
# SQLite explicit types
370372
"TEXT": "TEXT",
371373
"INTEGER": "INTEGER",
@@ -380,6 +382,8 @@ def _iter_complete_sql_statements(sql: str) -> Generator[str, None, None]:
380382
"real": "REAL",
381383
"blob": "BLOB",
382384
"bytes": "BLOB",
385+
"ANY": "ANY",
386+
"any": "ANY",
383387
}
384388
# If numpy is available, add more types
385389
if np:
@@ -3092,6 +3096,15 @@ def extract(
30923096
if col in columns
30933097
}
30943098
if lookup_table.exists():
3099+
if (
3100+
self.strict
3101+
and ANY in lookup_columns_definition.values()
3102+
and not lookup_table.strict
3103+
):
3104+
raise InvalidColumns(
3105+
f"Lookup table {table} already exists but is not STRICT, "
3106+
"so it cannot preserve ANY column values"
3107+
)
30953108
if not set(lookup_columns_definition.items()).issubset(
30963109
lookup_table.columns_dict.items()
30973110
):
@@ -3105,6 +3118,7 @@ def extract(
31053118
**lookup_columns_definition,
31063119
},
31073120
pk="id",
3121+
strict=self.strict,
31083122
)
31093123
lookup_columns = [(rename.get(col) or col) for col in columns]
31103124
lookup_table.create_index(lookup_columns, unique=True, if_not_exists=True)

‎sqlite_utils/utils.py‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,10 @@
5959
T = TypeVar("T")
6060

6161

62+
class ANY:
63+
"""Marker type for an SQLite ``ANY`` column."""
64+
65+
6266
class _CloseableIterator(Iterator[Row]):
6367
"""Iterator wrapper that closes a file when iteration is complete."""
6468

@@ -178,6 +182,8 @@ def column_affinity(column_type: str) -> type:
178182
return bytes
179183
if "REAL" in column_type or "FLOA" in column_type or "DOUB" in column_type:
180184
return float
185+
if column_type == "ANY":
186+
return ANY
181187
# Default is 'NUMERIC', which we currently also treat as float
182188
return float
183189

‎tests/test_cli.py‎

Lines changed: 78 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
import pytest
1010
from click.testing import CliRunner
1111

12-
from sqlite_utils import Database, cli
12+
from sqlite_utils import ANY, Database, cli
1313
from sqlite_utils.db import ForeignKey, Index
1414

1515

@@ -355,6 +355,7 @@ def test_create_index_desc(db_path):
355355
("blob", "BLOB", 'CREATE TABLE "dogs" (\n "name" TEXT\n, "blob" BLOB)'),
356356
("blob", "bytes", 'CREATE TABLE "dogs" (\n "name" TEXT\n, "blob" BLOB)'),
357357
("blob", "BYTES", 'CREATE TABLE "dogs" (\n "name" TEXT\n, "blob" BLOB)'),
358+
("anything", "any", 'CREATE TABLE "dogs" (\n "name" TEXT\n, "anything" ANY)'),
358359
("default", None, 'CREATE TABLE "dogs" (\n "name" TEXT\n, "default" TEXT)'),
359360
),
360361
)
@@ -2007,6 +2008,25 @@ def test_transform_strict_option_with_invalid_data(db_path):
20072008
assert not any(name.startswith("dogs_new_") for name in db.table_names())
20082009

20092010

2011+
def test_transform_column_to_any(db_path):
2012+
db = Database(db_path)
2013+
if not db.supports_strict:
2014+
pytest.skip("SQLite version does not support strict tables")
2015+
db.table("items").create({"data": str}, strict=True)
2016+
db.table("items").insert({"data": "000123"})
2017+
2018+
result = CliRunner().invoke(
2019+
cli.cli, ["transform", db_path, "items", "--type", "data", "any"]
2020+
)
2021+
2022+
assert result.exit_code == 0, result.output
2023+
assert db.table("items").columns_dict == {"data": ANY}
2024+
assert db.execute("select typeof(data), data from items").fetchone() == (
2025+
"text",
2026+
"000123",
2027+
)
2028+
2029+
20102030
@pytest.mark.parametrize(
20112031
"extra_args,expected_schema",
20122032
(
@@ -2872,6 +2892,30 @@ def test_create_table_strict(strict):
28722892
assert db.table("items").columns_dict == {"id": int, "w": float}
28732893

28742894

2895+
def test_create_table_strict_any():
2896+
runner = CliRunner()
2897+
with runner.isolated_filesystem():
2898+
db = Database("test.db")
2899+
if not db.supports_strict:
2900+
pytest.skip("SQLite version does not support strict tables")
2901+
result = runner.invoke(
2902+
cli.cli,
2903+
[
2904+
"create-table",
2905+
"test.db",
2906+
"items",
2907+
"id",
2908+
"integer",
2909+
"data",
2910+
"any",
2911+
"--strict",
2912+
],
2913+
)
2914+
assert result.exit_code == 0, result.output
2915+
assert db.table("items").strict is True
2916+
assert db.table("items").columns_dict == {"id": int, "data": ANY}
2917+
2918+
28752919
@pytest.mark.parametrize("method", ("insert", "upsert"))
28762920
@pytest.mark.parametrize("strict", (False, True))
28772921
def test_insert_upsert_strict(tmpdir, method, strict):
@@ -2887,6 +2931,39 @@ def test_insert_upsert_strict(tmpdir, method, strict):
28872931
assert db.table("items").strict == strict or not db.supports_strict
28882932

28892933

2934+
@pytest.mark.parametrize("method", ("insert", "upsert"))
2935+
def test_insert_upsert_strict_any(tmpdir, method):
2936+
db_path = str(tmpdir / "test.db")
2937+
db = Database(db_path)
2938+
if not db.supports_strict:
2939+
pytest.skip("SQLite version does not support strict tables")
2940+
db.close()
2941+
result = CliRunner().invoke(
2942+
cli.cli,
2943+
[
2944+
method,
2945+
db_path,
2946+
"items",
2947+
"-",
2948+
"--csv",
2949+
"--pk",
2950+
"id",
2951+
"--type",
2952+
"data",
2953+
"any",
2954+
"--strict",
2955+
],
2956+
input="id,data\n1,000123",
2957+
)
2958+
assert result.exit_code == 0, result.output
2959+
db = Database(db_path)
2960+
assert db.table("items").columns_dict == {"id": int, "data": ANY}
2961+
assert db.execute("select typeof(data), data from items").fetchone() == (
2962+
"text",
2963+
"000123",
2964+
)
2965+
2966+
28902967
def test_extract_bad_column_clean_error(db_path):
28912968
db = Database(db_path)
28922969
db.table("trees").insert({"id": 1, "species": "Palm"}, pk="id")

‎tests/test_column_affinity.py‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import pytest
22

3+
from sqlite_utils import ANY
34
from sqlite_utils.utils import column_affinity
45

56
EXAMPLES = [
@@ -26,6 +27,8 @@
2627
("DOUBLE", float),
2728
("DOUBLE PRECISION", float),
2829
("FLOAT", float),
30+
("ANY", ANY),
31+
("any", ANY),
2932
# Numeric, treated as float:
3033
("NUMERIC", float),
3134
("DECIMAL(10,5)", float),

0 commit comments

Comments
 (0)