diff --git a/datasette/views/table_create_alter.py b/datasette/views/table_create_alter.py index 88bdafc4..d4259287 100644 --- a/datasette/views/table_create_alter.py +++ b/datasette/views/table_create_alter.py @@ -319,13 +319,28 @@ class _StrictPydanticModel(BaseModel): model_config = ConfigDict(extra="forbid") -class CreateTableColumn(BaseModel): - model_config = ConfigDict(extra="forbid") +class _DefaultArgsMixin(_StrictPydanticModel): + default: Any | None = None + default_expr: DefaultExpr | None = None + + @model_validator(mode="after") + def validate_default_fields(self): + has_default = "default" in self.model_fields_set + has_default_expr = "default_expr" in self.model_fields_set + if has_default and has_default_expr: + raise ValueError("default and default_expr cannot both be provided") + if has_default_expr and self.default_expr is None: + raise ValueError("default_expr cannot be null") + return self + + +class CreateTableColumn(_DefaultArgsMixin): name: Any = None type: Any = "text" fk_table: str | None = None fk_column: str | None = None + not_null: bool = False @model_validator(mode="after") def validate_column(self): @@ -450,21 +465,6 @@ class CreateTableRequest(_StrictPydanticModel): return foreign_keys or None -class _DefaultArgsMixin(_StrictPydanticModel): - default: Any | None = None - default_expr: DefaultExpr | None = None - - @model_validator(mode="after") - def validate_default_fields(self): - has_default = "default" in self.model_fields_set - has_default_expr = "default_expr" in self.model_fields_set - if has_default and has_default_expr: - raise ValueError("default and default_expr cannot both be provided") - if has_default_expr and self.default_expr is None: - raise ValueError("default_expr cannot be null") - return self - - class AddColumnArgs(_DefaultArgsMixin): name: str type: SqliteApiType = "text" @@ -753,16 +753,33 @@ class TableCreateView(BaseView): ) def create_table(conn): - table = sqlite_utils.Database(conn)[table_name] + db_for_write = sqlite_utils.Database(conn) + table = db_for_write[table_name] if rows: table.insert_all( rows, pk=pks or pk, ignore=ignore, replace=replace, alter=alter ) else: + not_null = [column.name for column in columns if column.not_null] + defaults = {} + for column in columns: + if "default_expr" in column.model_fields_set: + defaults[column.name] = _default_expression_sql( + column.default_expr + ) + elif ( + "default" in column.model_fields_set + and column.default is not None + ): + defaults[column.name] = _literal_default( + db_for_write, column.default + ) table.create( {column.name: column.type for column in columns}, pk=pks or pk, foreign_keys=create_request.foreign_keys, + not_null=not_null or None, + defaults=defaults or None, ) return table.schema diff --git a/docs/json_api.rst b/docs/json_api.rst index a42175f7..b2927e38 100644 --- a/docs/json_api.rst +++ b/docs/json_api.rst @@ -1968,7 +1968,14 @@ To create a table, make a ``POST`` to ``//-/create``. This requires th }, { "name": "title", - "type": "text" + "type": "text", + "not_null": true, + "default": "Untitled" + }, + { + "name": "created", + "type": "text", + "default_expr": "current_timestamp" } ], "pk": "id" @@ -1981,6 +1988,9 @@ The JSON here describes the table that will be created: - ``name`` is the name of the column. This is required. - ``type`` is the type of the column. This is optional - if not provided, ``text`` will be assumed. The valid types are ``text``, ``integer``, ``float`` and ``blob``. + - ``not_null`` can be set to ``true`` to create this column with a ``NOT NULL`` constraint. + - ``default`` can be used to set a literal default value for this column. + - ``default_expr`` can be used instead of ``default`` to set a SQLite default expression. The supported values are ``current_timestamp``, ``current_date`` and ``current_time``. - ``fk_table`` can be used to create a single-column foreign key constraint referencing another table. ``fk_column`` is optional and can be used to specify the referenced column - if omitted, Datasette will use the single primary key of ``fk_table``. * ``pk`` is the primary key for the table. This is optional - if not provided, Datasette will create a SQLite table with a hidden ``rowid`` column. @@ -2028,7 +2038,7 @@ If the table is successfully created this will return a ``201`` status code and "table": "name_of_new_table", "table_url": "http://127.0.0.1:8001/data/name_of_new_table", "table_api_url": "http://127.0.0.1:8001/data/name_of_new_table.json", - "schema": "CREATE TABLE [name_of_new_table] (\n [id] INTEGER PRIMARY KEY,\n [title] TEXT\n)" + "schema": "CREATE TABLE [name_of_new_table] (\n [id] INTEGER PRIMARY KEY,\n [title] TEXT NOT NULL DEFAULT 'Untitled',\n [created] TEXT DEFAULT CURRENT_TIMESTAMP\n)" } .. _TableCreateView_example: diff --git a/tests/test_api_write.py b/tests/test_api_write.py index 49f38cde..32be8905 100644 --- a/tests/test_api_write.py +++ b/tests/test_api_write.py @@ -1935,6 +1935,60 @@ async def test_create_table_with_foreign_key(ds_write): assert "[owner_id] INTEGER REFERENCES [owners]([id])" in data["schema"] +@pytest.mark.asyncio +async def test_create_table_with_column_constraints(ds_write): + token = write_token(ds_write) + response = await ds_write.client.post( + "/data/-/create", + json={ + "table": "constrained", + "columns": [ + {"name": "id", "type": "integer"}, + { + "name": "title", + "type": "text", + "not_null": True, + "default": "Untitled", + }, + { + "name": "created", + "type": "text", + "default_expr": "current_timestamp", + }, + {"name": "score", "type": "integer", "default": 0}, + {"name": "literal_default", "type": "text", "default": "hello)"}, + ], + "pk": "id", + }, + headers=_headers(token), + ) + assert response.status_code == 201, response.text + data = response.json() + assert data["ok"] is True + assert "NOT NULL DEFAULT 'Untitled'" in data["schema"] + assert "DEFAULT CURRENT_TIMESTAMP" in data["schema"] + assert "DEFAULT 0" in data["schema"] + assert "DEFAULT 'hello)'" in data["schema"] + + db = ds_write.get_database("data") + columns = ( + await db.execute("select * from pragma_table_info('constrained') order by cid") + ).dicts() + assert [column["name"] for column in columns] == [ + "id", + "title", + "created", + "score", + "literal_default", + ] + assert columns[0]["pk"] == 1 + assert columns[1]["notnull"] == 1 + assert columns[1]["dflt_value"] == "'Untitled'" + assert columns[2]["dflt_value"] == "CURRENT_TIMESTAMP" + assert columns[3]["dflt_value"] == "0" + assert columns[4]["dflt_value"] == "'hello)'" + + @pytest.mark.asyncio @pytest.mark.parametrize( "column,expected_error", @@ -1947,9 +2001,26 @@ async def test_create_table_with_foreign_key(ds_write): {"name": "owner_id", "type": "integer", "fk_column": "id"}, "columns.0: fk_column requires fk_table", ), + ( + { + "name": "created", + "type": "text", + "default_expr": "datetime('now')", + }, + "columns.0.default_expr: Input should be 'current_timestamp', 'current_date' or 'current_time'", + ), + ( + { + "name": "created", + "type": "text", + "default": "x", + "default_expr": "current_timestamp", + }, + "columns.0: Value error, default and default_expr cannot both be provided", + ), ), ) -async def test_create_table_foreign_key_validation(ds_write, column, expected_error): +async def test_create_table_column_validation(ds_write, column, expected_error): token = write_token(ds_write) response = await ds_write.client.post( "/data/-/create",