Skip to content

Commit 104f37f

Browse files
authored
db[table].create(..., transform=True) and create-table --transform
Closes #467
1 parent 36ffcaf commit 104f37f

File tree

6 files changed

+189
-11
lines changed

6 files changed

+189
-11
lines changed

docs/cli-reference.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -865,6 +865,7 @@ See :ref:`cli_create_table`.
865865
foreign key
866866
--ignore If table already exists, do nothing
867867
--replace If table already exists, replace it
868+
--transform If table already exists, try to transform the schema
868869
--load-extension TEXT Path to SQLite extension, with optional :entrypoint
869870
-h, --help Show this message and exit.
870871

docs/cli.rst

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1520,6 +1520,8 @@ You can specify foreign key relationships between the tables you are creating us
15201520

15211521
If a table with the same name already exists, you will get an error. You can choose to silently ignore this error with ``--ignore``, or you can replace the existing table with a new, empty table using ``--replace``.
15221522

1523+
You can also pass ``--transform`` to transform the existing table to match the new schema. See :ref:`python_api_explicit_create` in the Python library documentation for details of how this option works.
1524+
15231525
.. _cli_duplicate_table:
15241526

15251527
Duplicating tables

docs/python-api.rst

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -554,6 +554,25 @@ To do nothing if the table already exists, add ``if_not_exists=True``:
554554
"name": str,
555555
}, pk="id", if_not_exists=True)
556556
557+
You can also pass ``transform=True`` to have any existing tables :ref:`transformed <python_api_transform>` to match your new table specification. This is a **dangerous operation** as it may drop columns that are no longer listed in your call to ``.create()``, so be careful when running this.
558+
559+
.. code-block:: python
560+
561+
db["cats"].create({
562+
"id": int,
563+
"name": str,
564+
"weight": float,
565+
}, pk="id", transform=True)
566+
567+
The ``transform=True`` option will update the table schema if any of the following have changed:
568+
569+
- The specified columns or their types
570+
- The specified primary key
571+
- The order of the columns, defined using ``column_order=``
572+
- The ``not_null=`` or ``defaults=`` arguments
573+
574+
Changes to ``foreign_keys=`` are not currently detected and applied by ``transform=True``.
575+
557576
.. _python_api_compound_primary_keys:
558577

559578
Compound primary keys

sqlite_utils/cli.py

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1453,9 +1453,24 @@ def create_database(path, enable_wal, init_spatialite, load_extension):
14531453
is_flag=True,
14541454
help="If table already exists, replace it",
14551455
)
1456+
@click.option(
1457+
"--transform",
1458+
is_flag=True,
1459+
help="If table already exists, try to transform the schema",
1460+
)
14561461
@load_extension_option
14571462
def create_table(
1458-
path, table, columns, pk, not_null, default, fk, ignore, replace, load_extension
1463+
path,
1464+
table,
1465+
columns,
1466+
pk,
1467+
not_null,
1468+
default,
1469+
fk,
1470+
ignore,
1471+
replace,
1472+
transform,
1473+
load_extension,
14591474
):
14601475
"""
14611476
Add a table with the specified columns. Columns should be specified using
@@ -1490,14 +1505,21 @@ def create_table(
14901505
return
14911506
elif replace:
14921507
db[table].drop()
1508+
elif transform:
1509+
pass
14931510
else:
14941511
raise click.ClickException(
14951512
'Table "{}" already exists. Use --replace to delete and replace it.'.format(
14961513
table
14971514
)
14981515
)
14991516
db[table].create(
1500-
coltypes, pk=pk, not_null=not_null, defaults=dict(default), foreign_keys=fk
1517+
coltypes,
1518+
pk=pk,
1519+
not_null=not_null,
1520+
defaults=dict(default),
1521+
foreign_keys=fk,
1522+
transform=transform,
15011523
)
15021524

15031525

sqlite_utils/db.py

Lines changed: 67 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,6 @@
3434
Union,
3535
Optional,
3636
List,
37-
Set,
3837
Tuple,
3938
)
4039
import uuid
@@ -876,6 +875,7 @@ def create_table(
876875
hash_id_columns: Optional[Iterable[str]] = None,
877876
extracts: Optional[Union[Dict[str, str], List[str]]] = None,
878877
if_not_exists: bool = False,
878+
transform: bool = False,
879879
) -> "Table":
880880
"""
881881
Create a table with the specified name and the specified ``{column_name: type}`` columns.
@@ -893,7 +893,61 @@ def create_table(
893893
:param hash_id_columns: List of columns to be used when calculating the hash ID for a row
894894
:param extracts: List or dictionary of columns to be extracted during inserts, see :ref:`python_api_extracts`
895895
:param if_not_exists: Use ``CREATE TABLE IF NOT EXISTS``
896-
"""
896+
:param transform: If table already exists transform it to fit the specified schema
897+
"""
898+
# Transform table to match the new definition if table already exists:
899+
if transform and self[name].exists():
900+
table = cast(Table, self[name])
901+
should_transform = False
902+
# First add missing columns and figure out columns to drop
903+
existing_columns = table.columns_dict
904+
missing_columns = dict(
905+
(col_name, col_type)
906+
for col_name, col_type in columns.items()
907+
if col_name not in existing_columns
908+
)
909+
columns_to_drop = [
910+
column for column in existing_columns if column not in columns
911+
]
912+
if missing_columns:
913+
for col_name, col_type in missing_columns.items():
914+
table.add_column(col_name, col_type)
915+
if missing_columns or columns_to_drop or columns != existing_columns:
916+
should_transform = True
917+
# Do we need to change the column order?
918+
if (
919+
column_order
920+
and list(existing_columns)[: len(column_order)] != column_order
921+
):
922+
should_transform = True
923+
# Has the primary key changed?
924+
current_pks = table.pks
925+
desired_pk = None
926+
if isinstance(pk, str):
927+
desired_pk = [pk]
928+
elif pk:
929+
desired_pk = list(pk)
930+
if desired_pk and current_pks != desired_pk:
931+
should_transform = True
932+
# Any not-null changes?
933+
current_not_null = {c.name for c in table.columns if c.notnull}
934+
desired_not_null = set(not_null) if not_null else set()
935+
if current_not_null != desired_not_null:
936+
should_transform = True
937+
# How about defaults?
938+
if defaults and defaults != table.default_values:
939+
should_transform = True
940+
# Only run .transform() if there is something to do
941+
if should_transform:
942+
table.transform(
943+
types=columns,
944+
drop=columns_to_drop,
945+
column_order=column_order,
946+
not_null=not_null,
947+
defaults=defaults,
948+
pk=pk,
949+
)
950+
return table
897951
sql = self.create_table_sql(
898952
name=name,
899953
columns=columns,
@@ -908,7 +962,7 @@ def create_table(
908962
if_not_exists=if_not_exists,
909963
)
910964
self.execute(sql)
911-
table = self.table(
965+
created_table = self.table(
912966
name,
913967
pk=pk,
914968
foreign_keys=foreign_keys,
@@ -918,7 +972,7 @@ def create_table(
918972
hash_id=hash_id,
919973
hash_id_columns=hash_id_columns,
920974
)
921-
return cast(Table, table)
975+
return cast(Table, created_table)
922976

923977
def create_view(
924978
self, name: str, sql: str, ignore: bool = False, replace: bool = False
@@ -1487,6 +1541,7 @@ def create(
14871541
hash_id_columns: Optional[Iterable[str]] = None,
14881542
extracts: Optional[Union[Dict[str, str], List[str]]] = None,
14891543
if_not_exists: bool = False,
1544+
transform: bool = False,
14901545
) -> "Table":
14911546
"""
14921547
Create a table with the specified columns.
@@ -1518,6 +1573,7 @@ def create(
15181573
hash_id_columns=hash_id_columns,
15191574
extracts=extracts,
15201575
if_not_exists=if_not_exists,
1576+
transform=transform,
15211577
)
15221578
return self
15231579

@@ -1544,7 +1600,7 @@ def transform(
15441600
rename: Optional[dict] = None,
15451601
drop: Optional[Iterable] = None,
15461602
pk: Optional[Any] = DEFAULT,
1547-
not_null: Optional[Set[str]] = None,
1603+
not_null: Optional[Iterable[str]] = None,
15481604
defaults: Optional[Dict[str, Any]] = None,
15491605
drop_foreign_keys: Optional[Iterable] = None,
15501606
column_order: Optional[List[str]] = None,
@@ -1664,10 +1720,12 @@ def transform_sql(
16641720
create_table_not_null.add(key)
16651721
elif isinstance(not_null, set):
16661722
create_table_not_null.update((rename.get(k) or k) for k in not_null)
1667-
elif not_null is None:
1723+
elif not not_null:
16681724
pass
16691725
else:
1670-
assert False, "not_null must be a dict or a set or None"
1726+
assert False, "not_null must be a dict or a set or None, it was {}".format(
1727+
repr(not_null)
1728+
)
16711729
# defaults=
16721730
create_table_defaults = {
16731731
(rename.get(c.name) or c.name): c.default_value
@@ -2861,7 +2919,7 @@ def insert(
28612919
pk=DEFAULT,
28622920
foreign_keys=DEFAULT,
28632921
column_order: Optional[Union[List[str], Default]] = DEFAULT,
2864-
not_null: Optional[Union[Set[str], Default]] = DEFAULT,
2922+
not_null: Optional[Union[Iterable[str], Default]] = DEFAULT,
28652923
defaults: Optional[Union[Dict[str, Any], Default]] = DEFAULT,
28662924
hash_id: Optional[Union[str, Default]] = DEFAULT,
28672925
hash_id_columns: Optional[Union[Iterable[str], Default]] = DEFAULT,
@@ -3141,7 +3199,7 @@ def lookup(
31413199
pk: Optional[str] = "id",
31423200
foreign_keys: Optional[ForeignKeysType] = None,
31433201
column_order: Optional[List[str]] = None,
3144-
not_null: Optional[Set[str]] = None,
3202+
not_null: Optional[Iterable[str]] = None,
31453203
defaults: Optional[Dict[str, Any]] = None,
31463204
extracts: Optional[Union[Dict[str, str], List[str]]] = None,
31473205
conversions: Optional[Dict[str, str]] = None,

tests/test_create.py

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1155,3 +1155,79 @@ def test_create_if_no_columns(fresh_db):
11551155
with pytest.raises(AssertionError) as error:
11561156
fresh_db["t"].create({})
11571157
assert error.value.args[0] == "Tables must have at least one column"
1158+
1159+
1160+
@pytest.mark.parametrize(
1161+
"cols,kwargs,expected_schema,should_transform",
1162+
(
1163+
# Change nothing
1164+
(
1165+
{"id": int, "name": str},
1166+
{"pk": "id"},
1167+
"CREATE TABLE [demo] (\n [id] INTEGER PRIMARY KEY,\n [name] TEXT\n)",
1168+
False,
1169+
),
1170+
# Drop name column, remove primary key
1171+
({"id": int}, {}, 'CREATE TABLE "demo" (\n [id] INTEGER\n)', True),
1172+
# Add a new column
1173+
(
1174+
{"id": int, "name": str, "age": int},
1175+
{"pk": "id"},
1176+
'CREATE TABLE "demo" (\n [id] INTEGER PRIMARY KEY,\n [name] TEXT,\n [age] INTEGER\n)',
1177+
True,
1178+
),
1179+
# Change a column type
1180+
(
1181+
{"id": int, "name": bytes},
1182+
{"pk": "id"},
1183+
'CREATE TABLE "demo" (\n [id] INTEGER PRIMARY KEY,\n [name] BLOB\n)',
1184+
True,
1185+
),
1186+
# Change the primary key
1187+
(
1188+
{"id": int, "name": str},
1189+
{"pk": "name"},
1190+
'CREATE TABLE "demo" (\n [id] INTEGER,\n [name] TEXT PRIMARY KEY\n)',
1191+
True,
1192+
),
1193+
# Change in column order
1194+
(
1195+
{"id": int, "name": str},
1196+
{"pk": "id", "column_order": ["name"]},
1197+
'CREATE TABLE "demo" (\n [name] TEXT,\n [id] INTEGER PRIMARY KEY\n)',
1198+
True,
1199+
),
1200+
# Same column order is ignored
1201+
(
1202+
{"id": int, "name": str},
1203+
{"pk": "id", "column_order": ["id", "name"]},
1204+
"CREATE TABLE [demo] (\n [id] INTEGER PRIMARY KEY,\n [name] TEXT\n)",
1205+
False,
1206+
),
1207+
# Change not null
1208+
(
1209+
{"id": int, "name": str},
1210+
{"pk": "id", "not_null": {"name"}},
1211+
'CREATE TABLE "demo" (\n [id] INTEGER PRIMARY KEY,\n [name] TEXT NOT NULL\n)',
1212+
True,
1213+
),
1214+
# Change default values
1215+
(
1216+
{"id": int, "name": str},
1217+
{"pk": "id", "defaults": {"id": 0, "name": "Bob"}},
1218+
"CREATE TABLE \"demo\" (\n [id] INTEGER PRIMARY KEY DEFAULT 0,\n [name] TEXT DEFAULT 'Bob'\n)",
1219+
True,
1220+
),
1221+
),
1222+
)
1223+
def test_create_transform(fresh_db, cols, kwargs, expected_schema, should_transform):
1224+
fresh_db.create_table("demo", {"id": int, "name": str}, pk="id")
1225+
fresh_db["demo"].insert({"id": 1, "name": "Cleo"})
1226+
traces = []
1227+
with fresh_db.tracer(lambda sql, parameters: traces.append((sql, parameters))):
1228+
fresh_db["demo"].create(cols, **kwargs, transform=True)
1229+
at_least_one_create_table = any(sql.startswith("CREATE TABLE") for sql, _ in traces)
1230+
assert should_transform == at_least_one_create_table
1231+
new_schema = fresh_db["demo"].schema
1232+
assert new_schema == expected_schema, repr(new_schema)
1233+
assert fresh_db["demo"].count == 1

0 commit comments

Comments
 (0)