├── .github └── workflows │ ├── publish.yml │ └── test.yml ├── .gitignore ├── README.md ├── datasette_write ├── __init__.py └── templates │ └── datasette_write.html ├── setup.py └── tests └── test_write.py /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: Publish Python Package 2 | 3 | on: 4 | release: 5 | types: [created] 6 | 7 | permissions: 8 | contents: read 9 | 10 | jobs: 11 | test: 12 | runs-on: ubuntu-latest 13 | strategy: 14 | matrix: 15 | python-version: ["3.8", "3.9", "3.10", "3.11", "3.12"] 16 | steps: 17 | - uses: actions/checkout@v4 18 | - name: Set up Python ${{ matrix.python-version }} 19 | uses: actions/setup-python@v5 20 | with: 21 | python-version: ${{ matrix.python-version }} 22 | cache: pip 23 | cache-dependency-path: setup.py 24 | - name: Install dependencies 25 | run: | 26 | pip install '.[test]' 27 | - name: Run tests 28 | run: | 29 | pytest 30 | deploy: 31 | runs-on: ubuntu-latest 32 | needs: [test] 33 | environment: release 34 | permissions: 35 | id-token: write 36 | steps: 37 | - uses: actions/checkout@v4 38 | - name: Set up Python 39 | uses: actions/setup-python@v5 40 | with: 41 | python-version: "3.12" 42 | cache: pip 43 | cache-dependency-path: setup.py 44 | - name: Install dependencies 45 | run: | 46 | pip install setuptools wheel build 47 | - name: Build 48 | run: | 49 | python -m build 50 | - name: Publish 51 | uses: pypa/gh-action-pypi-publish@release/v1 52 | 53 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Test 2 | 3 | on: [push, pull_request] 4 | 5 | permissions: 6 | contents: read 7 | 8 | jobs: 9 | test: 10 | runs-on: ubuntu-latest 11 | strategy: 12 | matrix: 13 | python-version: ["3.8", "3.9", "3.10", "3.11", "3.12"] 14 | datasette-version: ["<1.0", ">=1.0a10"] 15 | steps: 16 | - uses: actions/checkout@v4 17 | - name: Set up Python ${{ matrix.python-version }} 18 | uses: actions/setup-python@v5 19 | with: 20 | python-version: ${{ matrix.python-version }} 21 | cache: pip 22 | cache-dependency-path: setup.py 23 | - name: Install dependencies 24 | run: | 25 | pip install '.[test]' 26 | pip install "datasette${{ matrix.datasette-version }}" 27 | - name: Run tests 28 | run: | 29 | pytest 30 | 31 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .venv 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | venv 6 | .eggs 7 | .pytest_cache 8 | *.egg-info 9 | .DS_Store 10 | *.db 11 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # datasette-write 2 | 3 | [![PyPI](https://img.shields.io/pypi/v/datasette-write.svg)](https://pypi.org/project/datasette-write/) 4 | [![Changelog](https://img.shields.io/github/v/release/simonw/datasette-write?label=changelog)](https://github.com/simonw/datasette-write/releases) 5 | [![Tests](https://github.com/simonw/datasette-write/workflows/Test/badge.svg)](https://github.com/simonw/datasette-write/actions?query=workflow%3ATest) 6 | [![License](https://img.shields.io/badge/license-Apache%202.0-blue.svg)](https://github.com/simonw/datasette-write/blob/master/LICENSE) 7 | 8 | Datasette plugin providing a UI for writing to a database 9 | 10 | ## Installation 11 | 12 | Install this plugin in the same environment as Datasette. 13 | ```bash 14 | pip install datasette-write 15 | ``` 16 | ## Usage 17 | 18 | Having installed the plugin, visit `/db/-/write` on your Datasette instance to submit SQL queries that will be executed against a write connection to the specified database. 19 | 20 | By default only the `root` user can access the page - so you'll need to run Datasette with the `--root` option and click on the link shown in the terminal to sign in and access the page. 21 | 22 | The `datasette-write` permission governs access. You can use permission plugins such as [datasette-permissions-sql](https://github.com/simonw/datasette-permissions-sql) to grant additional access to the write interface. 23 | 24 | Pass `?sql=...` in the query string to pre-populate the SQL editor with a query. 25 | 26 | ## Parameterized queries 27 | 28 | SQL queries can include parameters like this: 29 | ```sql 30 | insert into news (title, body) 31 | values (:title, :body_textarea) 32 | ``` 33 | These will be converted into form fields on the `/db/-/write` page. 34 | 35 | If a parameter name ends with `_textarea` it will be rendered as a multi-line textarea instead of a text input. 36 | 37 | If a parameter name ends with `_hidden` it will be rendered as a hidden input. 38 | 39 | ## Updating rows with SQL 40 | 41 | On Datasette 1.0a13 and higher a row actions menu item will be added to the row page linking to a SQL query for updating that row, for users with the `datasette-write` permission. 42 | 43 | ## Development 44 | 45 | To set up this plugin locally, first checkout the code. Then create a new virtual environment: 46 | ```bash 47 | cd datasette-write 48 | python3 -mvenv venv 49 | source venv/bin/activate 50 | ``` 51 | Or if you are using `pipenv`: 52 | ```bash 53 | pipenv shell 54 | ``` 55 | Now install the dependencies and tests: 56 | ```bash 57 | pip install -e '.[test]' 58 | ``` 59 | To run the tests: 60 | ```bash 61 | pytest 62 | ``` 63 | -------------------------------------------------------------------------------- /datasette_write/__init__.py: -------------------------------------------------------------------------------- 1 | from datasette import hookimpl, Forbidden, Response 2 | from datasette.utils import derive_named_parameters 3 | import itsdangerous 4 | from urllib.parse import urlencode 5 | import re 6 | 7 | 8 | async def write(request, datasette): 9 | if not await datasette.permission_allowed( 10 | request.actor, "datasette-write", default=False 11 | ): 12 | raise Forbidden("Permission denied for datasette-write") 13 | database_name = request.url_vars["database"] 14 | if request.method == "GET": 15 | database = datasette.get_database(database_name) 16 | tables = await database.table_names() 17 | views = await database.view_names() 18 | sql = request.args.get("sql") or "" 19 | parameters = await derive_parameters(database, sql) 20 | # Set values based on incoming request query string 21 | for parameter in parameters: 22 | parameter["value"] = request.args.get(parameter["name"]) or "" 23 | custom_title = "" 24 | try: 25 | custom_title = datasette.unsign( 26 | request.args.get("_title", ""), "query_title" 27 | ) 28 | except itsdangerous.BadSignature: 29 | pass 30 | return Response.html( 31 | await datasette.render_template( 32 | "datasette_write.html", 33 | { 34 | "custom_title": custom_title, 35 | "sql_from_args": sql, 36 | "parameters": parameters, 37 | "database_name": database_name, 38 | "tables": tables, 39 | "views": views, 40 | "redirect_to": request.args.get("_redirect_to"), 41 | "sql_textarea_height": max(10, int(1.4 * len(sql.split("\n")))), 42 | }, 43 | request=request, 44 | ) 45 | ) 46 | elif request.method == "POST": 47 | formdata = await request.post_vars() 48 | sql = formdata["sql"] 49 | database = datasette.get_database(database_name) 50 | 51 | result = None 52 | message = None 53 | params = { 54 | key[3:]: value for key, value in formdata.items() if key.startswith("qp_") 55 | } 56 | try: 57 | result = await database.execute_write(sql, params, block=True) 58 | if result.rowcount == -1: 59 | # Maybe it was a create table / create view? 60 | name_verb_type = parse_create_alter_drop_sql(sql) 61 | if name_verb_type: 62 | name, verb, type = name_verb_type 63 | message = "{verb} {type}: {name}".format( 64 | name=name, 65 | type=type, 66 | verb={ 67 | "create": "Created", 68 | "drop": "Dropped", 69 | "alter": "Altered", 70 | }[verb], 71 | ) 72 | else: 73 | message = "Query executed" 74 | else: 75 | message = "{} row{} affected".format( 76 | result.rowcount, "" if result.rowcount == 1 else "s" 77 | ) 78 | except Exception as e: 79 | message = str(e) 80 | datasette.add_message( 81 | request, 82 | message, 83 | type=datasette.INFO if result else datasette.ERROR, 84 | ) 85 | # Default redirect back to this page 86 | redirect_to = datasette.urls.path("/-/write?") + urlencode( 87 | { 88 | "database": database.name, 89 | "sql": sql, 90 | } 91 | ) 92 | try: 93 | # Unless value and valid signature for _redirect_to= 94 | redirect_to = datasette.unsign(formdata["_redirect_to"], "redirect_to") 95 | except (itsdangerous.BadSignature, KeyError): 96 | pass 97 | return Response.redirect(redirect_to) 98 | else: 99 | return Response.html("Bad method", status_code=405) 100 | 101 | 102 | async def write_redirect(request, datasette): 103 | if not await datasette.permission_allowed( 104 | request.actor, "datasette-write", default=False 105 | ): 106 | raise Forbidden("Permission denied for datasette-write") 107 | 108 | db = request.args.get("database") or "" 109 | if not db: 110 | db = datasette.get_database().name 111 | 112 | # Preserve query string, except the database= 113 | pairs = [ 114 | (key, request.args.getlist(key)) for key in request.args if key != "database" 115 | ] 116 | query_string = "" 117 | if pairs: 118 | query_string = "?" + urlencode(pairs, doseq=True) 119 | 120 | return Response.redirect(datasette.urls.database(db) + "/-/write" + query_string) 121 | 122 | 123 | async def derive_parameters(db, sql): 124 | parameters = await derive_named_parameters(db, sql) 125 | 126 | def _type(parameter): 127 | type = "text" 128 | if parameter.endswith("_textarea"): 129 | type = "textarea" 130 | if parameter.endswith("_hidden"): 131 | type = "hidden" 132 | return type 133 | 134 | def _label(parameter): 135 | if parameter.endswith("_textarea"): 136 | return parameter[: -len("_textarea")] 137 | if parameter.endswith("_hidden"): 138 | return parameter[: -len("_hidden")] 139 | return parameter 140 | 141 | return [ 142 | {"name": parameter, "type": _type(parameter), "label": _label(parameter)} 143 | for parameter in parameters 144 | ] 145 | 146 | 147 | async def write_derive_parameters(datasette, request): 148 | if not await datasette.permission_allowed( 149 | request.actor, "datasette-write", default=False 150 | ): 151 | raise Forbidden("Permission denied for datasette-write") 152 | try: 153 | db = datasette.get_database(request.args.get("database")) 154 | except KeyError: 155 | db = datasette.get_database() 156 | parameters = await derive_parameters(db, request.args.get("sql") or "") 157 | return Response.json({"parameters": parameters}) 158 | 159 | 160 | @hookimpl 161 | def register_routes(): 162 | return [ 163 | (r"^/(?P[^/]+)/-/write$", write), 164 | (r"^/-/write$", write_redirect), 165 | (r"^/-/write/derive-parameters$", write_derive_parameters), 166 | ] 167 | 168 | 169 | @hookimpl 170 | def permission_allowed(actor, action): 171 | if action == "datasette-write" and actor and actor.get("id") == "root": 172 | return True 173 | 174 | 175 | @hookimpl 176 | def database_actions(datasette, actor, database): 177 | async def inner(): 178 | if database != "_internal" and await datasette.permission_allowed( 179 | actor, "datasette-write", default=False 180 | ): 181 | return [ 182 | { 183 | "href": datasette.urls.database(database) + "/-/write", 184 | "label": "Execute SQL write", 185 | "description": "Run queries like insert/update/delete against this database", 186 | }, 187 | ] 188 | 189 | return inner 190 | 191 | 192 | @hookimpl 193 | def row_actions(datasette, actor, database, table, row, request): 194 | async def inner(): 195 | if database != "_internal" and await datasette.permission_allowed( 196 | actor, "datasette-write", default=False 197 | ): 198 | db = datasette.get_database(database) 199 | pks = [] 200 | columns = [] 201 | for details in await db.table_column_details(table): 202 | if details.is_pk: 203 | pks.append(details.name) 204 | else: 205 | columns.append( 206 | { 207 | "name": details.name, 208 | "notnull": details.notnull, 209 | } 210 | ) 211 | row_dict = dict(row) 212 | set_clause_bits = [] 213 | args = { 214 | "database": database, 215 | } 216 | for column in columns: 217 | column_name = column["name"] 218 | field_name = column_name 219 | if column_name in ("sql", "_redirect_to", "_title"): 220 | field_name = "_{}".format(column_name) 221 | current_value = str(row_dict.get(column_name) or "") 222 | if "\n" in current_value: 223 | field_name = field_name + "_textarea" 224 | if column["notnull"]: 225 | fragment = '"{}" = :{}' 226 | else: 227 | fragment = "\"{}\" = nullif(:{}, '')" 228 | set_clause_bits.append(fragment.format(column["name"], field_name)) 229 | args[field_name] = current_value 230 | set_clauses = ",\n ".join(set_clause_bits) 231 | 232 | # Add the where clauses, with _hidden to prevent edits 233 | where_clauses = " and ".join( 234 | '"{}" = :{}_hidden'.format(pk, pk) for pk in pks 235 | ) 236 | args.update([("{}_hidden".format(pk), row_dict[pk]) for pk in pks]) 237 | 238 | row_desc = ", ".join( 239 | "{}={}".format(k, v) for k, v in row_dict.items() if k in pks 240 | ) 241 | 242 | sql = 'update "{}" set\n {}\nwhere {}'.format( 243 | table, set_clauses, where_clauses 244 | ) 245 | args["sql"] = sql 246 | args["_redirect_to"] = datasette.sign(request.path, "redirect_to") 247 | args["_title"] = datasette.sign( 248 | "Update {} where {}".format(table, row_desc), "query_title" 249 | ) 250 | return [ 251 | { 252 | "href": datasette.urls.path("/-/write") + "?" + urlencode(args), 253 | "label": "Update using SQL", 254 | "description": "Compose and execute a SQL query to update this row", 255 | }, 256 | ] 257 | 258 | return inner 259 | 260 | 261 | _name_patterns = ( 262 | r"\[([^\]]+)\]", # create table [foo] 263 | r'"([^"]+)"', # create table "foo" 264 | r"'([^']+)'", # create table 'foo' 265 | r"([a-zA-Z_][a-zA-Z0-9_]*)", # create table foo123 266 | ) 267 | _res = [] 268 | for type in ("table", "view"): 269 | for name_pattern in _name_patterns: 270 | for verb in ("create", "drop"): 271 | pattern = r"\s*{}\s+{}\s+{}.*".format(verb, type, name_pattern) 272 | _res.append((type, verb, re.compile(pattern, re.I))) 273 | alter_table_pattern = r"\s*alter\s+table\s+{}.*".format(name_pattern) 274 | _res.append(("table", "alter", re.compile(alter_table_pattern, re.I))) 275 | 276 | 277 | def parse_create_alter_drop_sql(sql): 278 | """ 279 | Simple regex-based detection of 'create table foo' type queries 280 | 281 | Returns the view or table name, or None if none was identified 282 | """ 283 | for type, verb, _re in _res: 284 | match = _re.match(sql) 285 | if match is not None: 286 | return match.group(1), verb, type 287 | return None 288 | -------------------------------------------------------------------------------- /datasette_write/templates/datasette_write.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | 3 | {% block title %}{% if custom_title %}{{ custom_title }}{% else %}Write to {{ database_name }} with SQL{% endif %}{% endblock %} 4 | 5 | {% block extra_head %} 6 | 37 | {% endblock %} 38 | 39 | {% block crumbs %} 40 | {{ crumbs.nav(request=request, database=database_name) }} 41 | {% endblock %} 42 | 43 | {% block content %} 44 |

{% if custom_title %}{{ custom_title }}{% else %}Write to {{ database_name }} with SQL{% endif %}

45 | 46 |
47 | 48 | {% if custom_title %}
SQL query{% endif %} 49 |

50 | {% if custom_title %}
{% endif %} 51 |
52 |
53 | {% for parameter in parameters %} 54 | 55 | {% if parameter.type == "text" %} 56 | 57 | {% elif parameter.type == "hidden" %} 58 | 59 | {% elif parameter.type == "textarea" %} 60 | 61 | {% endif %} 62 | {% endfor %} 63 |
64 |
65 |
66 | 67 | 68 |
69 |
70 | 71 | {% if tables %} 72 |

Tables: 73 | {% for table in tables %} 74 | {{ table }}{% if not loop.last %}, {% endif %} 75 | {% endfor %} 76 |

77 | {% endif %} 78 | 79 | {% if views %} 80 |

Views: 81 | {% for view in views %} 82 | {{ view }}{% if not loop.last %}, {% endif %} 83 | {% endfor %} 84 |

85 | {% endif %} 86 | 87 | 181 | 182 | {% endblock %} 183 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup 2 | import os 3 | 4 | VERSION = "0.4" 5 | 6 | 7 | def get_long_description(): 8 | with open( 9 | os.path.join(os.path.dirname(os.path.abspath(__file__)), "README.md"), 10 | encoding="utf8", 11 | ) as fp: 12 | return fp.read() 13 | 14 | 15 | setup( 16 | name="datasette-write", 17 | description="Datasette plugin providing a UI for writing to a database", 18 | long_description=get_long_description(), 19 | long_description_content_type="text/markdown", 20 | author="Simon Willison", 21 | url="https://github.com/simonw/datasette-write", 22 | project_urls={ 23 | "Issues": "https://github.com/simonw/datasette-write/issues", 24 | "CI": "https://github.com/simonw/datasette-write/actions", 25 | "Changelog": "https://github.com/simonw/datasette-write/releases", 26 | }, 27 | license="Apache License, Version 2.0", 28 | version=VERSION, 29 | packages=["datasette_write"], 30 | package_data={ 31 | "datasette_write": [ 32 | "templates/datasette_write.html", 33 | ], 34 | }, 35 | entry_points={"datasette": ["write = datasette_write"]}, 36 | install_requires=["datasette>=0.64.6"], 37 | extras_require={"test": ["pytest", "pytest-asyncio", "httpx", "beautifulsoup4"]}, 38 | ) 39 | -------------------------------------------------------------------------------- /tests/test_write.py: -------------------------------------------------------------------------------- 1 | from bs4 import BeautifulSoup as Soup 2 | import datasette 3 | from datasette.app import Datasette 4 | from datasette_write import parse_create_alter_drop_sql 5 | import pytest 6 | import sqlite3 7 | import textwrap 8 | import urllib 9 | 10 | 11 | @pytest.fixture 12 | def ds(tmp_path_factory): 13 | db_directory = tmp_path_factory.mktemp("dbs") 14 | db_path = str(db_directory / "test.db") 15 | db_path2 = str(db_directory / "test2.db") 16 | sqlite3.connect(db_path).executescript( 17 | """ 18 | create table one (id integer primary key, count integer); 19 | insert into one (id, count) values (1, 10); 20 | insert into one (id, count) values (2, 20); 21 | """ 22 | ) 23 | sqlite3.connect(db_path2).executescript( 24 | """ 25 | create table simple_pk (id integer primary key, name text); 26 | insert into simple_pk (id, name) values (1, 'one'); 27 | create table simple_pk_multiline (id integer primary key, name text); 28 | insert into simple_pk_multiline (id, name) values (1, 'one' || char(10) || 'two'); 29 | create table compound_pk (id1 integer, id2 integer, name text, primary key (id1, id2)); 30 | insert into compound_pk (id1, id2, name) values (1, 2, 'one-two'); 31 | create table has_not_null (id integer primary key, sql text not null); 32 | insert into has_not_null (id, sql) values (1, 'one'); 33 | """ 34 | ) 35 | ds = Datasette([db_path, db_path2]) 36 | return ds 37 | 38 | 39 | @pytest.mark.asyncio 40 | async def test_permission_denied(ds): 41 | response = await ds.client.get("/test/-/write") 42 | assert 403 == response.status_code 43 | 44 | 45 | @pytest.mark.asyncio 46 | async def test_permission_granted_to_root(ds): 47 | response = await ds.client.get( 48 | "/test/-/write", 49 | cookies={"ds_actor": ds.sign({"a": {"id": "root"}}, "actor")}, 50 | ) 51 | assert response.status_code == 200 52 | assert "Tables:" in response.text 53 | assert 'one' in response.text 54 | 55 | # Should have database action menu option too: 56 | anon_response = (await ds.client.get("/test")).text 57 | fragment = 'Execute SQL write' 58 | assert fragment not in anon_response 59 | root_response = ( 60 | await ds.client.get( 61 | "/test", cookies={"ds_actor": ds.sign({"a": {"id": "root"}}, "actor")} 62 | ) 63 | ).text 64 | assert fragment in root_response 65 | 66 | 67 | @pytest.mark.asyncio 68 | async def test_populate_sql_from_query_string(ds): 69 | response = await ds.client.get( 70 | "/test/-/write?sql=select+1", 71 | cookies={"ds_actor": ds.sign({"a": {"id": "root"}}, "actor")}, 72 | ) 73 | assert response.status_code == 200 74 | assert '">select 1' in response.text 75 | 76 | 77 | @pytest.mark.parametrize( 78 | "database,sql,params,expected_message", 79 | [ 80 | ( 81 | "test", 82 | "create table newtable (id integer)", 83 | {}, 84 | "Created table: newtable", 85 | ), 86 | ( 87 | "test", 88 | "drop table one", 89 | {}, 90 | "Dropped table: one", 91 | ), 92 | ( 93 | "test", 94 | "alter table one add column bigfile blob", 95 | {}, 96 | "Altered table: one", 97 | ), 98 | ( 99 | "test2", 100 | "create table newtable (id integer)", 101 | {}, 102 | "Created table: newtable", 103 | ), 104 | ( 105 | "test2", 106 | "create view blah as select 1 + 1", 107 | {}, 108 | "Created view: blah", 109 | ), 110 | ("test", "update one set count = 5", {}, "2 rows affected"), 111 | ("test", "invalid sql", {}, 'near "invalid": syntax error'), 112 | # Parameterized queries 113 | ("test", "update one set count = :count", {"qp_count": 4}, "2 rows affected"), 114 | # This should error 115 | ( 116 | "test", 117 | "update one set count = :count", 118 | {}, 119 | "Incorrect number of bindings supplied. The current statement uses 1, and there are 0 supplied.", 120 | ), 121 | ], 122 | ) 123 | @pytest.mark.asyncio 124 | async def test_execute_write(ds, database, sql, params, expected_message): 125 | # Get csrftoken 126 | cookies = {"ds_actor": ds.sign({"a": {"id": "root"}}, "actor")} 127 | response = await ds.client.get("/{}/-/write".format(database), cookies=cookies) 128 | assert 200 == response.status_code 129 | csrftoken = response.cookies["ds_csrftoken"] 130 | cookies["ds_csrftoken"] = csrftoken 131 | data = { 132 | "sql": sql, 133 | "csrftoken": csrftoken, 134 | } 135 | data.update(params) 136 | # write to database 137 | response2 = await ds.client.post( 138 | "/{}/-/write".format(database), 139 | data=data, 140 | cookies=cookies, 141 | ) 142 | messages = [m[0] for m in ds.unsign(response2.cookies["ds_messages"], "messages")] 143 | assert messages[0] == expected_message 144 | # Should have preserved ?database= in redirect: 145 | bits = dict(urllib.parse.parse_qsl(response2.headers["location"].split("?")[-1])) 146 | assert bits["database"] == database 147 | # Should have preserved ?sql= in redirect: 148 | assert bits["sql"] == sql 149 | 150 | 151 | @pytest.mark.parametrize( 152 | "sql,expected_name,expected_verb,expected_type", 153 | ( 154 | ("create table hello (...", "hello", "create", "table"), 155 | (" create view hello2 as (...", "hello2", "create", "view"), 156 | ("select 1 + 1", None, None, None), 157 | # Various styles of quoting 158 | ("create table 'hello' (", "hello", "create", "table"), 159 | (' create \n table "hello" (', "hello", "create", "table"), 160 | ("create table [hello] (", "hello", "create", "table"), 161 | ("create view 'hello' (", "hello", "create", "view"), 162 | (' create \n view "hello" (', "hello", "create", "view"), 163 | ("create view [hello] (", "hello", "create", "view"), 164 | # Alter table 165 | ("alter table [hello] ", "hello", "alter", "table"), 166 | # But no alter view 167 | ("alter view [hello] ", None, None, None), 168 | ), 169 | ) 170 | def test_parse_create_alter_drop_sql(sql, expected_name, expected_verb, expected_type): 171 | name_verb_type = parse_create_alter_drop_sql(sql) 172 | if expected_name is None: 173 | assert name_verb_type is None 174 | else: 175 | assert name_verb_type == (expected_name, expected_verb, expected_type) 176 | 177 | 178 | @pytest.mark.asyncio 179 | @pytest.mark.parametrize( 180 | "path,expected_path", 181 | ( 182 | ("/-/write", "/test/-/write"), 183 | ("/-/write?database=test", "/test/-/write"), 184 | ("/-/write?database=test2", "/test2/-/write"), 185 | ("/-/write?database=test2&a=1&a=2", "/test2/-/write?a=1&a=2"), 186 | ), 187 | ) 188 | async def test_write_redirect(ds, path, expected_path): 189 | response = await ds.client.get( 190 | path, 191 | cookies={"ds_actor": ds.sign({"a": {"id": "root"}}, "actor")}, 192 | ) 193 | assert response.status_code == 302 194 | assert response.headers["location"] == expected_path 195 | 196 | 197 | @pytest.mark.asyncio 198 | @pytest.mark.parametrize("valid", (True, False)) 199 | async def test_redirect_to(ds, valid): 200 | cookies = {"ds_actor": ds.sign({"a": {"id": "root"}}, "actor")} 201 | signed_redirect_to = ds.sign("/", "redirect_to") 202 | used_redirect_to = signed_redirect_to + ("" if valid else "invalid") 203 | response = await ds.client.get( 204 | "/test/-/write", 205 | params={"_redirect_to": used_redirect_to}, 206 | cookies=cookies, 207 | ) 208 | assert response.status_code == 200 209 | # Should have redirect_to field 210 | input = Soup(response.text, "html.parser").find("input", {"name": "_redirect_to"}) 211 | assert input.attrs["value"] == used_redirect_to 212 | assert 'Custom Title" in response.text 247 | #
SQL query" in response.text 249 | else: 250 | assert "Write to test with SQL" in response.text 251 | assert "SQL query" not in response.text 252 | 253 | 254 | @pytest.mark.asyncio 255 | # Skip if Datasette < ('1', '0a15') 256 | @pytest.mark.skipif( 257 | datasette.__version_info__ < ("1", "0"), 258 | reason="Datasette < 1.0 does not support this hook", 259 | ) 260 | @pytest.mark.parametrize( 261 | "path,expected", 262 | [ 263 | ( 264 | "/test2/simple_pk/1", 265 | textwrap.dedent( 266 | """ 267 | update "simple_pk" set 268 | "name" = nullif(:name, '') 269 | where "id" = :id_hidden 270 | """ 271 | ).strip(), 272 | ), 273 | ( 274 | "/test2/simple_pk_multiline/1", 275 | textwrap.dedent( 276 | """ 277 | update "simple_pk_multiline" set 278 | "name" = nullif(:name_textarea, '') 279 | where "id" = :id_hidden 280 | """ 281 | ).strip(), 282 | ), 283 | ( 284 | "/test2/compound_pk/1,2", 285 | textwrap.dedent( 286 | """ 287 | update "compound_pk" set 288 | "name" = nullif(:name, '') 289 | where "id1" = :id1_hidden and "id2" = :id2_hidden 290 | """ 291 | ).strip(), 292 | ), 293 | ( 294 | "/test2/has_not_null/1", 295 | textwrap.dedent( 296 | """ 297 | update "has_not_null" set 298 | "sql" = :_sql 299 | where "id" = :id_hidden 300 | """ 301 | ).strip(), 302 | ), 303 | ], 304 | ) 305 | async def test_row_actions(ds, path, expected): 306 | cookies = {"ds_actor": ds.sign({"a": {"id": "root"}}, "actor")} 307 | response = await ds.client.get( 308 | path, 309 | cookies=cookies, 310 | ) 311 | href = Soup(response.text, "html.parser").select(".dropdown-menu a")[0]["href"] 312 | qs = href.split("?")[-1] 313 | bits = dict(urllib.parse.parse_qsl(qs)) 314 | actual = bits["sql"] 315 | assert actual == expected 316 | --------------------------------------------------------------------------------