diff --git a/datasette/static/edit-tools.js b/datasette/static/edit-tools.js index fd8d9a0c..9f4f89b9 100644 --- a/datasette/static/edit-tools.js +++ b/datasette/static/edit-tools.js @@ -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) { diff --git a/datasette/views/table.py b/datasette/views/table.py index dbca3a18..80e1c514 100644 --- a/datasette/views/table.py +++ b/datasette/views/table.py @@ -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=""), diff --git a/datasette/views/table_create_alter.py b/datasette/views/table_create_alter.py index 111dfab1..ffeb7f14 100644 --- a/datasette/views/table_create_alter.py +++ b/datasette/views/table_create_alter.py @@ -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 diff --git a/docs/json_api.rst b/docs/json_api.rst index 849a8acf..b8632bc3 100644 --- a/docs/json_api.rst +++ b/docs/json_api.rst @@ -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 `. - ``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 ` 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. diff --git a/tests/test_api_write.py b/tests/test_api_write.py index 6e8c4d4b..b3a2f6a3 100644 --- a/tests/test_api_write.py +++ b/tests/test_api_write.py @@ -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'", ), ( { diff --git a/tests/test_playwright.py b/tests/test_playwright.py index fcf97a90..ee396de5 100644 --- a/tests/test_playwright.py +++ b/tests/test_playwright.py @@ -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") diff --git a/tests/test_table_html.py b/tests/test_table_html.py index 9d1d1245..3af2bb08 100644 --- a/tests/test_table_html.py +++ b/tests/test_table_html.py @@ -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(