datasette-write 0.3.1__tar.gz → 0.4__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Potentially problematic release.
This version of datasette-write might be problematic. Click here for more details.
- {datasette-write-0.3.1 → datasette_write-0.4}/PKG-INFO +11 -4
- {datasette-write-0.3.1 → datasette_write-0.4}/README.md +9 -3
- datasette_write-0.4/datasette_write/__init__.py +287 -0
- {datasette-write-0.3.1 → datasette_write-0.4}/datasette_write/templates/datasette_write.html +46 -14
- {datasette-write-0.3.1 → datasette_write-0.4}/datasette_write.egg-info/PKG-INFO +11 -4
- {datasette-write-0.3.1 → datasette_write-0.4}/datasette_write.egg-info/requires.txt +1 -0
- {datasette-write-0.3.1 → datasette_write-0.4}/setup.py +2 -2
- datasette_write-0.4/tests/test_write.py +315 -0
- datasette-write-0.3.1/datasette_write/__init__.py +0 -203
- datasette-write-0.3.1/tests/test_write.py +0 -173
- {datasette-write-0.3.1 → datasette_write-0.4}/datasette_write.egg-info/SOURCES.txt +0 -0
- {datasette-write-0.3.1 → datasette_write-0.4}/datasette_write.egg-info/dependency_links.txt +0 -0
- {datasette-write-0.3.1 → datasette_write-0.4}/datasette_write.egg-info/entry_points.txt +0 -0
- {datasette-write-0.3.1 → datasette_write-0.4}/datasette_write.egg-info/top_level.txt +0 -0
- {datasette-write-0.3.1 → datasette_write-0.4}/setup.cfg +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.1
|
|
2
2
|
Name: datasette-write
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.4
|
|
4
4
|
Summary: Datasette plugin providing a UI for writing to a database
|
|
5
5
|
Home-page: https://github.com/simonw/datasette-write
|
|
6
6
|
Author: Simon Willison
|
|
@@ -14,6 +14,7 @@ Provides-Extra: test
|
|
|
14
14
|
Requires-Dist: pytest; extra == "test"
|
|
15
15
|
Requires-Dist: pytest-asyncio; extra == "test"
|
|
16
16
|
Requires-Dist: httpx; extra == "test"
|
|
17
|
+
Requires-Dist: beautifulsoup4; extra == "test"
|
|
17
18
|
|
|
18
19
|
# datasette-write
|
|
19
20
|
|
|
@@ -32,13 +33,13 @@ pip install datasette-write
|
|
|
32
33
|
```
|
|
33
34
|
## Usage
|
|
34
35
|
|
|
35
|
-
Having installed the plugin, visit
|
|
36
|
+
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.
|
|
36
37
|
|
|
37
38
|
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.
|
|
38
39
|
|
|
39
40
|
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.
|
|
40
41
|
|
|
41
|
-
Pass `?sql=...` in the query string to pre-populate the SQL editor with a query.
|
|
42
|
+
Pass `?sql=...` in the query string to pre-populate the SQL editor with a query.
|
|
42
43
|
|
|
43
44
|
## Parameterized queries
|
|
44
45
|
|
|
@@ -47,10 +48,16 @@ SQL queries can include parameters like this:
|
|
|
47
48
|
insert into news (title, body)
|
|
48
49
|
values (:title, :body_textarea)
|
|
49
50
|
```
|
|
50
|
-
These will be converted into form fields on the
|
|
51
|
+
These will be converted into form fields on the `/db/-/write` page.
|
|
51
52
|
|
|
52
53
|
If a parameter name ends with `_textarea` it will be rendered as a multi-line textarea instead of a text input.
|
|
53
54
|
|
|
55
|
+
If a parameter name ends with `_hidden` it will be rendered as a hidden input.
|
|
56
|
+
|
|
57
|
+
## Updating rows with SQL
|
|
58
|
+
|
|
59
|
+
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.
|
|
60
|
+
|
|
54
61
|
## Development
|
|
55
62
|
|
|
56
63
|
To set up this plugin locally, first checkout the code. Then create a new virtual environment:
|
|
@@ -15,13 +15,13 @@ pip install datasette-write
|
|
|
15
15
|
```
|
|
16
16
|
## Usage
|
|
17
17
|
|
|
18
|
-
Having installed the plugin, visit
|
|
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
19
|
|
|
20
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
21
|
|
|
22
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
23
|
|
|
24
|
-
Pass `?sql=...` in the query string to pre-populate the SQL editor with a query.
|
|
24
|
+
Pass `?sql=...` in the query string to pre-populate the SQL editor with a query.
|
|
25
25
|
|
|
26
26
|
## Parameterized queries
|
|
27
27
|
|
|
@@ -30,10 +30,16 @@ SQL queries can include parameters like this:
|
|
|
30
30
|
insert into news (title, body)
|
|
31
31
|
values (:title, :body_textarea)
|
|
32
32
|
```
|
|
33
|
-
These will be converted into form fields on the
|
|
33
|
+
These will be converted into form fields on the `/db/-/write` page.
|
|
34
34
|
|
|
35
35
|
If a parameter name ends with `_textarea` it will be rendered as a multi-line textarea instead of a text input.
|
|
36
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
|
+
|
|
37
43
|
## Development
|
|
38
44
|
|
|
39
45
|
To set up this plugin locally, first checkout the code. Then create a new virtual environment:
|
|
@@ -0,0 +1,287 @@
|
|
|
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<database>[^/]+)/-/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
|
{datasette-write-0.3.1 → datasette_write-0.4}/datasette_write/templates/datasette_write.html
RENAMED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{% extends "base.html" %}
|
|
2
2
|
|
|
3
|
-
{% block title %}Write with SQL{% endblock %}
|
|
3
|
+
{% block title %}{% if custom_title %}{{ custom_title }}{% else %}Write to {{ database_name }} with SQL{% endif %}{% endblock %}
|
|
4
4
|
|
|
5
5
|
{% block extra_head %}
|
|
6
6
|
<style>
|
|
@@ -25,7 +25,7 @@
|
|
|
25
25
|
box-sizing: border-box;
|
|
26
26
|
}
|
|
27
27
|
#query-parameters-area textarea {
|
|
28
|
-
height:
|
|
28
|
+
height: 20em;
|
|
29
29
|
}
|
|
30
30
|
.submit-container {
|
|
31
31
|
padding-top: 1em;
|
|
@@ -36,28 +36,34 @@
|
|
|
36
36
|
</style>
|
|
37
37
|
{% endblock %}
|
|
38
38
|
|
|
39
|
+
{% block crumbs %}
|
|
40
|
+
{{ crumbs.nav(request=request, database=database_name) }}
|
|
41
|
+
{% endblock %}
|
|
42
|
+
|
|
39
43
|
{% block content %}
|
|
40
|
-
<h1>Write to
|
|
44
|
+
<h1>{% if custom_title %}{{ custom_title }}{% else %}Write to {{ database_name }} with SQL{% endif %}</h1>
|
|
41
45
|
|
|
42
|
-
<form class="write-form" action="{{
|
|
46
|
+
<form class="write-form core" action="{{ request.path }}" method="post" style="margin-bottom: 1em">
|
|
43
47
|
<input type="hidden" name="csrftoken" value="{{ csrftoken() }}">
|
|
44
|
-
<
|
|
45
|
-
<
|
|
46
|
-
{%
|
|
47
|
-
<p><textarea name="sql" style="box-sizing: border-box; width: 100%; padding-right: 10px; max-width: 600px; height: 10em; padding: 6px;">{{ sql_from_args }}</textarea></p>
|
|
48
|
+
{% if custom_title %}<details style="margin-bottom: 1em"><summary>SQL query</summary>{% endif %}
|
|
49
|
+
<p><textarea name="sql" style="box-sizing: border-box; width: 100%; padding-right: 10px; max-width: 600px; height: {{ sql_textarea_height }}em; padding: 6px;">{{ sql_from_args }}</textarea></p>
|
|
50
|
+
{% if custom_title %}</details>{% endif %}
|
|
48
51
|
<div class="query-parameters">
|
|
49
52
|
<div id="query-parameters-area">
|
|
50
53
|
{% for parameter in parameters %}
|
|
51
54
|
<label for="qp_{{ parameter.name }}">{{ parameter.label }}</label>
|
|
52
55
|
{% if parameter.type == "text" %}
|
|
53
|
-
<input type="text" id="qp_{{ parameter.name }}" name="qp_{{ parameter.name }}" value="">
|
|
56
|
+
<input type="text" id="qp_{{ parameter.name }}" name="qp_{{ parameter.name }}" value="{{ parameter.value }}">
|
|
57
|
+
{% elif parameter.type == "hidden" %}
|
|
58
|
+
<input type="text" disabled id="qp_{{ parameter.name }}" name="qp_{{ parameter.name }}" value="{{ parameter.value }}">
|
|
54
59
|
{% elif parameter.type == "textarea" %}
|
|
55
|
-
<textarea id="qp_{{ parameter.name }}" name="qp_{{ parameter.name }}"
|
|
60
|
+
<textarea id="qp_{{ parameter.name }}" name="qp_{{ parameter.name }}">{{ parameter.value }}</textarea>
|
|
56
61
|
{% endif %}
|
|
57
62
|
{% endfor %}
|
|
58
63
|
</div>
|
|
59
64
|
</div>
|
|
60
65
|
<div class="submit-container">
|
|
66
|
+
<input type="hidden" name="_redirect_to" value="{{ redirect_to }}">
|
|
61
67
|
<input type="submit" value="Execute query">
|
|
62
68
|
</div>
|
|
63
69
|
</form>
|
|
@@ -65,7 +71,7 @@
|
|
|
65
71
|
{% if tables %}
|
|
66
72
|
<p><strong>Tables</strong>:
|
|
67
73
|
{% for table in tables %}
|
|
68
|
-
<a href="{{ urls.table(
|
|
74
|
+
<a href="{{ urls.table(database_name, table) }}">{{ table }}</a>{% if not loop.last %}, {% endif %}
|
|
69
75
|
{% endfor %}
|
|
70
76
|
</p>
|
|
71
77
|
{% endif %}
|
|
@@ -73,7 +79,7 @@
|
|
|
73
79
|
{% if views %}
|
|
74
80
|
<p><strong>Views</strong>:
|
|
75
81
|
{% for view in views %}
|
|
76
|
-
<a href="{{ urls.table(
|
|
82
|
+
<a href="{{ urls.table(database_name, view) }}">{{ view }}</a>{% if not loop.last %}, {% endif %}
|
|
77
83
|
{% endfor %}
|
|
78
84
|
</p>
|
|
79
85
|
{% endif %}
|
|
@@ -81,16 +87,42 @@
|
|
|
81
87
|
<script>
|
|
82
88
|
function buildQueryParametersHtml(parameters) {
|
|
83
89
|
let htmlString = '';
|
|
90
|
+
var qsParams = new URLSearchParams(location.search);
|
|
84
91
|
parameters.forEach(param => {
|
|
92
|
+
const value = escapeHtml(qsParams.get(param.name) || '');
|
|
85
93
|
if (param.type === 'text') {
|
|
86
|
-
htmlString +=
|
|
94
|
+
htmlString += `
|
|
95
|
+
<label for="qp_${param.name}">${param.label}</label>
|
|
96
|
+
<input type="text" id="qp_${param.name}" name="qp_${param.name}" value="${value}">
|
|
97
|
+
`;
|
|
87
98
|
} else if (param.type === 'textarea') {
|
|
88
|
-
htmlString +=
|
|
99
|
+
htmlString += `
|
|
100
|
+
<label for="qp_${param.name}">${param.label}</label>
|
|
101
|
+
<textarea id="qp_${param.name}" name="qp_${param.name}">${value}</textarea>
|
|
102
|
+
`;
|
|
103
|
+
} else if (param.type === 'hidden') {
|
|
104
|
+
htmlString += `
|
|
105
|
+
<span></span>
|
|
106
|
+
<input type="hidden" id="qp_${param.name}" name="qp_${param.name}" value="${value}">
|
|
107
|
+
`;
|
|
89
108
|
}
|
|
90
109
|
});
|
|
91
110
|
return htmlString;
|
|
92
111
|
}
|
|
93
112
|
|
|
113
|
+
function escapeHtml(str) {
|
|
114
|
+
return str.replace(/[&<>"']/g, function(match) {
|
|
115
|
+
switch (match) {
|
|
116
|
+
case '&': return '&';
|
|
117
|
+
case '<': return '<';
|
|
118
|
+
case '>': return '>';
|
|
119
|
+
case '"': return '"';
|
|
120
|
+
case "'": return ''';
|
|
121
|
+
default: return match;
|
|
122
|
+
}
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
|
|
94
126
|
const sqlTextArea = document.querySelector('textarea[name="sql"]');
|
|
95
127
|
const queryParametersArea = document.getElementById('query-parameters-area');
|
|
96
128
|
let lastRequestTime = 0;
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.1
|
|
2
2
|
Name: datasette-write
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.4
|
|
4
4
|
Summary: Datasette plugin providing a UI for writing to a database
|
|
5
5
|
Home-page: https://github.com/simonw/datasette-write
|
|
6
6
|
Author: Simon Willison
|
|
@@ -14,6 +14,7 @@ Provides-Extra: test
|
|
|
14
14
|
Requires-Dist: pytest; extra == "test"
|
|
15
15
|
Requires-Dist: pytest-asyncio; extra == "test"
|
|
16
16
|
Requires-Dist: httpx; extra == "test"
|
|
17
|
+
Requires-Dist: beautifulsoup4; extra == "test"
|
|
17
18
|
|
|
18
19
|
# datasette-write
|
|
19
20
|
|
|
@@ -32,13 +33,13 @@ pip install datasette-write
|
|
|
32
33
|
```
|
|
33
34
|
## Usage
|
|
34
35
|
|
|
35
|
-
Having installed the plugin, visit
|
|
36
|
+
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.
|
|
36
37
|
|
|
37
38
|
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.
|
|
38
39
|
|
|
39
40
|
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.
|
|
40
41
|
|
|
41
|
-
Pass `?sql=...` in the query string to pre-populate the SQL editor with a query.
|
|
42
|
+
Pass `?sql=...` in the query string to pre-populate the SQL editor with a query.
|
|
42
43
|
|
|
43
44
|
## Parameterized queries
|
|
44
45
|
|
|
@@ -47,10 +48,16 @@ SQL queries can include parameters like this:
|
|
|
47
48
|
insert into news (title, body)
|
|
48
49
|
values (:title, :body_textarea)
|
|
49
50
|
```
|
|
50
|
-
These will be converted into form fields on the
|
|
51
|
+
These will be converted into form fields on the `/db/-/write` page.
|
|
51
52
|
|
|
52
53
|
If a parameter name ends with `_textarea` it will be rendered as a multi-line textarea instead of a text input.
|
|
53
54
|
|
|
55
|
+
If a parameter name ends with `_hidden` it will be rendered as a hidden input.
|
|
56
|
+
|
|
57
|
+
## Updating rows with SQL
|
|
58
|
+
|
|
59
|
+
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.
|
|
60
|
+
|
|
54
61
|
## Development
|
|
55
62
|
|
|
56
63
|
To set up this plugin locally, first checkout the code. Then create a new virtual environment:
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
from setuptools import setup
|
|
2
2
|
import os
|
|
3
3
|
|
|
4
|
-
VERSION = "0.
|
|
4
|
+
VERSION = "0.4"
|
|
5
5
|
|
|
6
6
|
|
|
7
7
|
def get_long_description():
|
|
@@ -34,5 +34,5 @@ setup(
|
|
|
34
34
|
},
|
|
35
35
|
entry_points={"datasette": ["write = datasette_write"]},
|
|
36
36
|
install_requires=["datasette>=0.64.6"],
|
|
37
|
-
extras_require={"test": ["pytest", "pytest-asyncio", "httpx"]},
|
|
37
|
+
extras_require={"test": ["pytest", "pytest-asyncio", "httpx", "beautifulsoup4"]},
|
|
38
38
|
)
|
|
@@ -0,0 +1,315 @@
|
|
|
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 "<strong>Tables</strong>:" in response.text
|
|
53
|
+
assert '<a href="/test/one">one</a>' in response.text
|
|
54
|
+
|
|
55
|
+
# Should have database action menu option too:
|
|
56
|
+
anon_response = (await ds.client.get("/test")).text
|
|
57
|
+
fragment = '<a href="/test/-/write">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</textarea>' 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 '<input type="hidden" name="_redirect_to"' in response.text
|
|
213
|
+
csrftoken = response.cookies["ds_csrftoken"]
|
|
214
|
+
cookies["ds_csrftoken"] = csrftoken
|
|
215
|
+
data = {
|
|
216
|
+
"sql": "select 1",
|
|
217
|
+
"csrftoken": csrftoken,
|
|
218
|
+
"_redirect_to": signed_redirect_to,
|
|
219
|
+
}
|
|
220
|
+
# POSTing this should redirect to / if signed_redirect_to is valid
|
|
221
|
+
response2 = await ds.client.post(
|
|
222
|
+
"/test/-/write",
|
|
223
|
+
data=data,
|
|
224
|
+
cookies=cookies,
|
|
225
|
+
)
|
|
226
|
+
assert response2.status_code == 302
|
|
227
|
+
actual_redirect_to = response2.headers["location"]
|
|
228
|
+
assert actual_redirect_to == "/" if valid else "/test/-/write"
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
@pytest.mark.asyncio
|
|
232
|
+
@pytest.mark.parametrize("scenario", ("valid", "invalid", "none"))
|
|
233
|
+
async def test_title(ds, scenario):
|
|
234
|
+
cookies = {"ds_actor": ds.sign({"a": {"id": "root"}}, "actor")}
|
|
235
|
+
signed_title = ds.sign("Custom Title", "query_title")
|
|
236
|
+
params = {}
|
|
237
|
+
if scenario != "none":
|
|
238
|
+
params["_title"] = signed_title + ("" if scenario == "valid" else "invalid")
|
|
239
|
+
response = await ds.client.get(
|
|
240
|
+
"/test/-/write",
|
|
241
|
+
params=params,
|
|
242
|
+
cookies=cookies,
|
|
243
|
+
)
|
|
244
|
+
assert response.status_code == 200
|
|
245
|
+
if scenario == "valid":
|
|
246
|
+
assert "<title>Custom Title</title>" in response.text
|
|
247
|
+
# <details><summary only if custom title is set
|
|
248
|
+
assert "<summary>SQL query</summary>" in response.text
|
|
249
|
+
else:
|
|
250
|
+
assert "<title>Write to test with SQL</title>" in response.text
|
|
251
|
+
assert "<summary>SQL query</summary>" 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
|
|
@@ -1,203 +0,0 @@
|
|
|
1
|
-
from datasette import hookimpl, Forbidden, Response
|
|
2
|
-
from datasette.utils import derive_named_parameters
|
|
3
|
-
from urllib.parse import urlencode
|
|
4
|
-
import re
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
async def write(request, datasette):
|
|
8
|
-
if not await datasette.permission_allowed(
|
|
9
|
-
request.actor, "datasette-write", default=False
|
|
10
|
-
):
|
|
11
|
-
raise Forbidden("Permission denied for datasette-write")
|
|
12
|
-
databases = [
|
|
13
|
-
db
|
|
14
|
-
for db in datasette.databases.values()
|
|
15
|
-
if db.is_mutable and db.name != "_internal"
|
|
16
|
-
]
|
|
17
|
-
if request.method == "GET":
|
|
18
|
-
selected_database = request.args.get("database") or ""
|
|
19
|
-
if not selected_database or selected_database == "_internal":
|
|
20
|
-
selected_database = databases[0].name
|
|
21
|
-
database = datasette.get_database(selected_database)
|
|
22
|
-
tables = await database.table_names()
|
|
23
|
-
views = await database.view_names()
|
|
24
|
-
sql = request.args.get("sql") or ""
|
|
25
|
-
return Response.html(
|
|
26
|
-
await datasette.render_template(
|
|
27
|
-
"datasette_write.html",
|
|
28
|
-
{
|
|
29
|
-
"databases": databases,
|
|
30
|
-
"sql_from_args": sql,
|
|
31
|
-
"selected_database": selected_database,
|
|
32
|
-
"parameters": await derive_parameters(database, sql),
|
|
33
|
-
"tables": tables,
|
|
34
|
-
"views": views,
|
|
35
|
-
},
|
|
36
|
-
request=request,
|
|
37
|
-
)
|
|
38
|
-
)
|
|
39
|
-
elif request.method == "POST":
|
|
40
|
-
formdata = await request.post_vars()
|
|
41
|
-
database_name = formdata["database"]
|
|
42
|
-
sql = formdata["sql"]
|
|
43
|
-
try:
|
|
44
|
-
database = [db for db in databases if db.name == database_name][0]
|
|
45
|
-
except IndexError:
|
|
46
|
-
return Response.html("Database not found", status_code=404)
|
|
47
|
-
|
|
48
|
-
result = None
|
|
49
|
-
message = None
|
|
50
|
-
params = {
|
|
51
|
-
key[3:]: value for key, value in formdata.items() if key.startswith("qp_")
|
|
52
|
-
}
|
|
53
|
-
print(params)
|
|
54
|
-
try:
|
|
55
|
-
result = await database.execute_write(sql, params, block=True)
|
|
56
|
-
if result.rowcount == -1:
|
|
57
|
-
# Maybe it was a create table / create view?
|
|
58
|
-
name_verb_type = parse_create_alter_drop_sql(sql)
|
|
59
|
-
if name_verb_type:
|
|
60
|
-
name, verb, type = name_verb_type
|
|
61
|
-
message = "{verb} {type}: {name}".format(
|
|
62
|
-
name=name,
|
|
63
|
-
type=type,
|
|
64
|
-
verb={
|
|
65
|
-
"create": "Created",
|
|
66
|
-
"drop": "Dropped",
|
|
67
|
-
"alter": "Altered",
|
|
68
|
-
}[verb],
|
|
69
|
-
)
|
|
70
|
-
else:
|
|
71
|
-
message = "Query executed"
|
|
72
|
-
else:
|
|
73
|
-
message = "{} row{} affected".format(
|
|
74
|
-
result.rowcount, "" if result.rowcount == 1 else "s"
|
|
75
|
-
)
|
|
76
|
-
except Exception as e:
|
|
77
|
-
message = str(e)
|
|
78
|
-
datasette.add_message(
|
|
79
|
-
request,
|
|
80
|
-
message,
|
|
81
|
-
type=datasette.INFO if result else datasette.ERROR,
|
|
82
|
-
)
|
|
83
|
-
return Response.redirect(
|
|
84
|
-
datasette.urls.path("/-/write?")
|
|
85
|
-
+ urlencode(
|
|
86
|
-
{
|
|
87
|
-
"database": database.name,
|
|
88
|
-
"sql": sql,
|
|
89
|
-
}
|
|
90
|
-
)
|
|
91
|
-
)
|
|
92
|
-
else:
|
|
93
|
-
return Response.html("Bad method", status_code=405)
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
async def derive_parameters(db, sql):
|
|
97
|
-
parameters = await derive_named_parameters(db, sql)
|
|
98
|
-
return [
|
|
99
|
-
{
|
|
100
|
-
"name": parameter,
|
|
101
|
-
"type": "textarea" if parameter.endswith("textarea") else "text",
|
|
102
|
-
"label": (
|
|
103
|
-
parameter.replace("_textarea", "")
|
|
104
|
-
if parameter.endswith("textarea")
|
|
105
|
-
else parameter
|
|
106
|
-
),
|
|
107
|
-
}
|
|
108
|
-
for parameter in parameters
|
|
109
|
-
]
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
async def write_derive_parameters(datasette, request):
|
|
113
|
-
if not await datasette.permission_allowed(
|
|
114
|
-
request.actor, "datasette-write", default=False
|
|
115
|
-
):
|
|
116
|
-
raise Forbidden("Permission denied for datasette-write")
|
|
117
|
-
try:
|
|
118
|
-
db = datasette.get_database(request.args.get("database"))
|
|
119
|
-
except KeyError:
|
|
120
|
-
db = datasette.get_database()
|
|
121
|
-
parameters = await derive_parameters(db, request.args.get("sql") or "")
|
|
122
|
-
return Response.json({"parameters": parameters})
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
@hookimpl
|
|
126
|
-
def register_routes():
|
|
127
|
-
return [
|
|
128
|
-
(r"^/-/write$", write),
|
|
129
|
-
(r"^/-/write/derive-parameters$", write_derive_parameters),
|
|
130
|
-
]
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
@hookimpl
|
|
134
|
-
def permission_allowed(actor, action):
|
|
135
|
-
if action == "datasette-write" and actor and actor.get("id") == "root":
|
|
136
|
-
return True
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
@hookimpl
|
|
140
|
-
def menu_links(datasette, actor):
|
|
141
|
-
async def inner():
|
|
142
|
-
if await datasette.permission_allowed(actor, "datasette-write", default=False):
|
|
143
|
-
return [
|
|
144
|
-
{
|
|
145
|
-
"href": datasette.urls.path("/-/write"),
|
|
146
|
-
"label": "Execute SQL write",
|
|
147
|
-
},
|
|
148
|
-
]
|
|
149
|
-
|
|
150
|
-
return inner
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
@hookimpl
|
|
154
|
-
def database_actions(datasette, actor, database):
|
|
155
|
-
async def inner():
|
|
156
|
-
if database != "_internal" and await datasette.permission_allowed(
|
|
157
|
-
actor, "datasette-write", default=False
|
|
158
|
-
):
|
|
159
|
-
return [
|
|
160
|
-
{
|
|
161
|
-
"href": datasette.urls.path(
|
|
162
|
-
"/-/write?"
|
|
163
|
-
+ urlencode(
|
|
164
|
-
{
|
|
165
|
-
"database": database,
|
|
166
|
-
}
|
|
167
|
-
)
|
|
168
|
-
),
|
|
169
|
-
"label": "Execute SQL write",
|
|
170
|
-
"description": "Run queries like insert/update/delete against this database",
|
|
171
|
-
},
|
|
172
|
-
]
|
|
173
|
-
|
|
174
|
-
return inner
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
_name_patterns = (
|
|
178
|
-
r"\[([^\]]+)\]", # create table [foo]
|
|
179
|
-
r'"([^"]+)"', # create table "foo"
|
|
180
|
-
r"'([^']+)'", # create table 'foo'
|
|
181
|
-
r"([a-zA-Z_][a-zA-Z0-9_]*)", # create table foo123
|
|
182
|
-
)
|
|
183
|
-
_res = []
|
|
184
|
-
for type in ("table", "view"):
|
|
185
|
-
for name_pattern in _name_patterns:
|
|
186
|
-
for verb in ("create", "drop"):
|
|
187
|
-
pattern = r"\s*{}\s+{}\s+{}.*".format(verb, type, name_pattern)
|
|
188
|
-
_res.append((type, verb, re.compile(pattern, re.I)))
|
|
189
|
-
alter_table_pattern = r"\s*alter\s+table\s+{}.*".format(name_pattern)
|
|
190
|
-
_res.append(("table", "alter", re.compile(alter_table_pattern, re.I)))
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
def parse_create_alter_drop_sql(sql):
|
|
194
|
-
"""
|
|
195
|
-
Simple regex-based detection of 'create table foo' type queries
|
|
196
|
-
|
|
197
|
-
Returns the view or table name, or None if none was identified
|
|
198
|
-
"""
|
|
199
|
-
for type, verb, _re in _res:
|
|
200
|
-
match = _re.match(sql)
|
|
201
|
-
if match is not None:
|
|
202
|
-
return match.group(1), verb, type
|
|
203
|
-
return None
|
|
@@ -1,173 +0,0 @@
|
|
|
1
|
-
from datasette.app import Datasette
|
|
2
|
-
from datasette_write import parse_create_alter_drop_sql
|
|
3
|
-
import pytest
|
|
4
|
-
import sqlite3
|
|
5
|
-
import urllib
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
@pytest.fixture
|
|
9
|
-
def ds(tmp_path_factory):
|
|
10
|
-
db_directory = tmp_path_factory.mktemp("dbs")
|
|
11
|
-
db_path = str(db_directory / "test.db")
|
|
12
|
-
db_path2 = str(db_directory / "test2.db")
|
|
13
|
-
sqlite3.connect(db_path).executescript(
|
|
14
|
-
"""
|
|
15
|
-
create table one (id integer primary key, count integer);
|
|
16
|
-
insert into one (id, count) values (1, 10);
|
|
17
|
-
insert into one (id, count) values (2, 20);
|
|
18
|
-
"""
|
|
19
|
-
)
|
|
20
|
-
sqlite3.connect(db_path2).execute("vacuum")
|
|
21
|
-
ds = Datasette([db_path, db_path2])
|
|
22
|
-
return ds
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
@pytest.mark.asyncio
|
|
26
|
-
async def test_permission_denied(ds):
|
|
27
|
-
response = await ds.client.get("/-/write")
|
|
28
|
-
assert 403 == response.status_code
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
@pytest.mark.asyncio
|
|
32
|
-
async def test_permission_granted_to_root(ds):
|
|
33
|
-
response = await ds.client.get(
|
|
34
|
-
"/-/write",
|
|
35
|
-
cookies={"ds_actor": ds.sign({"a": {"id": "root"}}, "actor")},
|
|
36
|
-
)
|
|
37
|
-
assert response.status_code == 200
|
|
38
|
-
assert "<strong>Tables</strong>:" in response.text
|
|
39
|
-
assert '<a href="/test/one">one</a>' in response.text
|
|
40
|
-
|
|
41
|
-
# Should have database action menu option too:
|
|
42
|
-
anon_response = (await ds.client.get("/test")).text
|
|
43
|
-
fragment = ">Execute SQL write<"
|
|
44
|
-
assert fragment not in anon_response
|
|
45
|
-
root_response = (
|
|
46
|
-
await ds.client.get(
|
|
47
|
-
"/test", cookies={"ds_actor": ds.sign({"a": {"id": "root"}}, "actor")}
|
|
48
|
-
)
|
|
49
|
-
).text
|
|
50
|
-
assert fragment in root_response
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
@pytest.mark.asyncio
|
|
54
|
-
@pytest.mark.parametrize("database", ["test", "test2"])
|
|
55
|
-
async def test_select_database(ds, database):
|
|
56
|
-
response = await ds.client.get(
|
|
57
|
-
"/-/write?database={}".format(database),
|
|
58
|
-
cookies={"ds_actor": ds.sign({"a": {"id": "root"}}, "actor")},
|
|
59
|
-
)
|
|
60
|
-
assert response.status_code == 200
|
|
61
|
-
assert '<option selected="selected">{}</option>'.format(database) in response.text
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
@pytest.mark.asyncio
|
|
65
|
-
async def test_populate_sql_from_query_string(ds):
|
|
66
|
-
response = await ds.client.get(
|
|
67
|
-
"/-/write?sql=select+1",
|
|
68
|
-
cookies={"ds_actor": ds.sign({"a": {"id": "root"}}, "actor")},
|
|
69
|
-
)
|
|
70
|
-
assert response.status_code == 200
|
|
71
|
-
assert '">select 1</textarea>' in response.text
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
@pytest.mark.parametrize(
|
|
75
|
-
"database,sql,params,expected_message",
|
|
76
|
-
[
|
|
77
|
-
(
|
|
78
|
-
"test",
|
|
79
|
-
"create table newtable (id integer)",
|
|
80
|
-
{},
|
|
81
|
-
"Created table: newtable",
|
|
82
|
-
),
|
|
83
|
-
(
|
|
84
|
-
"test",
|
|
85
|
-
"drop table one",
|
|
86
|
-
{},
|
|
87
|
-
"Dropped table: one",
|
|
88
|
-
),
|
|
89
|
-
(
|
|
90
|
-
"test",
|
|
91
|
-
"alter table one add column bigfile blob",
|
|
92
|
-
{},
|
|
93
|
-
"Altered table: one",
|
|
94
|
-
),
|
|
95
|
-
(
|
|
96
|
-
"test2",
|
|
97
|
-
"create table newtable (id integer)",
|
|
98
|
-
{},
|
|
99
|
-
"Created table: newtable",
|
|
100
|
-
),
|
|
101
|
-
(
|
|
102
|
-
"test2",
|
|
103
|
-
"create view blah as select 1 + 1",
|
|
104
|
-
{},
|
|
105
|
-
"Created view: blah",
|
|
106
|
-
),
|
|
107
|
-
("test", "update one set count = 5", {}, "2 rows affected"),
|
|
108
|
-
("test", "invalid sql", {}, 'near "invalid": syntax error'),
|
|
109
|
-
# Parameterized queries
|
|
110
|
-
("test", "update one set count = :count", {"qp_count": 4}, "2 rows affected"),
|
|
111
|
-
# This should error
|
|
112
|
-
(
|
|
113
|
-
"test",
|
|
114
|
-
"update one set count = :count",
|
|
115
|
-
{},
|
|
116
|
-
"Incorrect number of bindings supplied. The current statement uses 1, and there are 0 supplied.",
|
|
117
|
-
),
|
|
118
|
-
],
|
|
119
|
-
)
|
|
120
|
-
@pytest.mark.asyncio
|
|
121
|
-
async def test_execute_write(ds, database, sql, params, expected_message):
|
|
122
|
-
# Get csrftoken
|
|
123
|
-
cookies = {"ds_actor": ds.sign({"a": {"id": "root"}}, "actor")}
|
|
124
|
-
response = await ds.client.get("/-/write", cookies=cookies)
|
|
125
|
-
assert 200 == response.status_code
|
|
126
|
-
csrftoken = response.cookies["ds_csrftoken"]
|
|
127
|
-
cookies["ds_csrftoken"] = csrftoken
|
|
128
|
-
data = {
|
|
129
|
-
"sql": sql,
|
|
130
|
-
"csrftoken": csrftoken,
|
|
131
|
-
"database": database,
|
|
132
|
-
}
|
|
133
|
-
data.update(params)
|
|
134
|
-
# write to database
|
|
135
|
-
response2 = await ds.client.post(
|
|
136
|
-
"/-/write",
|
|
137
|
-
data=data,
|
|
138
|
-
cookies=cookies,
|
|
139
|
-
)
|
|
140
|
-
messages = [m[0] for m in ds.unsign(response2.cookies["ds_messages"], "messages")]
|
|
141
|
-
assert messages[0] == expected_message
|
|
142
|
-
# Should have preserved ?database= in redirect:
|
|
143
|
-
bits = dict(urllib.parse.parse_qsl(response2.headers["location"].split("?")[-1]))
|
|
144
|
-
assert bits["database"] == database
|
|
145
|
-
# Should have preserved ?sql= in redirect:
|
|
146
|
-
assert bits["sql"] == sql
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
@pytest.mark.parametrize(
|
|
150
|
-
"sql,expected_name,expected_verb,expected_type",
|
|
151
|
-
(
|
|
152
|
-
("create table hello (...", "hello", "create", "table"),
|
|
153
|
-
(" create view hello2 as (...", "hello2", "create", "view"),
|
|
154
|
-
("select 1 + 1", None, None, None),
|
|
155
|
-
# Various styles of quoting
|
|
156
|
-
("create table 'hello' (", "hello", "create", "table"),
|
|
157
|
-
(' create \n table "hello" (', "hello", "create", "table"),
|
|
158
|
-
("create table [hello] (", "hello", "create", "table"),
|
|
159
|
-
("create view 'hello' (", "hello", "create", "view"),
|
|
160
|
-
(' create \n view "hello" (', "hello", "create", "view"),
|
|
161
|
-
("create view [hello] (", "hello", "create", "view"),
|
|
162
|
-
# Alter table
|
|
163
|
-
("alter table [hello] ", "hello", "alter", "table"),
|
|
164
|
-
# But no alter view
|
|
165
|
-
("alter view [hello] ", None, None, None),
|
|
166
|
-
),
|
|
167
|
-
)
|
|
168
|
-
def test_parse_create_alter_drop_sql(sql, expected_name, expected_verb, expected_type):
|
|
169
|
-
name_verb_type = parse_create_alter_drop_sql(sql)
|
|
170
|
-
if expected_name is None:
|
|
171
|
-
assert name_verb_type is None
|
|
172
|
-
else:
|
|
173
|
-
assert name_verb_type == (expected_name, expected_verb, expected_type)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|