mirror of
https://github.com/simonw/datasette.git
synced 2026-06-23 09:14:34 +02:00
current_unixtime and current_unixtime_ms default_expr options
Plus tweaked how alter table changing those works a bit.
This commit is contained in:
parent
2ebae5ed71
commit
b932d0dc78
7 changed files with 521 additions and 54 deletions
|
|
@ -109,14 +109,64 @@ function createCustomColumnTypeSelect(options, className, placeholderClass) {
|
|||
return select;
|
||||
}
|
||||
|
||||
var DEFAULT_EXPRESSION_LABELS = {
|
||||
current_timestamp: "Current timestamp in UTC, e.g. 2026-05-01 13:34:00",
|
||||
current_date: "Current date in UTC, e.g. 2026-05-01",
|
||||
current_time: "Current time in UTC, e.g. 13:34:00",
|
||||
};
|
||||
function normalizeDefaultExpressionOption(option) {
|
||||
if (typeof option === "string") {
|
||||
return {
|
||||
value: option,
|
||||
label: option.replace(/_/g, " "),
|
||||
sqliteType: "",
|
||||
};
|
||||
}
|
||||
option = option || {};
|
||||
return {
|
||||
value: option.value || "",
|
||||
label: option.label || option.value || "",
|
||||
sqliteType: option.sqliteType || "",
|
||||
};
|
||||
}
|
||||
|
||||
function defaultExpressionLabel(value) {
|
||||
return DEFAULT_EXPRESSION_LABELS[value] || value.replace(/_/g, " ");
|
||||
function defaultExpressionOptionForValue(options, value) {
|
||||
var match = null;
|
||||
(options || []).some(function (option) {
|
||||
var normalized = normalizeDefaultExpressionOption(option);
|
||||
if (normalized.value === value) {
|
||||
match = normalized;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
return match;
|
||||
}
|
||||
|
||||
function defaultExpressionLabelForValue(options, value) {
|
||||
var option = defaultExpressionOptionForValue(options, value);
|
||||
return option && option.label
|
||||
? option.label
|
||||
: value
|
||||
? value.replace(/_/g, " ")
|
||||
: "";
|
||||
}
|
||||
|
||||
function applyDefaultExpressionColumnType(row, prefix, options, columnTypes) {
|
||||
var defaultExprSelect = row.querySelector("." + prefix + "-default-expr");
|
||||
var typeSelect = row.querySelector("." + prefix + "-column-type");
|
||||
if (!defaultExprSelect || !typeSelect || !defaultExprSelect.value) {
|
||||
return false;
|
||||
}
|
||||
var option = defaultExpressionOptionForValue(
|
||||
options,
|
||||
defaultExprSelect.value,
|
||||
);
|
||||
if (
|
||||
option &&
|
||||
option.sqliteType &&
|
||||
(columnTypes || []).indexOf(option.sqliteType) !== -1 &&
|
||||
typeSelect.value !== option.sqliteType
|
||||
) {
|
||||
typeSelect.value = option.sqliteType;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function createDefaultExpressionSelect(
|
||||
|
|
@ -132,9 +182,13 @@ function createDefaultExpressionSelect(
|
|||
blankOption.textContent = "- default expr -";
|
||||
select.appendChild(blankOption);
|
||||
options.forEach(function (option) {
|
||||
var normalized = normalizeDefaultExpressionOption(option);
|
||||
if (!normalized.value) {
|
||||
return;
|
||||
}
|
||||
var optionElement = document.createElement("option");
|
||||
optionElement.value = option;
|
||||
optionElement.textContent = defaultExpressionLabel(option);
|
||||
optionElement.value = normalized.value;
|
||||
optionElement.textContent = normalized.label;
|
||||
select.appendChild(optionElement);
|
||||
});
|
||||
select.value = value || "";
|
||||
|
|
@ -1078,6 +1132,18 @@ function createTableColumnRow(state, column) {
|
|||
if (defaultControls.defaultExprSelect.value) {
|
||||
defaultControls.defaultInput.value = "";
|
||||
}
|
||||
if (
|
||||
applyDefaultExpressionColumnType(
|
||||
row,
|
||||
"table-create",
|
||||
tableCreateDefaultExpressions(),
|
||||
tableCreateColumnTypes(),
|
||||
)
|
||||
) {
|
||||
syncTableCreateCustomTypeForSqliteType(row);
|
||||
syncTableCreateForeignKeyOptions(row, state);
|
||||
syncTableCreateCustomTypeAndForeignKey(row, state);
|
||||
}
|
||||
syncSchemaDialogDefaultControls(row, "table-create");
|
||||
clearTableCreateDialogError(state);
|
||||
});
|
||||
|
|
@ -1948,8 +2014,15 @@ function createTableAlterColumnRow(state, column) {
|
|||
var originalName = existing ? column.name || "" : "";
|
||||
var originalCustomType =
|
||||
existing && column.column_type ? column.column_type.type || "" : "";
|
||||
var originalDefaultExpr =
|
||||
existing && column.has_default && column.default_expr
|
||||
? column.default_expr
|
||||
: "";
|
||||
var originalDefault =
|
||||
existing && column.has_default && column.default !== null
|
||||
existing &&
|
||||
column.has_default &&
|
||||
!originalDefaultExpr &&
|
||||
column.default !== null
|
||||
? String(column.default)
|
||||
: "";
|
||||
var originalForeignKey =
|
||||
|
|
@ -1965,6 +2038,7 @@ function createTableAlterColumnRow(state, column) {
|
|||
row.dataset.originalNotNull = existing && column.notnull ? "1" : "0";
|
||||
row.dataset.originalHasDefault = existing && column.has_default ? "1" : "0";
|
||||
row.dataset.originalDefault = originalDefault;
|
||||
row.dataset.originalDefaultExpr = originalDefaultExpr;
|
||||
row.dataset.originalPk = existing && column.is_pk ? "1" : "0";
|
||||
row.dataset.originalCustomType = originalCustomType;
|
||||
row.dataset.originalForeignKey = originalForeignKey;
|
||||
|
|
@ -2051,7 +2125,7 @@ function createTableAlterColumnRow(state, column) {
|
|||
tableAlterDefaultExpressions(),
|
||||
{
|
||||
defaultValue: originalDefault,
|
||||
defaultExpr: "",
|
||||
defaultExpr: originalDefaultExpr,
|
||||
},
|
||||
);
|
||||
var defaultInput = defaultControls.defaultInput;
|
||||
|
|
@ -2167,6 +2241,18 @@ function createTableAlterColumnRow(state, column) {
|
|||
if (defaultExprSelect.value) {
|
||||
defaultInput.value = "";
|
||||
}
|
||||
if (
|
||||
applyDefaultExpressionColumnType(
|
||||
row,
|
||||
"table-alter",
|
||||
tableAlterDefaultExpressions(),
|
||||
tableAlterColumnTypes(),
|
||||
)
|
||||
) {
|
||||
syncTableAlterCustomTypeForSqliteType(row);
|
||||
syncTableAlterForeignKeyOptions(row, state);
|
||||
syncSchemaDialogCustomTypeAndForeignKey(row, state, "table-alter");
|
||||
}
|
||||
syncSchemaDialogDefaultControls(row, "table-alter");
|
||||
updateTableAlterSaveButtonState(state);
|
||||
});
|
||||
|
|
@ -2336,6 +2422,7 @@ function collectTableAlterRows(state) {
|
|||
signature.originalNotNull = row.dataset.originalNotNull === "1";
|
||||
signature.originalHasDefault = row.dataset.originalHasDefault === "1";
|
||||
signature.originalDefault = row.dataset.originalDefault || "";
|
||||
signature.originalDefaultExpr = row.dataset.originalDefaultExpr || "";
|
||||
signature.originalPk = row.dataset.originalPk === "1";
|
||||
signature.originalCustomType = row.dataset.originalCustomType || "";
|
||||
signature.originalForeignKey = row.dataset.originalForeignKey || "";
|
||||
|
|
@ -2567,8 +2654,12 @@ function collectTableAlterPayload(state) {
|
|||
if (row.notNull !== row.originalNotNull) {
|
||||
alterArgs.not_null = row.notNull;
|
||||
}
|
||||
if (row.defaultExpr) {
|
||||
alterArgs.default_expr = row.defaultExpr;
|
||||
if (row.defaultExpr !== row.originalDefaultExpr) {
|
||||
if (row.defaultExpr) {
|
||||
alterArgs.default_expr = row.defaultExpr;
|
||||
} else {
|
||||
alterArgs.default = row.defaultValue === "" ? null : row.defaultValue;
|
||||
}
|
||||
} else if (row.originalHasDefault) {
|
||||
if (row.defaultValue !== row.originalDefault) {
|
||||
alterArgs.default = row.defaultValue === "" ? null : row.defaultValue;
|
||||
|
|
@ -2630,7 +2721,7 @@ function tableAlterQuotedName(name) {
|
|||
}
|
||||
|
||||
function tableAlterReadableDefaultExpression(value) {
|
||||
return value ? value.replace(/_/g, " ") : "";
|
||||
return defaultExpressionLabelForValue(tableAlterDefaultExpressions(), value);
|
||||
}
|
||||
|
||||
function tableAlterReadableValue(value) {
|
||||
|
|
|
|||
|
|
@ -51,8 +51,9 @@ from .database import QueryView
|
|||
from .table_create_alter import (
|
||||
ALTER_TABLE_COLUMN_TYPES,
|
||||
ALTER_TABLE_TYPE_FOR_SQLITE_TYPE,
|
||||
DEFAULT_EXPR_SQL,
|
||||
_custom_column_type_options_for_create_table,
|
||||
default_expr_for_sql,
|
||||
default_expression_options,
|
||||
)
|
||||
from .table_extras import (
|
||||
TABLE_EXTRA_BUNDLES,
|
||||
|
|
@ -401,23 +402,25 @@ async def _table_alter_ui(
|
|||
continue
|
||||
sqlite_type = SQLiteType.from_declared_type(column.type)
|
||||
column_type = column_types_map.get(column.name)
|
||||
columns.append(
|
||||
{
|
||||
"name": column.name,
|
||||
"type": ALTER_TABLE_TYPE_FOR_SQLITE_TYPE.get(sqlite_type, "text"),
|
||||
"sqlite_type": sqlite_type.value,
|
||||
"notnull": column.notnull,
|
||||
"default": column.default_value,
|
||||
"has_default": column.default_value is not None,
|
||||
"is_pk": column.name in pks,
|
||||
"foreign_key": foreign_keys_by_column.get(column.name),
|
||||
"column_type": (
|
||||
{"type": column_type.name, "config": column_type.config}
|
||||
if column_type is not None
|
||||
else None
|
||||
),
|
||||
}
|
||||
)
|
||||
default_expr = default_expr_for_sql(column.default_value)
|
||||
column_data = {
|
||||
"name": column.name,
|
||||
"type": ALTER_TABLE_TYPE_FOR_SQLITE_TYPE.get(sqlite_type, "text"),
|
||||
"sqlite_type": sqlite_type.value,
|
||||
"notnull": column.notnull,
|
||||
"default": None if default_expr else column.default_value,
|
||||
"has_default": column.default_value is not None,
|
||||
"is_pk": column.name in pks,
|
||||
"foreign_key": foreign_keys_by_column.get(column.name),
|
||||
"column_type": (
|
||||
{"type": column_type.name, "config": column_type.config}
|
||||
if column_type is not None
|
||||
else None
|
||||
),
|
||||
}
|
||||
if default_expr:
|
||||
column_data["default_expr"] = default_expr
|
||||
columns.append(column_data)
|
||||
|
||||
data = {
|
||||
"path": "{}/-/alter".format(datasette.urls.table(database_name, table_name)),
|
||||
|
|
@ -425,7 +428,7 @@ async def _table_alter_ui(
|
|||
"columns": columns,
|
||||
"primaryKeys": pks,
|
||||
"columnTypes": ALTER_TABLE_COLUMN_TYPES,
|
||||
"defaultExpressions": list(DEFAULT_EXPR_SQL),
|
||||
"defaultExpressions": default_expression_options(),
|
||||
"foreignKeyTargetsPath": "{}/-/foreign-key-targets?table={}".format(
|
||||
datasette.urls.database(database_name),
|
||||
urllib.parse.quote(table_name, safe=""),
|
||||
|
|
|
|||
|
|
@ -266,7 +266,7 @@ async def _create_table_ui_context(
|
|||
),
|
||||
"databaseName": database_name,
|
||||
"columnTypes": CREATE_TABLE_COLUMN_TYPES,
|
||||
"defaultExpressions": list(DEFAULT_EXPR_SQL),
|
||||
"defaultExpressions": default_expression_options(),
|
||||
}
|
||||
can_set_column_type = await datasette.allowed(
|
||||
action="set-column-type",
|
||||
|
|
@ -308,12 +308,109 @@ def _custom_column_type_options_for_create_table(datasette):
|
|||
|
||||
|
||||
SqliteApiType = Literal["text", "integer", "float", "blob"]
|
||||
DefaultExpr = Literal["current_timestamp", "current_date", "current_time"]
|
||||
DEFAULT_EXPR_SQL = {
|
||||
"current_timestamp": "CURRENT_TIMESTAMP",
|
||||
"current_date": "CURRENT_DATE",
|
||||
"current_time": "CURRENT_TIME",
|
||||
DEFAULT_EXPRESSIONS = {
|
||||
"current_timestamp": {
|
||||
"sql": "CURRENT_TIMESTAMP",
|
||||
"label": "Current timestamp in UTC, e.g. 2026-05-01 13:34:00",
|
||||
"sqliteType": "text",
|
||||
},
|
||||
"current_date": {
|
||||
"sql": "CURRENT_DATE",
|
||||
"label": "Current date in UTC, e.g. 2026-05-01",
|
||||
"sqliteType": "text",
|
||||
},
|
||||
"current_time": {
|
||||
"sql": "CURRENT_TIME",
|
||||
"label": "Current time in UTC, e.g. 13:34:00",
|
||||
"sqliteType": "text",
|
||||
},
|
||||
"current_unixtime": {
|
||||
"sql": "(CAST(strftime('%s', 'now') AS INTEGER))",
|
||||
"label": "Current Unix time, integer seconds since the epoch",
|
||||
"sqliteType": "integer",
|
||||
},
|
||||
"current_unixtime_ms": {
|
||||
"sql": "(CAST((julianday('now') - 2440587.5) * 86400000 AS INTEGER))",
|
||||
"label": "Current Unix time, integer milliseconds since the epoch",
|
||||
"sqliteType": "integer",
|
||||
},
|
||||
}
|
||||
DefaultExpr = str
|
||||
DEFAULT_EXPR_SQL = {
|
||||
name: metadata["sql"] for name, metadata in DEFAULT_EXPRESSIONS.items()
|
||||
}
|
||||
|
||||
|
||||
def _strip_wrapping_parentheses(expression):
|
||||
expression = expression.strip()
|
||||
while expression.startswith("(") and expression.endswith(")"):
|
||||
depth = 0
|
||||
in_single_quote = False
|
||||
wraps_whole_expression = True
|
||||
i = 0
|
||||
while i < len(expression):
|
||||
char = expression[i]
|
||||
if char == "'":
|
||||
if (
|
||||
in_single_quote
|
||||
and i + 1 < len(expression)
|
||||
and expression[i + 1] == "'"
|
||||
):
|
||||
i += 2
|
||||
continue
|
||||
in_single_quote = not in_single_quote
|
||||
elif not in_single_quote:
|
||||
if char == "(":
|
||||
depth += 1
|
||||
elif char == ")":
|
||||
depth -= 1
|
||||
if depth == 0 and i != len(expression) - 1:
|
||||
wraps_whole_expression = False
|
||||
break
|
||||
i += 1
|
||||
if not wraps_whole_expression or depth != 0 or in_single_quote:
|
||||
break
|
||||
expression = expression[1:-1].strip()
|
||||
return expression
|
||||
|
||||
|
||||
def _default_expression_lookup_key(expression):
|
||||
return re.sub(r"\s+", " ", _strip_wrapping_parentheses(expression)).lower()
|
||||
|
||||
|
||||
DEFAULT_EXPR_BY_SQL = {
|
||||
_default_expression_lookup_key(sql): name for name, sql in DEFAULT_EXPR_SQL.items()
|
||||
}
|
||||
|
||||
|
||||
def default_expr_for_sql(expression):
|
||||
if expression is None:
|
||||
return None
|
||||
return DEFAULT_EXPR_BY_SQL.get(_default_expression_lookup_key(expression))
|
||||
|
||||
|
||||
def _quoted_options(options):
|
||||
if len(options) == 1:
|
||||
return "'{}'".format(options[0])
|
||||
return "{} or '{}'".format(
|
||||
", ".join("'{}'".format(option) for option in options[:-1]),
|
||||
options[-1],
|
||||
)
|
||||
|
||||
|
||||
def _default_expr_error_message():
|
||||
return "Input should be {}".format(_quoted_options(list(DEFAULT_EXPRESSIONS)))
|
||||
|
||||
|
||||
def default_expression_options():
|
||||
return [
|
||||
{
|
||||
"value": value,
|
||||
"label": metadata["label"],
|
||||
"sqliteType": metadata["sqliteType"],
|
||||
}
|
||||
for value, metadata in DEFAULT_EXPRESSIONS.items()
|
||||
]
|
||||
|
||||
|
||||
class _StrictPydanticModel(BaseModel):
|
||||
|
|
@ -324,6 +421,13 @@ class _DefaultArgsMixin(_StrictPydanticModel):
|
|||
default: Any | None = None
|
||||
default_expr: DefaultExpr | None = None
|
||||
|
||||
@field_validator("default_expr")
|
||||
@classmethod
|
||||
def validate_default_expr_value(cls, value):
|
||||
if value is not None and value not in DEFAULT_EXPRESSIONS:
|
||||
raise PydanticCustomError("default_expr", _default_expr_error_message())
|
||||
return value
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_default_fields(self):
|
||||
has_default = "default" in self.model_fields_set
|
||||
|
|
|
|||
|
|
@ -44,7 +44,7 @@ looks like this:
|
|||
|
||||
``"ok"`` is always ``true`` if an error did not occur.
|
||||
|
||||
The ``"rows"`` key is a list of objects, each one representing a row.
|
||||
The ``"rows"`` key is a list of objects, each one representing a row.
|
||||
|
||||
The ``"truncated"`` key lets you know if the query was truncated. This can happen if a SQL query returns more than 1,000 results (or the :ref:`setting_max_returned_rows` setting).
|
||||
|
||||
|
|
@ -1990,7 +1990,7 @@ The JSON here describes the table that will be created:
|
|||
- ``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``.
|
||||
- ``default_expr`` can be used instead of ``default`` to set a SQLite default expression. See :ref:`default_expr values <json_api_default_expr_values>`.
|
||||
- ``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.
|
||||
|
|
@ -2004,6 +2004,32 @@ The JSON here describes the table that will be created:
|
|||
* ``replace`` can be set to ``true`` to replace existing rows by primary key if the table already exists. This requires the :ref:`actions_update_row` permission.
|
||||
* ``alter`` can be set to ``true`` if you want to automatically add any missing columns to the table. This requires the :ref:`actions_alter_table` permission.
|
||||
|
||||
.. _json_api_default_expr_values:
|
||||
|
||||
``default_expr`` accepts these values:
|
||||
|
||||
.. list-table::
|
||||
:header-rows: 1
|
||||
|
||||
* - Value
|
||||
- Recommended column type
|
||||
- Example inserted value
|
||||
* - ``current_timestamp``
|
||||
- ``text``
|
||||
- ``2026-05-01 13:34:00``
|
||||
* - ``current_date``
|
||||
- ``text``
|
||||
- ``2026-05-01``
|
||||
* - ``current_time``
|
||||
- ``text``
|
||||
- ``13:34:00``
|
||||
* - ``current_unixtime``
|
||||
- ``integer``
|
||||
- ``1777642440``
|
||||
* - ``current_unixtime_ms``
|
||||
- ``integer``
|
||||
- ``1777642440000``
|
||||
|
||||
This example creates a foreign key from ``projects.owner_id`` to the single primary key of ``owners``:
|
||||
|
||||
.. code-block:: json
|
||||
|
|
@ -2314,7 +2340,7 @@ Supported operations:
|
|||
* ``set_foreign_keys`` replaces all foreign key constraints on the table. ``args`` accepts ``foreign_keys``, a list of objects that each have ``column``, ``fk_table`` and optional ``fk_column``. An empty list removes all foreign key constraints.
|
||||
* ``reorder_columns`` reorders columns. ``args`` accepts ``columns``, a list of one or more column names. Columns omitted from this list will appear afterwards in their existing order.
|
||||
|
||||
``default`` is always treated as a literal value. ``default_expr`` accepts one of ``current_timestamp``, ``current_date`` or ``current_time`` and is rendered as the corresponding SQLite default expression.
|
||||
``default`` is always treated as a literal value. ``default_expr`` accepts the values shown in :ref:`default_expr values <json_api_default_expr_values>` and is rendered as the corresponding SQLite default expression.
|
||||
|
||||
For foreign key operations that omit ``fk_column``, the referenced ``fk_table`` must have a single-column primary key. Datasette will return an error if it cannot identify a single primary key column for that table.
|
||||
|
||||
|
|
|
|||
|
|
@ -899,6 +899,64 @@ async def test_alter_table_operations(ds_write):
|
|||
assert event.after_schema == data["schema"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize(
|
||||
"default_expr,minimum_value,expected_schema",
|
||||
(
|
||||
(
|
||||
"current_unixtime",
|
||||
1_600_000_000,
|
||||
"strftime('%s', 'now')",
|
||||
),
|
||||
(
|
||||
"current_unixtime_ms",
|
||||
1_600_000_000_000,
|
||||
"julianday('now')",
|
||||
),
|
||||
),
|
||||
)
|
||||
async def test_alter_table_integer_default_expr(
|
||||
ds_write, default_expr, minimum_value, expected_schema
|
||||
):
|
||||
token = write_token(ds_write, permissions=["at"])
|
||||
db = ds_write.get_database("data")
|
||||
response = await ds_write.client.post(
|
||||
"/data/docs/-/alter",
|
||||
json={
|
||||
"operations": [
|
||||
{
|
||||
"op": "add_column",
|
||||
"args": {
|
||||
"name": "created",
|
||||
"type": "integer",
|
||||
"default_expr": default_expr,
|
||||
},
|
||||
}
|
||||
]
|
||||
},
|
||||
headers=_headers(token),
|
||||
)
|
||||
assert response.status_code == 200, response.text
|
||||
data = response.json()
|
||||
assert expected_schema in data["schema"]
|
||||
|
||||
columns = await db.execute("select * from pragma_table_info('docs')")
|
||||
created_column = [
|
||||
column for column in columns.dicts() if column["name"] == "created"
|
||||
][0]
|
||||
assert created_column["type"] == "INTEGER"
|
||||
assert expected_schema in created_column["dflt_value"]
|
||||
|
||||
row = await db.execute_write_fn(
|
||||
lambda conn: conn.execute(
|
||||
"insert into docs (title) values ('with default') "
|
||||
"returning created, typeof(created)"
|
||||
).fetchone()
|
||||
)
|
||||
assert row[0] > minimum_value
|
||||
assert row[1] == "integer"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_alter_table_rename_table(ds_write):
|
||||
token = write_token(ds_write, permissions=["at"])
|
||||
|
|
@ -1315,7 +1373,7 @@ async def test_alter_table_permission_denied(ds_write):
|
|||
}
|
||||
]
|
||||
},
|
||||
"operations.0.add_column.args.default_expr: Input should be 'current_timestamp', 'current_date' or 'current_time'",
|
||||
"operations.0.add_column.args.default_expr: Input should be 'current_timestamp', 'current_date', 'current_time', 'current_unixtime' or 'current_unixtime_ms'",
|
||||
),
|
||||
(
|
||||
{
|
||||
|
|
@ -2053,6 +2111,63 @@ async def test_create_table_with_column_constraints(ds_write):
|
|||
assert columns[4]["dflt_value"] == "'hello)'"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize(
|
||||
"default_expr,minimum_value,expected_schema",
|
||||
(
|
||||
(
|
||||
"current_unixtime",
|
||||
1_600_000_000,
|
||||
"strftime('%s', 'now')",
|
||||
),
|
||||
(
|
||||
"current_unixtime_ms",
|
||||
1_600_000_000_000,
|
||||
"julianday('now')",
|
||||
),
|
||||
),
|
||||
)
|
||||
async def test_create_table_integer_default_expr(
|
||||
ds_write, default_expr, minimum_value, expected_schema
|
||||
):
|
||||
token = write_token(ds_write)
|
||||
table = "default_{}".format(default_expr)
|
||||
response = await ds_write.client.post(
|
||||
"/data/-/create",
|
||||
json={
|
||||
"table": table,
|
||||
"columns": [
|
||||
{"name": "id", "type": "integer"},
|
||||
{
|
||||
"name": "created",
|
||||
"type": "integer",
|
||||
"default_expr": default_expr,
|
||||
},
|
||||
],
|
||||
"pk": "id",
|
||||
},
|
||||
headers=_headers(token),
|
||||
)
|
||||
assert response.status_code == 201, response.text
|
||||
data = response.json()
|
||||
assert expected_schema in data["schema"]
|
||||
|
||||
db = ds_write.get_database("data")
|
||||
columns = (await db.execute("select * from pragma_table_info(?)", [table])).dicts()
|
||||
assert columns[1]["type"] == "INTEGER"
|
||||
assert expected_schema in columns[1]["dflt_value"]
|
||||
|
||||
row = await db.execute_write_fn(
|
||||
lambda conn: conn.execute(
|
||||
"insert into [{}] default values returning created, typeof(created)".format(
|
||||
table
|
||||
)
|
||||
).fetchone()
|
||||
)
|
||||
assert row[0] > minimum_value
|
||||
assert row[1] == "integer"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize(
|
||||
"column,expected_error",
|
||||
|
|
@ -2071,7 +2186,7 @@ async def test_create_table_with_column_constraints(ds_write):
|
|||
"type": "text",
|
||||
"default_expr": "datetime('now')",
|
||||
},
|
||||
"columns.0.default_expr: Input should be 'current_timestamp', 'current_date' or 'current_time'",
|
||||
"columns.0.default_expr: Input should be 'current_timestamp', 'current_date', 'current_time', 'current_unixtime' or 'current_unixtime_ms'",
|
||||
),
|
||||
(
|
||||
{
|
||||
|
|
|
|||
|
|
@ -98,6 +98,10 @@ def write_playwright_database(db_path):
|
|||
notes text,
|
||||
score integer default 5
|
||||
);
|
||||
create table defaults_demo (
|
||||
id integer primary key,
|
||||
created_ms integer default (CAST((julianday('now') - 2440587.5) * 86400000 AS INTEGER))
|
||||
);
|
||||
insert into projects (title, metadata, logo, notes, score) values
|
||||
(
|
||||
'Build Datasette',
|
||||
|
|
@ -136,6 +140,11 @@ def write_playwright_config(config_path):
|
|||
"delete-row": True,
|
||||
},
|
||||
},
|
||||
"defaults_demo": {
|
||||
"permissions": {
|
||||
"alter-table": True,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
|
@ -394,6 +403,32 @@ def test_create_table_foreign_key_selection_updates_column_type(page, datasette_
|
|||
assert foreign_key_select.input_value() == "projects\u001fid"
|
||||
|
||||
|
||||
@pytest.mark.playwright
|
||||
def test_create_table_unix_default_expression_updates_column_type(
|
||||
page, datasette_server
|
||||
):
|
||||
page.goto(f"{datasette_server}data")
|
||||
page.locator("details.actions-menu-links summary").click()
|
||||
page.locator('button[data-database-action="create-table"]').click()
|
||||
|
||||
dialog = page.locator("#table-create-dialog")
|
||||
dialog.wait_for()
|
||||
row = dialog.locator(".table-create-column-row").nth(1)
|
||||
row.locator(".table-create-more-options").click()
|
||||
row.locator(".table-create-default-options summary").click()
|
||||
|
||||
type_select = row.locator(".table-create-column-type")
|
||||
default_expr = row.locator(".table-create-default-expr")
|
||||
assert type_select.input_value() == "text"
|
||||
assert (
|
||||
"Current Unix time, integer milliseconds since the epoch"
|
||||
in default_expr.locator("option").last.inner_text()
|
||||
)
|
||||
|
||||
default_expr.select_option("current_unixtime_ms")
|
||||
assert type_select.input_value() == "integer"
|
||||
|
||||
|
||||
@pytest.mark.playwright
|
||||
def test_alter_table_foreign_key_selection_updates_blank_column(page, datasette_server):
|
||||
page.goto(f"{datasette_server}data/projects")
|
||||
|
|
@ -416,6 +451,52 @@ def test_alter_table_foreign_key_selection_updates_blank_column(page, datasette_
|
|||
assert foreign_key_select.input_value() == "projects\u001fid"
|
||||
|
||||
|
||||
@pytest.mark.playwright
|
||||
def test_alter_table_unix_default_expression_updates_column_type(
|
||||
page, datasette_server
|
||||
):
|
||||
page.goto(f"{datasette_server}data/projects")
|
||||
page.locator("details.actions-menu-links summary").click()
|
||||
page.locator('button[data-table-action="alter-table"]').click()
|
||||
|
||||
dialog = page.locator("#table-alter-dialog")
|
||||
dialog.wait_for()
|
||||
dialog.locator(".table-alter-add-column").click()
|
||||
row = dialog.locator(".table-alter-column-row").last
|
||||
row.locator(".table-alter-default-options summary").click()
|
||||
|
||||
type_select = row.locator(".table-alter-column-type")
|
||||
default_expr = row.locator(".table-alter-default-expr")
|
||||
assert type_select.input_value() == "text"
|
||||
assert (
|
||||
"Current Unix time, integer seconds since the epoch"
|
||||
in default_expr.locator("option").all_inner_texts()
|
||||
)
|
||||
|
||||
default_expr.select_option("current_unixtime")
|
||||
assert type_select.input_value() == "integer"
|
||||
|
||||
|
||||
@pytest.mark.playwright
|
||||
def test_alter_table_existing_default_expression_populates_select(
|
||||
page, datasette_server
|
||||
):
|
||||
page.goto(f"{datasette_server}data/defaults_demo")
|
||||
page.locator("details.actions-menu-links summary").click()
|
||||
page.locator('button[data-table-action="alter-table"]').click()
|
||||
|
||||
dialog = page.locator("#table-alter-dialog")
|
||||
dialog.wait_for()
|
||||
row = dialog.locator(".table-alter-column-row").nth(1)
|
||||
row.locator(".table-alter-more-options").click()
|
||||
row.locator(".table-alter-default-options summary").click()
|
||||
|
||||
assert row.locator(".table-alter-default-expr").input_value() == (
|
||||
"current_unixtime_ms"
|
||||
)
|
||||
assert row.locator(".table-alter-default").input_value() == ""
|
||||
|
||||
|
||||
@pytest.mark.playwright
|
||||
def test_alter_table_flow(page, datasette_server):
|
||||
page.goto(f"{datasette_server}data/projects")
|
||||
|
|
|
|||
|
|
@ -40,6 +40,35 @@ def database_data_from_soup(soup):
|
|||
return json.loads(match.group(1))
|
||||
|
||||
|
||||
DEFAULT_EXPRESSION_OPTIONS = [
|
||||
{
|
||||
"value": "current_timestamp",
|
||||
"label": "Current timestamp in UTC, e.g. 2026-05-01 13:34:00",
|
||||
"sqliteType": "text",
|
||||
},
|
||||
{
|
||||
"value": "current_date",
|
||||
"label": "Current date in UTC, e.g. 2026-05-01",
|
||||
"sqliteType": "text",
|
||||
},
|
||||
{
|
||||
"value": "current_time",
|
||||
"label": "Current time in UTC, e.g. 13:34:00",
|
||||
"sqliteType": "text",
|
||||
},
|
||||
{
|
||||
"value": "current_unixtime",
|
||||
"label": "Current Unix time, integer seconds since the epoch",
|
||||
"sqliteType": "integer",
|
||||
},
|
||||
{
|
||||
"value": "current_unixtime_ms",
|
||||
"label": "Current Unix time, integer milliseconds since the epoch",
|
||||
"sqliteType": "integer",
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize(
|
||||
"path,expected_definition_sql",
|
||||
|
|
@ -997,11 +1026,7 @@ async def test_database_create_table_action_button_and_data():
|
|||
"foreignKeyTargetsPath": "/data/-/foreign-key-targets",
|
||||
"databaseName": "data",
|
||||
"columnTypes": ["text", "integer", "float", "blob"],
|
||||
"defaultExpressions": [
|
||||
"current_timestamp",
|
||||
"current_date",
|
||||
"current_time",
|
||||
],
|
||||
"defaultExpressions": DEFAULT_EXPRESSION_OPTIONS,
|
||||
},
|
||||
}
|
||||
assert "customColumnTypes" not in database_data_from_soup(soup)["createTable"]
|
||||
|
|
@ -1113,7 +1138,9 @@ async def test_table_alter_action_button_and_data():
|
|||
create table items (
|
||||
id integer primary key,
|
||||
name text not null,
|
||||
score integer default 5
|
||||
score integer default 5,
|
||||
created text default current_timestamp,
|
||||
created_ms integer default (CAST((julianday('now') - 2440587.5) * 86400000 AS INTEGER))
|
||||
);
|
||||
""")
|
||||
response = await ds.client.get("/data/items", actor={"id": "root"})
|
||||
|
|
@ -1145,11 +1172,7 @@ async def test_table_alter_action_button_and_data():
|
|||
assert alter_data["foreignKeyTargetsPath"] == (
|
||||
"/data/-/foreign-key-targets?table=items"
|
||||
)
|
||||
assert alter_data["defaultExpressions"] == [
|
||||
"current_timestamp",
|
||||
"current_date",
|
||||
"current_time",
|
||||
]
|
||||
assert alter_data["defaultExpressions"] == DEFAULT_EXPRESSION_OPTIONS
|
||||
assert [option["name"] for option in alter_data["customColumnTypes"]] == [
|
||||
"email",
|
||||
"json",
|
||||
|
|
@ -1191,6 +1214,30 @@ async def test_table_alter_action_button_and_data():
|
|||
"foreign_key": None,
|
||||
"column_type": None,
|
||||
},
|
||||
{
|
||||
"name": "created",
|
||||
"type": "text",
|
||||
"sqlite_type": "TEXT",
|
||||
"notnull": 0,
|
||||
"default": None,
|
||||
"default_expr": "current_timestamp",
|
||||
"has_default": True,
|
||||
"is_pk": False,
|
||||
"foreign_key": None,
|
||||
"column_type": None,
|
||||
},
|
||||
{
|
||||
"name": "created_ms",
|
||||
"type": "integer",
|
||||
"sqlite_type": "INTEGER",
|
||||
"notnull": 0,
|
||||
"default": None,
|
||||
"default_expr": "current_unixtime_ms",
|
||||
"has_default": True,
|
||||
"is_pk": False,
|
||||
"foreign_key": None,
|
||||
"column_type": None,
|
||||
},
|
||||
]
|
||||
|
||||
response_without_permission = await ds.client.get(
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue