Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions datasette/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@
TableInsertView,
TableUpsertView,
TableDropView,
TableScriptView,
table_view,
)
from .views.row import RowView, RowDeleteView, RowUpdateView
Expand Down Expand Up @@ -2033,6 +2034,10 @@ def add_route(view, regex):
TableDropView.as_view(self),
r"/(?P<database>[^\/\.]+)/(?P<table>[^\/\.]+)/-/drop$",
)
add_route(
TableScriptView.as_view(self),
r"/(?P<database>[^\/\.]+)/(?P<table>[^\/\.]+)/-/script$",
)
add_route(
TableSchemaView.as_view(self),
r"/(?P<database>[^\/\.]+)/(?P<table>[^\/\.]+)/-/schema(\.(?P<format>json|md))?$",
Expand Down
22 changes: 22 additions & 0 deletions datasette/events.py
Original file line number Diff line number Diff line change
Expand Up @@ -219,6 +219,27 @@ class DeleteRowEvent(Event):
pks: list


@dataclass
class ExecuteScriptEvent(Event):
"""
Event name: ``execute-script``

A SQL script with multiple statements was executed.

:ivar database: The name of the database where the script was executed.
:type database: str
:ivar table: The table context for the script execution.
:type table: str
:ivar num_statements: The number of SQL statements in the script.
:type num_statements: int
"""

name = "execute-script"
database: str
table: str
num_statements: int


@hookimpl
def register_events():
return [
Expand All @@ -232,4 +253,5 @@ def register_events():
UpsertRowsEvent,
UpdateRowEvent,
DeleteRowEvent,
ExecuteScriptEvent,
]
97 changes: 97 additions & 0 deletions datasette/views/table.py
Original file line number Diff line number Diff line change
Expand Up @@ -679,6 +679,103 @@ def drop_table(conn):
return Response.json({"ok": True}, status=200)


class TableScriptView(BaseView):
name = "table-script"

def __init__(self, datasette):
self.ds = datasette

async def post(self, request):
# Resolve database and table
try:
resolved = await self.ds.resolve_table(request)
except NotFound as e:
return _error([e.args[0]], 404)

db = resolved.db
database_name = db.name
table_name = resolved.table

# Check if database is mutable
if not db.is_mutable:
return _error(["Database is immutable"], 403)

# Check execute-sql permission (database-level)
if not await self.ds.allowed(
action="execute-sql",
resource=DatabaseResource(database=database_name),
actor=request.actor,
):
return _error(["Permission denied"], 403)

# Validate request body
if not request.headers.get("content-type", "").startswith("application/json"):
return _error(["Invalid content-type, must be application/json"], 400)

body = await request.post_body()
try:
data = json.loads(body)
except json.JSONDecodeError as e:
return _error(["Invalid JSON: {}".format(e)], 400)

if not isinstance(data, dict):
return _error(["JSON must be a dictionary"], 400)

if "sql" not in data:
return _error(['JSON must contain a "sql" key'], 400)

sql_script = data["sql"]
if not isinstance(sql_script, str):
return _error(['"sql" must be a string'], 400)

if not sql_script.strip():
return _error(["SQL script cannot be empty"], 400)

# Split script into statements (basic splitting by semicolon)
# This is a simple approach - SQLite handles parsing
statements = [s.strip() for s in sql_script.split(";") if s.strip()]
num_statements = len(statements)

# Execute script in a transaction
def execute_script(conn):
# Execute each statement individually within our transaction
cursor = conn.cursor()
for i, statement in enumerate(statements):
try:
cursor.execute(statement)
except Exception as e:
# Add statement number to error message
raise Exception("{} at statement {}".format(str(e), i + 1))
return cursor.rowcount

try:
await db.execute_write_fn(execute_script)
except Exception as e:
return _error([str(e)], 400)

# Track event
from datasette.events import ExecuteScriptEvent

await self.ds.track_event(
ExecuteScriptEvent(
actor=request.actor,
database=database_name,
table=table_name,
num_statements=num_statements,
)
)

return Response.json(
{
"ok": True,
"database": database_name,
"table": table_name,
"statements_executed": num_statements,
},
status=200,
)


def _get_extras(request):
extra_bits = request.args.getlist("_extra")
extras = set()
Expand Down
67 changes: 67 additions & 0 deletions docs/json_api.rst
Original file line number Diff line number Diff line change
Expand Up @@ -977,3 +977,70 @@ If you pass the following POST body:
Then the table will be dropped and a status ``200`` response of ``{"ok": true}`` will be returned.

Any errors will return ``{"errors": ["... descriptive message ..."], "ok": false}``, and a ``400`` status code for a bad input or a ``403`` status code for an authentication or permission error.

.. _TableScriptView:

Executing SQL scripts
~~~~~~~~~~~~~~~~~~~~~

To execute a SQL script with multiple statements in a transaction, make a ``POST`` to ``/<database>/<table>/-/script``. This requires the :ref:`actions_execute_sql` permission.

::

POST /<database>/<table>/-/script
Content-Type: application/json
Authorization: Bearer dstok_<rest-of-token>

.. code-block:: json

{
"sql": "INSERT INTO items (name, value) VALUES ('item1', 10); INSERT INTO items (name, value) VALUES ('item2', 20); UPDATE items SET value = value * 2 WHERE name = 'item1'"
}

The SQL script can contain multiple statements separated by semicolons. All statements are executed within a single transaction, ensuring atomicity - either all statements succeed or all fail with the transaction rolled back.

If successful, this will return a ``200`` status code and the following response:

.. code-block:: json

{
"ok": true,
"database": "data",
"table": "items",
"statements_executed": 3
}

If any statement fails, the entire transaction is rolled back and an error is returned:

.. code-block:: json

{
"ok": false,
"errors": [
"UNIQUE constraint failed: items.id at statement 2"
]
}

The error message will indicate which statement number caused the failure (1-indexed).

The ``table`` parameter in the URL provides context for the script execution but does not restrict which tables the script can operate on. The script can include statements that affect any table in the database.

Any errors will return ``{"errors": ["... descriptive message ..."], "ok": false}``, and a ``400`` status code for a bad input or a ``403`` status code for an authentication or permission error.

**Transaction semantics:**

- All statements in the script execute within a single database transaction
- If any statement fails, the entire transaction is rolled back
- No partial execution occurs - it's all-or-nothing
- This ensures data consistency when performing related operations

**Use cases:**

- Performing multiple related inserts/updates atomically
- Creating tables and populating them with initial data
- Complex data migrations that require multiple steps
- Ensuring referential integrity across multiple operations

**Security note:**

This endpoint requires the ``execute-sql`` permission at the database level. Since it allows arbitrary SQL execution, it should only be granted to trusted users. Unlike the insert/update/delete endpoints which are more restricted, this endpoint provides full SQL flexibility.
Loading
Loading