not_null, default and default_exr support for create table API columns

This commit is contained in:
Simon Willison 2026-06-22 11:04:19 -07:00
commit 87354cf94e
3 changed files with 119 additions and 21 deletions

View file

@ -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

View file

@ -1968,7 +1968,14 @@ To create a table, make a ``POST`` to ``/<database>/-/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:

View file

@ -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",