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.

@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: datasette-write
3
- Version: 0.3.1
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 `/-/write` on your Datasette instance to submit SQL queries that will be executed against a write connection to the specified database.
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. Pass `?database=...` to specify a database to run the query against.
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 `/-/write` page.
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 `/-/write` on your Datasette instance to submit SQL queries that will be executed against a write connection to the specified database.
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. Pass `?database=...` to specify a database to run the query against.
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 `/-/write` page.
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
@@ -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: 8em;
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 the database with SQL</h1>
44
+ <h1>{% if custom_title %}{{ custom_title }}{% else %}Write to {{ database_name }} with SQL{% endif %}</h1>
41
45
 
42
- <form class="write-form" action="{{ base_url }}-/write" method="post" style="margin-bottom: 1em">
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
- <p class="database-select"><label>Database: <select name="database">{% for database in databases %}
45
- <option{% if database.name == selected_database %} selected="selected"{% endif %}>{{ database.name }}</option>
46
- {% endfor %}</select></label></p>
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 }}"></textarea>
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(selected_database, table) }}">{{ table }}</a>{% if not loop.last %}, {% endif %}
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(selected_database, view) }}">{{ view }}</a>{% if not loop.last %}, {% endif %}
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 += `<label for="qp_${param.name}">${param.label}</label> <input type="text" id="qp_${param.name}" name="qp_${param.name}" value="">\n`;
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 += `<label for="qp_${param.name}">${param.label}</label> <textarea id="qp_${param.name}" name="qp_${param.name}"></textarea>\n`;
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 '&amp;';
117
+ case '<': return '&lt;';
118
+ case '>': return '&gt;';
119
+ case '"': return '&quot;';
120
+ case "'": return '&#39;';
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.1
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 `/-/write` on your Datasette instance to submit SQL queries that will be executed against a write connection to the specified database.
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. Pass `?database=...` to specify a database to run the query against.
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 `/-/write` page.
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:
@@ -4,3 +4,4 @@ datasette>=0.64.6
4
4
  pytest
5
5
  pytest-asyncio
6
6
  httpx
7
+ beautifulsoup4
@@ -1,7 +1,7 @@
1
1
  from setuptools import setup
2
2
  import os
3
3
 
4
- VERSION = "0.3.1"
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