diff --git a/datasette/app.py b/datasette/app.py index 139e4c34..79dffb66 100644 --- a/datasette/app.py +++ b/datasette/app.py @@ -47,9 +47,14 @@ from .views import Context from .views.database import ( database_download, DatabaseView, - TableCreateView, QueryView, ) +from .views.table_create_alter import ( + DatabaseForeignKeyTargetsView, + TableAlterView, + TableCreateView, + TableForeignKeySuggestionsView, +) from .views.execute_write import ExecuteWriteAnalyzeView, ExecuteWriteView from .views.stored_queries import ( QueryCreateAnalyzeView, @@ -2562,6 +2567,10 @@ class Datasette: r"/(?P[^\/\.]+)(\.(?P\w+))?$", ) add_route(TableCreateView.as_view(self), r"/(?P[^\/\.]+)/-/create$") + add_route( + DatabaseForeignKeyTargetsView.as_view(self), + r"/(?P[^\/\.]+)/-/foreign-key-targets$", + ) add_route( QueryListView.as_view(self), r"/(?P[^\/\.]+)/-/queries(\.(?Pjson))?$", @@ -2626,6 +2635,14 @@ class Datasette: TableUpsertView.as_view(self), r"/(?P[^\/\.]+)/(?P[^\/\.]+)/-/upsert$", ) + add_route( + TableAlterView.as_view(self), + r"/(?P[^\/\.]+)/(?P
[^\/\.]+)/-/alter$", + ) + add_route( + TableForeignKeySuggestionsView.as_view(self), + r"/(?P[^\/\.]+)/(?P
[^\/\.]+)/-/foreign-key-suggestions$", + ) add_route( TableSetColumnTypeView.as_view(self), r"/(?P[^\/\.]+)/(?P
[^\/\.]+)/-/set-column-type$", diff --git a/datasette/default_table_actions.py b/datasette/default_table_actions.py new file mode 100644 index 00000000..e41434ef --- /dev/null +++ b/datasette/default_table_actions.py @@ -0,0 +1,29 @@ +from datasette import hookimpl +from datasette.resources import TableResource + + +@hookimpl +def table_actions(datasette, actor, database, table, request): + async def inner(): + db = datasette.get_database(database) + if not db.is_mutable: + return [] + if not await datasette.allowed( + action="alter-table", + resource=TableResource(database=database, table=table), + actor=actor, + ): + return [] + return [ + { + "type": "button", + "label": "Alter table", + "description": "Change columns and primary key for this table.", + "attrs": { + "aria-label": "Alter table {}".format(table), + "data-table-action": "alter-table", + }, + } + ] + + return inner diff --git a/datasette/plugins.py b/datasette/plugins.py index f0fbc7f8..ae2cb17d 100644 --- a/datasette/plugins.py +++ b/datasette/plugins.py @@ -31,6 +31,7 @@ DEFAULT_PLUGINS = ( "datasette.default_debug_menu", "datasette.default_jump_items", "datasette.default_database_actions", + "datasette.default_table_actions", "datasette.default_query_actions", "datasette.handle_exception", "datasette.forbidden", diff --git a/datasette/static/app.css b/datasette/static/app.css index 5fe4502d..f7cd97b0 100644 --- a/datasette/static/app.css +++ b/datasette/static/app.css @@ -1749,6 +1749,986 @@ datasette-autocomplete input[type="text"], cursor: not-allowed; } +dialog.table-create-dialog { + --ink: #0f0f0f; + --paper: #eef6ff; + --muted: #6b6b6b; + --rule: #d8e6f5; + --accent: #1a56db; + --card: #ffffff; + border: none; + border-radius: var(--modal-border-radius, 0.75rem); + padding: 0; + margin: auto; + width: min(980px, calc(100vw - 32px)); + max-width: 95vw; + max-height: min(780px, calc(100vh - 32px)); + box-shadow: var(--modal-shadow, 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04)); + animation: datasette-modal-slide-in var(--modal-animation-duration, 0.2s) ease-out; + overflow: hidden; + font-family: system-ui, -apple-system, sans-serif; + background: var(--card); +} + +dialog.table-create-dialog[open] { + display: flex; + flex-direction: column; +} + +dialog.table-create-dialog::backdrop { + background: var(--modal-backdrop-bg, rgba(0, 0, 0, 0.5)); + backdrop-filter: var(--modal-backdrop-blur, blur(4px)); + -webkit-backdrop-filter: var(--modal-backdrop-blur, blur(4px)); + animation: datasette-modal-fade-in var(--modal-animation-duration, 0.2s) ease-out; +} + +.table-create-dialog .modal-header { + padding: 20px 24px 12px; + border-bottom: 1px solid var(--rule); + display: flex; + align-items: center; + gap: 12px; + flex-shrink: 0; + min-width: 0; +} + +.table-create-dialog .modal-title { + display: flex; + align-items: center; + min-width: 0; + max-width: 100%; + font-size: 1rem; + font-weight: 600; + color: var(--ink); +} + +.table-create-form { + display: flex; + flex: 1 1 auto; + min-height: 0; + flex-direction: column; +} + +.table-create-error { + border-left: 4px solid #b91c1c; + border-radius: 4px; + background: #fff1f1; + color: #7f1d1d; + font-size: 0.9rem; + margin: 12px 24px 0; + padding: 10px 12px; +} + +.table-create-error:focus { + outline: 3px solid rgba(185, 28, 28, 0.18); + outline-offset: 2px; +} + +.table-create-fields { + display: grid; + gap: 18px; + padding: 16px 24px 24px; + overflow-y: auto; +} + +.table-create-field { + display: grid; + grid-template-columns: minmax(120px, 180px) minmax(0, 1fr); + gap: 12px; + align-items: start; +} + +.table-create-label, +.table-create-column-headings { + color: var(--ink); + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + font-size: 0.82rem; +} + +.table-create-label { + padding-top: 8px; +} + +.table-create-column-label { + border: 0; + clip: rect(0 0 0 0); + height: 1px; + margin: -1px; + overflow: hidden; + padding: 0; + position: absolute; + white-space: nowrap; + width: 1px; +} + +.table-create-input { + box-sizing: border-box; + min-width: 0; + min-height: 46px; + border: 1px solid var(--rule); + border-radius: 5px; + padding: 8px 10px; + color: var(--ink); + background: #fff; + font: inherit; + line-height: 1.35; +} + +select.table-create-input { + height: 46px; +} + +.table-create-input-placeholder { + color: var(--muted); +} + +.table-create-foreign-key-target option, +.table-create-custom-column-type option, +.table-create-default-expr option { + color: var(--ink); +} + +.table-create-foreign-key-target option[value=""], +.table-create-custom-column-type option[value=""], +.table-create-default-expr option[value=""] { + color: var(--muted); +} + +.table-create-table-name { + width: 100%; +} + +.table-create-input:focus { + border-color: var(--accent); + outline: 3px solid rgba(26, 86, 219, 0.12); +} + +.table-create-columns { + display: grid; + gap: 10px; +} + +.table-create-column-list { + display: grid; + gap: 8px; +} + +.table-create-column-headings, +.table-create-column-main { + display: grid; + grid-template-columns: minmax(140px, 1.2fr) minmax(7.5rem, 0.7fr) max-content 32px; + align-items: center; + gap: 8px; + min-width: 0; +} + +.table-create-column-row { + display: grid; + gap: 8px; + min-width: 0; +} + +.table-create-column-headings { + color: var(--muted); + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + font-size: 0.72rem; + padding: 0 1px; +} + +.table-create-column-details { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); + align-items: start; + gap: 12px 16px; + padding: 12px; + border-left: 3px solid var(--rule); + background: #f8fafc; +} + +.table-create-column-details[hidden] { + display: none; +} + +.table-create-detail-field { + display: grid; + gap: 4px; + min-width: 0; +} + +.table-create-detail-label { + color: var(--muted); + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + font-size: 0.72rem; +} + +.table-create-detail-help, +.table-alter-detail-help { + color: var(--muted); + font-size: 0.82rem; + line-height: 1.35; + margin: 0; +} + +.table-create-detail-check { + display: inline-flex; + align-items: flex-start; + gap: 8px; + color: var(--ink); + font-size: 0.85rem; + line-height: 1.35; + min-width: 0; + white-space: normal; +} + +.table-create-not-null, +.table-create-primary-key, +.table-create-foreign-key-field, +.table-create-default-options { + grid-column: 1 / -1; +} + +.table-create-default-options, +.table-alter-default-options { + border: 1px solid var(--rule); + border-radius: 5px; + background: #fff; + color: var(--ink); + min-width: 0; +} + +.table-create-default-options > summary, +.table-alter-default-options > summary { + cursor: pointer; + color: var(--accent); + font-size: 0.85rem; + padding: 8px 10px; +} + +.table-create-default-options > summary:focus, +.table-alter-default-options > summary:focus { + outline: 3px solid rgba(26, 86, 219, 0.12); + outline-offset: 1px; +} + +.table-create-default-grid, +.table-alter-default-grid { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); + gap: 12px 16px; + padding: 0 10px 10px; +} + +.table-create-detail-check input { + flex: 0 0 auto; + margin: 0.15rem 0 0; +} + +.table-create-detail-check span { + min-width: 0; + overflow-wrap: break-word; +} + +.table-create-move-controls { + display: grid; + grid-template-columns: repeat(4, 32px); + gap: 4px; + justify-content: start; +} + +.table-create-more-options { + appearance: none; + border: 0; + background: transparent; + color: var(--accent); + cursor: pointer; + font: inherit; + font-size: 0.85rem; + justify-self: start; + padding: 0; + grid-column: 1 / -1; + text-align: left; +} + +.table-create-more-options:hover, +.table-create-more-options:focus { + text-decoration: underline; +} + +.table-create-more-options:focus { + outline: 3px solid rgba(26, 86, 219, 0.12); + outline-offset: 2px; +} + +.table-create-more-options:disabled { + color: var(--muted); + cursor: default; + text-decoration: none; +} + +.table-create-icon-button { + appearance: none; + border: 1px solid rgba(74, 85, 104, 0.24); + background: transparent; + color: #4a5568; + border-radius: 4px; + cursor: pointer; + display: inline-grid; + place-items: center; + height: 32px; + width: 32px; + padding: 0; +} + +.table-create-icon-button:hover, +.table-create-icon-button:focus { + background: rgba(74, 85, 104, 0.07); +} + +.table-create-icon-button:focus { + outline: 3px solid #b3d4ff; + outline-offset: 1px; +} + +.table-create-icon-button svg { + display: block; +} + +.table-create-add-column { + appearance: none; + justify-self: start; + border: 1px solid var(--rule); + border-radius: 5px; + background: #fff; + color: var(--accent); + cursor: pointer; + display: inline-flex; + align-items: center; + gap: 6px; + font: inherit; + font-size: 0.85rem; + padding: 7px 10px; +} + +.table-create-add-column svg { + display: block; + flex: 0 0 auto; +} + +.table-create-add-column:hover, +.table-create-add-column:focus { + background: #f8fafc; +} + +.table-create-add-column:focus { + outline: 3px solid rgba(26, 86, 219, 0.12); + outline-offset: 1px; +} + +.table-create-dialog .modal-footer { + padding: 14px 20px; + border-top: 1px solid var(--rule); + display: flex; + align-items: center; + justify-content: flex-end; + gap: 10px; + flex-shrink: 0; + background: var(--paper); +} + +.table-create-dialog .btn { + border: none; + border-radius: 5px; + padding: 9px 20px; + font-size: 0.85rem; + font-weight: 500; + cursor: pointer; + touch-action: manipulation; + font-family: inherit; + transition: background 0.12s; +} + +.table-create-dialog .btn-ghost { + background: transparent; + color: var(--muted); + border: 1px solid var(--rule); +} + +.table-create-dialog .btn-ghost:hover { + background: var(--rule); + color: var(--ink); +} + +.table-create-dialog .btn-primary { + background: var(--accent); + color: #fff; +} + +.table-create-dialog .btn-primary:hover { + background: #1949b8; +} + +.table-create-dialog .btn:disabled, +.table-create-add-column:disabled, +.table-create-icon-button:disabled { + opacity: 0.55; + cursor: not-allowed; +} + +dialog.table-alter-dialog { + --ink: #0f0f0f; + --paper: #eef6ff; + --muted: #6b6b6b; + --rule: #d8e6f5; + --accent: #1a56db; + --card: #ffffff; + border: none; + border-radius: var(--modal-border-radius, 0.75rem); + padding: 0; + margin: auto; + width: min(980px, calc(100vw - 32px)); + max-width: 95vw; + max-height: min(780px, calc(100vh - 32px)); + box-shadow: var(--modal-shadow, 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04)); + animation: datasette-modal-slide-in var(--modal-animation-duration, 0.2s) ease-out; + overflow: hidden; + font-family: system-ui, -apple-system, sans-serif; + background: var(--card); +} + +dialog.table-alter-dialog[open] { + display: flex; + flex-direction: column; +} + +dialog.table-alter-dialog::backdrop { + background: var(--modal-backdrop-bg, rgba(0, 0, 0, 0.5)); + backdrop-filter: var(--modal-backdrop-blur, blur(4px)); + -webkit-backdrop-filter: var(--modal-backdrop-blur, blur(4px)); + animation: datasette-modal-fade-in var(--modal-animation-duration, 0.2s) ease-out; +} + +.table-alter-dialog .modal-header { + padding: 20px 24px 12px; + border-bottom: 1px solid var(--rule); + display: flex; + align-items: center; + gap: 12px; + flex-shrink: 0; + min-width: 0; +} + +.table-alter-dialog .modal-title { + display: flex; + align-items: center; + min-width: 0; + max-width: 100%; + font-size: 1rem; + font-weight: 600; + color: var(--ink); +} + +.table-alter-form { + display: flex; + flex: 1 1 auto; + min-height: 0; + flex-direction: column; +} + +.table-alter-error { + border-left: 4px solid #b91c1c; + border-radius: 4px; + background: #fff1f1; + color: #7f1d1d; + font-size: 0.9rem; + margin: 12px 24px 0; + padding: 10px 12px; +} + +.table-alter-error:focus { + outline: 3px solid rgba(185, 28, 28, 0.18); + outline-offset: 2px; +} + +.table-alter-fields { + display: grid; + gap: 18px; + padding: 16px 24px 24px; + overflow-y: auto; +} + +.table-alter-table-options { + border-top: 1px solid var(--rule); + padding-top: 12px; +} + +.table-alter-table-options > summary { + color: var(--ink); + cursor: pointer; + font-weight: 600; +} + +.table-alter-table-options > summary:focus { + outline: 3px solid rgba(26, 86, 219, 0.12); + outline-offset: 3px; +} + +.table-alter-table-name-field { + display: grid; + gap: 4px; + margin-top: 10px; + max-width: 24rem; +} + +.table-alter-fields[hidden], +.table-alter-dialog .modal-footer [hidden] { + display: none; +} + +.table-alter-review { + display: grid; + gap: 12px; + overflow-y: auto; + padding: 16px 24px 24px; +} + +.table-alter-review[hidden] { + display: none; +} + +.table-alter-review-title { + color: var(--ink); + font-size: 1rem; + line-height: 1.35; + margin: 0; +} + +.table-alter-review-title:focus { + outline: 3px solid rgba(26, 86, 219, 0.12); + outline-offset: 2px; +} + +.table-alter-review-intro { + color: var(--muted); + font-size: 0.9rem; + margin: 0; +} + +.table-alter-review-warning { + border-left: 4px solid #b91c1c; + border-radius: 4px; + background: #fff1f1; + color: #7f1d1d; + font-size: 0.9rem; + margin: 0; + padding: 10px 12px; +} + +.table-alter-review-list { + display: grid; + gap: 8px; + margin: 0; + padding-left: 1.4rem; +} + +.table-alter-review-list li { + color: var(--ink); + line-height: 1.4; +} + +.table-alter-review-damaging { + font-weight: 600; +} + +.table-alter-review-name { + background: #eef6ff; + border: 1px solid #c9ddf2; + border-radius: 4px; + color: var(--ink); + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + font-size: 0.85em; + padding: 1px 4px; + white-space: nowrap; +} + +.table-alter-columns { + display: grid; + gap: 10px; +} + +.table-alter-column-list { + display: grid; + gap: 8px; +} + +.table-alter-column-headings, +.table-alter-column-main { + display: grid; + grid-template-columns: minmax(140px, 1.2fr) minmax(7.5rem, 0.7fr) max-content 32px; + align-items: center; + gap: 8px; + min-width: 0; +} + +.table-alter-column-row { + display: grid; + gap: 8px; + min-width: 0; +} + +.table-alter-column-headings { + color: var(--muted); + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + font-size: 0.72rem; + padding: 0 1px; +} + +.table-alter-column-label { + border: 0; + clip: rect(0 0 0 0); + height: 1px; + margin: -1px; + overflow: hidden; + padding: 0; + position: absolute; + white-space: nowrap; + width: 1px; +} + +.table-alter-input { + box-sizing: border-box; + min-width: 0; + min-height: 46px; + border: 1px solid var(--rule); + border-radius: 5px; + padding: 8px 10px; + color: var(--ink); + background: #fff; + font: inherit; + line-height: 1.35; +} + +select.table-alter-input { + height: 46px; +} + +.table-alter-input-placeholder { + color: var(--muted); +} + +.table-alter-default-expr option, +.table-alter-custom-column-type option, +.table-alter-foreign-key-target option { + color: var(--ink); +} + +.table-alter-default-expr option[value=""], +.table-alter-custom-column-type option[value=""], +.table-alter-foreign-key-target option[value=""] { + color: var(--muted); +} + +.table-alter-input:focus { + border-color: var(--accent); + outline: 3px solid rgba(26, 86, 219, 0.12); +} + +.table-alter-column-details { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); + align-items: start; + gap: 12px 16px; + padding: 12px; + border-left: 3px solid var(--rule); + background: #f8fafc; +} + +.table-alter-column-details[hidden] { + display: none; +} + +.table-alter-detail-field { + display: grid; + gap: 4px; + min-width: 0; +} + +.table-alter-detail-label { + color: var(--muted); + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + font-size: 0.72rem; +} + +.table-alter-detail-check { + display: inline-flex; + align-items: flex-start; + gap: 8px; + color: var(--ink); + font-size: 0.85rem; + line-height: 1.35; + min-width: 0; + white-space: normal; +} + +.table-alter-not-null, +.table-alter-primary-key, +.table-alter-foreign-key-field, +.table-alter-default-options { + grid-column: 1 / -1; +} + +.table-alter-detail-check input { + flex: 0 0 auto; + margin: 0.15rem 0 0; +} + +.table-alter-detail-check span { + min-width: 0; + overflow-wrap: break-word; +} + +.table-alter-move-controls { + display: grid; + grid-template-columns: repeat(4, 32px); + gap: 4px; + justify-content: start; +} + +.table-alter-more-options { + appearance: none; + border: 0; + background: transparent; + color: var(--accent); + cursor: pointer; + font: inherit; + font-size: 0.85rem; + justify-self: start; + padding: 0; + grid-column: 1 / -1; + text-align: left; +} + +.table-alter-more-options:hover, +.table-alter-more-options:focus { + text-decoration: underline; +} + +.table-alter-more-options:focus { + outline: 3px solid rgba(26, 86, 219, 0.12); + outline-offset: 2px; +} + +.table-alter-more-options:disabled { + color: var(--muted); + cursor: default; + text-decoration: none; +} + +.table-alter-icon-button { + appearance: none; + border: 1px solid rgba(74, 85, 104, 0.24); + background: transparent; + color: #4a5568; + border-radius: 4px; + cursor: pointer; + display: inline-grid; + place-items: center; + height: 32px; + width: 32px; + padding: 0; +} + +.table-alter-icon-button:hover, +.table-alter-icon-button:focus { + background: rgba(74, 85, 104, 0.07); +} + +.table-alter-icon-button:focus { + outline: 3px solid #b3d4ff; + outline-offset: 1px; +} + +.table-alter-icon-button svg { + display: block; +} + +.table-alter-add-column { + appearance: none; + justify-self: start; + border: 1px solid var(--rule); + border-radius: 5px; + background: #fff; + color: var(--accent); + cursor: pointer; + display: inline-flex; + align-items: center; + gap: 6px; + font: inherit; + font-size: 0.85rem; + padding: 7px 10px; +} + +.table-alter-add-column svg { + display: block; + flex: 0 0 auto; +} + +.table-alter-add-column:hover, +.table-alter-add-column:focus { + background: #f8fafc; +} + +.table-alter-add-column:focus { + outline: 3px solid rgba(26, 86, 219, 0.12); + outline-offset: 1px; +} + +.table-alter-dialog .modal-footer { + padding: 14px 20px; + border-top: 1px solid var(--rule); + display: flex; + align-items: center; + justify-content: flex-end; + gap: 10px; + flex-shrink: 0; + background: var(--paper); +} + +.table-alter-dialog .btn { + border: none; + border-radius: 5px; + padding: 9px 20px; + font-size: 0.85rem; + font-weight: 500; + cursor: pointer; + touch-action: manipulation; + font-family: inherit; + transition: background 0.12s; +} + +.table-alter-dialog .btn-ghost { + background: transparent; + color: var(--muted); + border: 1px solid var(--rule); +} + +.table-alter-dialog .btn-ghost:hover { + background: var(--rule); + color: var(--ink); +} + +.table-alter-dialog .btn-danger { + background: #b91c1c; + color: #fff; + margin-right: auto; +} + +.table-alter-dialog .btn-danger:hover { + background: #991b1b; +} + +.table-alter-dialog .btn-danger:disabled, +.table-alter-dialog .btn-danger:disabled:hover { + background: #d98c8c; + color: #fff; +} + +.table-alter-dialog .btn-primary { + background: var(--accent); + color: #fff; +} + +.table-alter-dialog .btn-primary:hover { + background: #1949b8; +} + +.table-alter-dialog .btn-primary:disabled, +.table-alter-dialog .btn-primary:disabled:hover { + background: #a0aec0; + color: #fff; +} + +.table-alter-dialog .btn:disabled, +.table-alter-add-column:disabled, +.table-alter-icon-button:disabled { + opacity: 0.55; + cursor: not-allowed; +} + +@media (max-width: 900px) { + dialog.table-alter-dialog { + width: 95vw; + max-height: 85vh; + border-radius: 0.5rem; + } + + .table-alter-dialog .modal-header, + .table-alter-fields, + .table-alter-review { + padding-left: 18px; + padding-right: 18px; + } + + .table-alter-error { + margin-left: 18px; + margin-right: 18px; + } + + .table-alter-column-headings { + display: none; + } + + .table-alter-column-row { + padding-bottom: 8px; + border-bottom: 1px solid var(--rule); + } + + .table-alter-column-main { + grid-template-columns: minmax(0, 1fr) minmax(7.5rem, 0.8fr) 32px; + align-items: end; + } + + .table-alter-column-name { + grid-column: 1; + grid-row: 1; + } + + .table-alter-column-type { + grid-column: 2; + grid-row: 1; + } + + .table-alter-remove-column { + grid-column: 3; + grid-row: 1; + justify-self: end; + } + + .table-alter-move-controls { + grid-column: 1; + grid-row: 2; + justify-self: start; + } + + .table-alter-more-options { + align-self: center; + grid-column: 2 / 4; + grid-row: 2; + } + + .table-alter-column-details { + grid-template-columns: 1fr; + } + + .table-alter-default-grid { + grid-template-columns: 1fr; + } + + .table-alter-dialog .modal-footer { + padding-left: 18px; + padding-right: 18px; + } +} + .row-link-with-actions { display: inline-flex; align-items: center; @@ -1892,6 +2872,87 @@ datasette-autocomplete input[type="text"], padding-right: 18px; } + dialog.table-create-dialog { + width: 95vw; + max-height: 85vh; + border-radius: 0.5rem; + } + + .table-create-dialog .modal-header, + .table-create-fields { + padding-left: 18px; + padding-right: 18px; + } + + .table-create-error { + margin-left: 18px; + margin-right: 18px; + } + + .table-create-field { + grid-template-columns: 1fr; + gap: 5px; + } + + .table-create-label { + padding-top: 0; + } + + .table-create-column-headings { + display: none; + } + + .table-create-column-row { + padding-bottom: 8px; + border-bottom: 1px solid var(--rule); + } + + .table-create-column-main { + grid-template-columns: minmax(0, 1fr) minmax(7.5rem, 0.8fr) 32px; + align-items: end; + } + + .table-create-column-name { + grid-column: 1; + grid-row: 1; + } + + .table-create-column-type { + grid-column: 2; + grid-row: 1; + } + + .table-create-remove-column { + grid-column: 3; + grid-row: 1; + justify-self: end; + } + + .table-create-move-controls { + grid-column: 1; + grid-row: 2; + justify-self: start; + } + + .table-create-more-options { + align-self: center; + grid-column: 2 / 4; + grid-row: 2; + } + + .table-create-column-details { + grid-template-columns: 1fr; + } + + .table-create-default-grid { + grid-template-columns: 1fr; + } + + .table-create-dialog .modal-footer { + padding-left: 18px; + padding-right: 18px; + } + .row-inline-action { min-height: 30px; min-width: 30px; diff --git a/datasette/static/edit-tools.js b/datasette/static/edit-tools.js index 8142d02b..9f4f89b9 100644 --- a/datasette/static/edit-tools.js +++ b/datasette/static/edit-tools.js @@ -2,6 +2,10 @@ var ROW_DELETE_DIALOG_ID = "row-delete-dialog"; var rowDeleteDialogState = null; var ROW_EDIT_DIALOG_ID = "row-edit-dialog"; var rowEditDialogState = null; +var TABLE_CREATE_DIALOG_ID = "table-create-dialog"; +var tableCreateDialogState = null; +var TABLE_ALTER_DIALOG_ID = "table-alter-dialog"; +var tableAlterDialogState = null; function ensureRowMutationStatus(manager) { var status = document.querySelector(".row-mutation-status"); @@ -43,6 +47,1654 @@ function hideRowMutationStatus() { status.textContent = ""; } +function databaseCreateTableData() { + return ( + window._datasetteDatabaseData && window._datasetteDatabaseData.createTable + ); +} + +function tableCreateColumnTypes() { + var data = databaseCreateTableData() || {}; + return data.columnTypes && data.columnTypes.length + ? data.columnTypes + : ["text", "integer", "float", "blob"]; +} + +function tableCreateDefaultExpressions() { + var data = databaseCreateTableData() || {}; + return data.defaultExpressions || []; +} + +var SQLITE_COLUMN_TYPE_LABELS = { + float: "floating point number", + real: "floating point number", + blob: "blob - binary data", +}; + +function sqliteColumnTypeLabel(type) { + return SQLITE_COLUMN_TYPE_LABELS[type] || type; +} + +function populateSqliteColumnTypeSelect(select, type, options) { + options.forEach(function (option) { + var optionElement = document.createElement("option"); + optionElement.value = option; + optionElement.textContent = sqliteColumnTypeLabel(option); + select.appendChild(optionElement); + }); + select.value = options.indexOf(type) === -1 ? options[0] : type; +} + +function updateSelectPlaceholder(select, placeholderClass) { + select.classList.toggle(placeholderClass, !select.value); +} + +function createCustomColumnTypeSelect(options, className, placeholderClass) { + var select = document.createElement("select"); + select.className = className; + select.setAttribute("aria-label", "Custom column type"); + var blankOption = document.createElement("option"); + blankOption.value = ""; + blankOption.textContent = "- custom type -"; + select.appendChild(blankOption); + options.forEach(function (option) { + var optionElement = document.createElement("option"); + optionElement.value = option.name; + optionElement.textContent = option.description + ? option.description + " (" + option.name + ")" + : option.name; + select.appendChild(optionElement); + }); + updateSelectPlaceholder(select, placeholderClass); + return select; +} + +function normalizeDefaultExpressionOption(option) { + if (typeof option === "string") { + return { + value: option, + label: option.replace(/_/g, " "), + sqliteType: "", + }; + } + option = option || {}; + return { + value: option.value || "", + label: option.label || option.value || "", + sqliteType: option.sqliteType || "", + }; +} + +function defaultExpressionOptionForValue(options, value) { + var match = null; + (options || []).some(function (option) { + var normalized = normalizeDefaultExpressionOption(option); + if (normalized.value === value) { + match = normalized; + return true; + } + return false; + }); + return match; +} + +function defaultExpressionLabelForValue(options, value) { + var option = defaultExpressionOptionForValue(options, value); + return option && option.label + ? option.label + : value + ? value.replace(/_/g, " ") + : ""; +} + +function applyDefaultExpressionColumnType(row, prefix, options, columnTypes) { + var defaultExprSelect = row.querySelector("." + prefix + "-default-expr"); + var typeSelect = row.querySelector("." + prefix + "-column-type"); + if (!defaultExprSelect || !typeSelect || !defaultExprSelect.value) { + return false; + } + var option = defaultExpressionOptionForValue( + options, + defaultExprSelect.value, + ); + if ( + option && + option.sqliteType && + (columnTypes || []).indexOf(option.sqliteType) !== -1 && + typeSelect.value !== option.sqliteType + ) { + typeSelect.value = option.sqliteType; + return true; + } + return false; +} + +function createDefaultExpressionSelect( + options, + className, + placeholderClass, + value, +) { + var select = document.createElement("select"); + select.className = className; + var blankOption = document.createElement("option"); + blankOption.value = ""; + blankOption.textContent = "- default expr -"; + select.appendChild(blankOption); + options.forEach(function (option) { + var normalized = normalizeDefaultExpressionOption(option); + if (!normalized.value) { + return; + } + var optionElement = document.createElement("option"); + optionElement.value = normalized.value; + optionElement.textContent = normalized.label; + select.appendChild(optionElement); + }); + select.value = value || ""; + updateSelectPlaceholder(select, placeholderClass); + return select; +} + +function createSchemaDialogDefaultControls(prefix, index, expressions, column) { + var defaultDetails = document.createElement("details"); + defaultDetails.className = prefix + "-default-options"; + defaultDetails.open = !!( + column && + (column.defaultValue || column.defaultExpr) + ); + var summary = document.createElement("summary"); + summary.textContent = "Set a default value"; + defaultDetails.appendChild(summary); + + var defaultGrid = document.createElement("div"); + defaultGrid.className = prefix + "-default-grid"; + + var defaultExprId = prefix + "-column-default-expr-" + index; + var defaultExprField = document.createElement("div"); + defaultExprField.className = prefix + "-detail-field"; + var defaultExprLabel = document.createElement("label"); + defaultExprLabel.className = prefix + "-detail-label"; + defaultExprLabel.setAttribute("for", defaultExprId); + defaultExprLabel.textContent = "Default expression"; + var defaultExprSelect = createDefaultExpressionSelect( + expressions, + prefix + "-input " + prefix + "-default-expr", + prefix + "-input-placeholder", + column && column.defaultExpr, + ); + defaultExprSelect.id = defaultExprId; + defaultExprSelect.setAttribute("aria-label", "Default expression"); + defaultExprField.appendChild(defaultExprLabel); + defaultExprField.appendChild(defaultExprSelect); + + var defaultId = prefix + "-column-default-" + index; + var defaultField = document.createElement("div"); + defaultField.className = prefix + "-detail-field"; + var defaultLabel = document.createElement("label"); + defaultLabel.className = prefix + "-detail-label"; + defaultLabel.setAttribute("for", defaultId); + defaultLabel.textContent = "or default to a specific value"; + var defaultInput = document.createElement("input"); + defaultInput.id = defaultId; + defaultInput.className = prefix + "-input " + prefix + "-default"; + defaultInput.type = "text"; + defaultInput.autocomplete = "off"; + defaultInput.placeholder = "default"; + defaultInput.setAttribute("aria-label", "or default to a specific value"); + defaultInput.value = column && column.defaultValue ? column.defaultValue : ""; + defaultField.appendChild(defaultLabel); + defaultField.appendChild(defaultInput); + + defaultGrid.appendChild(defaultExprField); + defaultGrid.appendChild(defaultField); + defaultDetails.appendChild(defaultGrid); + + return { + controls: defaultDetails, + defaultInput: defaultInput, + defaultExprSelect: defaultExprSelect, + }; +} + +function syncSchemaDialogDefaultControls(row, prefix) { + if (!row) { + return; + } + var defaultInput = row.querySelector("." + prefix + "-default"); + var defaultExprSelect = row.querySelector("." + prefix + "-default-expr"); + if (!defaultInput || !defaultExprSelect) { + return; + } + updateSelectPlaceholder(defaultExprSelect, prefix + "-input-placeholder"); +} + +var COLUMN_MOVE_ICONS = { + top: '', + up: '', + down: '', + bottom: + '', + remove: + '', +}; + +function createSchemaDialogIconButton(prefix, modifier, ariaLabel, title, svg) { + var button = document.createElement("button"); + button.type = "button"; + button.className = prefix + "-icon-button " + prefix + "-" + modifier; + button.setAttribute("aria-label", ariaLabel); + button.title = title; + button.dataset.defaultTitle = title; + button.innerHTML = svg; + return button; +} + +function createSchemaDialogMoveControls(prefix) { + var moveControls = document.createElement("div"); + moveControls.className = prefix + "-move-controls"; + + var moveTopButton = createSchemaDialogIconButton( + prefix, + "move-top", + "Move column to top", + "Move column to top", + COLUMN_MOVE_ICONS.top, + ); + var moveUpButton = createSchemaDialogIconButton( + prefix, + "move-up", + "Move column up", + "Move column up", + COLUMN_MOVE_ICONS.up, + ); + var moveDownButton = createSchemaDialogIconButton( + prefix, + "move-down", + "Move column down", + "Move column down", + COLUMN_MOVE_ICONS.down, + ); + var moveBottomButton = createSchemaDialogIconButton( + prefix, + "move-bottom", + "Move column to bottom", + "Move column to bottom", + COLUMN_MOVE_ICONS.bottom, + ); + + moveControls.appendChild(moveTopButton); + moveControls.appendChild(moveUpButton); + moveControls.appendChild(moveDownButton); + moveControls.appendChild(moveBottomButton); + + return { + controls: moveControls, + topButton: moveTopButton, + upButton: moveUpButton, + downButton: moveDownButton, + bottomButton: moveBottomButton, + }; +} + +function createSchemaDialogMoreOptionsButton(prefix, details) { + var expandButton = document.createElement("button"); + expandButton.type = "button"; + expandButton.className = prefix + "-more-options"; + expandButton.setAttribute("aria-label", "Toggle column settings"); + expandButton.setAttribute("aria-controls", details.id); + expandButton.setAttribute("aria-expanded", details.hidden ? "false" : "true"); + updateSchemaDialogMoreOptionsButton(expandButton); + return expandButton; +} + +function updateSchemaDialogMoreOptionsButton(button) { + var isExpanded = button.getAttribute("aria-expanded") === "true"; + button.textContent = isExpanded ? "v Hide options" : "> Advanced options"; + button.title = isExpanded ? "Hide column settings" : "Show column settings"; +} + +function toggleSchemaDialogMoreOptions(button, details) { + var isExpanded = button.getAttribute("aria-expanded") === "true"; + details.hidden = isExpanded; + button.setAttribute("aria-expanded", isExpanded ? "false" : "true"); + updateSchemaDialogMoreOptionsButton(button); +} + +function schemaDialogRows(state, prefix) { + return Array.prototype.slice.call( + state.columnList.querySelectorAll("." + prefix + "-column-row"), + ); +} + +function schemaDialogRowIsPrimaryKey(row, prefix) { + var input = row && row.querySelector("." + prefix + "-primary-key-input"); + return !!(input && input.checked); +} + +function schemaDialogFirstNonPrimaryRow(state, prefix) { + var rows = schemaDialogRows(state, prefix); + for (var i = 0; i < rows.length; i += 1) { + if (!schemaDialogRowIsPrimaryKey(rows[i], prefix)) { + return rows[i]; + } + } + return null; +} + +function updateSchemaDialogMoveButtons(state, prefix) { + if (!state || !state.columnList) { + return; + } + var firstNonPrimary = schemaDialogFirstNonPrimaryRow(state, prefix); + var rows = schemaDialogRows(state, prefix); + var hasPrimaryKeys = rows.some(function (row) { + return schemaDialogRowIsPrimaryKey(row, prefix); + }); + var primaryKeyMoveTitle = "Primary key columns are always listed first"; + rows.forEach(function (row) { + var isPrimaryKey = schemaDialogRowIsPrimaryKey(row, prefix); + var previous = row.previousElementSibling; + var next = row.nextElementSibling; + row + .querySelectorAll("." + prefix + "-move-controls button") + .forEach(function (button) { + button.title = button.dataset.defaultTitle || button.title; + button.disabled = state.isSaving || isPrimaryKey; + if (isPrimaryKey) { + button.title = primaryKeyMoveTitle; + } + }); + if (!isPrimaryKey) { + var topButton = row.querySelector("." + prefix + "-move-top"); + var upButton = row.querySelector("." + prefix + "-move-up"); + var downButton = row.querySelector("." + prefix + "-move-down"); + var bottomButton = row.querySelector("." + prefix + "-move-bottom"); + topButton.disabled = + state.isSaving || !firstNonPrimary || row === firstNonPrimary; + upButton.disabled = + state.isSaving || + !previous || + schemaDialogRowIsPrimaryKey(previous, prefix); + downButton.disabled = state.isSaving || !next; + bottomButton.disabled = state.isSaving || !next; + if (hasPrimaryKeys && row === firstNonPrimary) { + topButton.title = primaryKeyMoveTitle; + upButton.title = primaryKeyMoveTitle; + } + } + }); +} + +function normalizeSchemaDialogPrimaryKeyRows(state, prefix) { + var rows = schemaDialogRows(state, prefix); + rows + .filter(function (row) { + return schemaDialogRowIsPrimaryKey(row, prefix); + }) + .concat( + rows.filter(function (row) { + return !schemaDialogRowIsPrimaryKey(row, prefix); + }), + ) + .forEach(function (row) { + state.columnList.appendChild(row); + }); +} + +function tableCreateCustomColumnTypes() { + var data = databaseCreateTableData() || {}; + return data.customColumnTypes || []; +} + +function tableCreateCustomColumnType(name) { + var options = tableCreateCustomColumnTypes(); + for (var i = 0; i < options.length; i += 1) { + if (options[i].name === name) { + return options[i]; + } + } + return null; +} + +function tableCreateCustomTypeAppliesToSqliteType(option, sqliteType) { + return ( + option && + option.sqliteTypes && + option.sqliteTypes.indexOf(sqliteType) !== -1 + ); +} + +function tableCreateDialogRows(state) { + return schemaDialogRows(state, "table-create"); +} + +function tableCreateRowIsPrimaryKey(row) { + return schemaDialogRowIsPrimaryKey(row, "table-create"); +} + +function tableCreateFirstNonPrimaryRow(state) { + return schemaDialogFirstNonPrimaryRow(state, "table-create"); +} + +function updateTableCreateMoveButtons(state) { + updateSchemaDialogMoveButtons(state, "table-create"); +} + +function schemaDialogTypeAffinity(type) { + if (type === "float") { + return "real"; + } + return type; +} + +function foreignKeyTypesCompatible(sourceAffinity, targetAffinity) { + if (sourceAffinity === targetAffinity) { + return true; + } + var numericAffinities = ["integer", "real", "numeric"]; + if (sourceAffinity === "numeric") { + return numericAffinities.indexOf(targetAffinity) !== -1; + } + if (targetAffinity === "numeric") { + return numericAffinities.indexOf(sourceAffinity) !== -1; + } + return false; +} + +function foreignKeyTargetKey(target) { + return target.fk_table + "\u001f" + target.fk_column; +} + +function foreignKeyTargetLabel(target) { + return ( + target.fk_table + + "." + + target.fk_column + + " (" + + sqliteColumnTypeLabel(target.type) + + ")" + ); +} + +function appendForeignKeyTargetOption(select, target) { + var optionElement = document.createElement("option"); + optionElement.value = foreignKeyTargetKey(target); + optionElement.dataset.fkTable = target.fk_table; + optionElement.dataset.fkColumn = target.fk_column; + optionElement.dataset.fkType = target.type; + optionElement.textContent = foreignKeyTargetLabel(target); + select.appendChild(optionElement); + return optionElement; +} + +function sqliteColumnTypeForForeignKeyTarget(type) { + var affinity = schemaDialogTypeAffinity(type); + if (affinity === "real" || affinity === "numeric") { + return "float"; + } + if (["text", "integer", "blob"].indexOf(affinity) !== -1) { + return affinity; + } + return ""; +} + +function selectedSchemaDialogForeignKeyOption(foreignKeySelect) { + return foreignKeySelect && foreignKeySelect.selectedOptions + ? foreignKeySelect.selectedOptions[0] + : null; +} + +function setBlankSchemaDialogColumnNameFromForeignKey( + row, + prefix, + foreignKeyOption, +) { + var nameInput = row.querySelector("." + prefix + "-column-name"); + if ( + nameInput && + !nameInput.value.trim() && + foreignKeyOption && + foreignKeyOption.dataset.fkTable && + foreignKeyOption.dataset.fkColumn + ) { + nameInput.value = + foreignKeyOption.dataset.fkTable + + "_" + + foreignKeyOption.dataset.fkColumn; + } +} + +function tableCreateForeignKeyTargetsUrl() { + var data = databaseCreateTableData() || {}; + if (data.foreignKeyTargetsPath) { + return data.foreignKeyTargetsPath; + } + if (!data.path) { + return null; + } + return data.path.replace(/\/-\/create$/, "/-/foreign-key-targets"); +} + +function populateSchemaDialogForeignKeySelect( + select, + state, + prefix, + sourceType, + options, +) { + options = options || {}; + var previousKey = select.value || select.dataset.selectedKey || ""; + select.textContent = ""; + + var blankOption = document.createElement("option"); + blankOption.value = ""; + blankOption.textContent = "- no foreign key -"; + select.appendChild(blankOption); + + if (state.foreignKeyTargetsLoading) { + var loadingOption = document.createElement("option"); + loadingOption.value = ""; + loadingOption.disabled = true; + loadingOption.textContent = "Loading foreign keys..."; + select.appendChild(loadingOption); + } else if (state.foreignKeyTargetsError) { + var errorOption = document.createElement("option"); + errorOption.value = ""; + errorOption.disabled = true; + errorOption.textContent = "Could not load foreign keys"; + select.appendChild(errorOption); + } else { + var sourceAffinity = schemaDialogTypeAffinity(sourceType); + (state.foreignKeyTargets || []).forEach(function (target) { + if ( + options.filterByType !== false && + !foreignKeyTypesCompatible(sourceAffinity, target.type) + ) { + return; + } + appendForeignKeyTargetOption(select, target); + }); + } + + select.value = previousKey; + if ( + previousKey && + select.value !== previousKey && + select.dataset.currentFkTable && + select.dataset.currentFkColumn + ) { + appendForeignKeyTargetOption(select, { + fk_table: select.dataset.currentFkTable, + fk_column: select.dataset.currentFkColumn, + type: select.dataset.currentFkType || sourceType, + }); + select.value = previousKey; + } + if (select.value !== previousKey) { + select.value = ""; + } + select.dataset.selectedKey = select.value; + select.disabled = state.isSaving || select.options.length <= 1; + updateSelectPlaceholder(select, prefix + "-input-placeholder"); +} + +function syncSchemaDialogForeignKeyOptions(row, state, prefix, options) { + var typeSelect = row.querySelector("." + prefix + "-column-type"); + var foreignKeySelect = row.querySelector( + "." + prefix + "-foreign-key-target", + ); + if (!typeSelect || !foreignKeySelect) { + return; + } + populateSchemaDialogForeignKeySelect( + foreignKeySelect, + state, + prefix, + typeSelect.value, + options, + ); +} + +function syncSchemaDialogCustomTypeAndForeignKey(row, state, prefix) { + var customTypeSelect = row.querySelector( + "." + prefix + "-custom-column-type", + ); + var foreignKeySelect = row.querySelector( + "." + prefix + "-foreign-key-target", + ); + if (!foreignKeySelect) { + return; + } + + var hasCustomType = customTypeSelect && !!customTypeSelect.value; + var hasForeignKey = !!foreignKeySelect.value; + + if (customTypeSelect && hasForeignKey) { + customTypeSelect.value = ""; + updateSelectPlaceholder(customTypeSelect, prefix + "-input-placeholder"); + hasCustomType = false; + } + + if (hasCustomType) { + foreignKeySelect.value = ""; + foreignKeySelect.dataset.selectedKey = ""; + updateSelectPlaceholder(foreignKeySelect, prefix + "-input-placeholder"); + hasForeignKey = false; + } + + if (customTypeSelect) { + customTypeSelect.disabled = state.isSaving; + } + foreignKeySelect.disabled = + state.isSaving || foreignKeySelect.options.length <= 1; +} + +function handleSchemaDialogForeignKeyChange(row, state, prefix, options) { + options = options || {}; + var foreignKeySelect = row.querySelector( + "." + prefix + "-foreign-key-target", + ); + var typeSelect = row.querySelector("." + prefix + "-column-type"); + var customTypeSelect = row.querySelector( + "." + prefix + "-custom-column-type", + ); + if (!foreignKeySelect) { + return; + } + foreignKeySelect.dataset.selectedKey = foreignKeySelect.value; + updateSelectPlaceholder(foreignKeySelect, prefix + "-input-placeholder"); + + var foreignKeyOption = selectedSchemaDialogForeignKeyOption(foreignKeySelect); + setBlankSchemaDialogColumnNameFromForeignKey(row, prefix, foreignKeyOption); + + var columnTypes = options.columnTypes || []; + var foreignKeyColumnType = + foreignKeyOption && foreignKeyOption.dataset.fkType + ? sqliteColumnTypeForForeignKeyTarget(foreignKeyOption.dataset.fkType) + : ""; + if ( + options.matchType && + typeSelect && + foreignKeyColumnType && + columnTypes.indexOf(foreignKeyColumnType) !== -1 && + typeSelect.value !== foreignKeyColumnType + ) { + typeSelect.value = foreignKeyColumnType; + syncSchemaDialogForeignKeyOptions( + row, + state, + prefix, + options.foreignKeyOptions, + ); + } + + if (customTypeSelect && foreignKeySelect.value) { + customTypeSelect.value = ""; + updateSelectPlaceholder(customTypeSelect, prefix + "-input-placeholder"); + } + syncSchemaDialogCustomTypeAndForeignKey(row, state, prefix); +} + +function refreshSchemaDialogForeignKeyControls(state, prefix, options) { + schemaDialogRows(state, prefix).forEach(function (row) { + syncSchemaDialogForeignKeyOptions(row, state, prefix, options); + syncSchemaDialogCustomTypeAndForeignKey(row, state, prefix); + }); +} + +async function loadSchemaDialogForeignKeyTargets(state, prefix, url, options) { + if (!url || !window.fetch) { + state.foreignKeyTargets = []; + state.foreignKeyTargetsLoading = false; + refreshSchemaDialogForeignKeyControls(state, prefix, options); + return; + } + state.foreignKeyTargets = []; + state.foreignKeyTargetsError = null; + state.foreignKeyTargetsLoading = true; + refreshSchemaDialogForeignKeyControls(state, prefix, options); + try { + var response = await fetch(url, { + headers: { + Accept: "application/json", + }, + }); + var data = await response.json(); + if (!response.ok || data.ok === false) { + throw rowMutationRequestError(response, data); + } + state.foreignKeyTargets = data.targets || []; + } catch (error) { + state.foreignKeyTargets = []; + state.foreignKeyTargetsError = error; + } finally { + state.foreignKeyTargetsLoading = false; + refreshSchemaDialogForeignKeyControls(state, prefix, options); + } +} + +function syncTableCreateForeignKeyOptions(row, state) { + syncSchemaDialogForeignKeyOptions(row, state, "table-create", { + filterByType: false, + }); +} + +function syncTableCreateCustomTypeAndForeignKey(row, state) { + syncSchemaDialogCustomTypeAndForeignKey(row, state, "table-create"); +} + +function refreshTableCreateForeignKeyControls(state) { + tableCreateDialogRows(state).forEach(function (row) { + syncTableCreateForeignKeyOptions(row, state); + syncTableCreateCustomTypeAndForeignKey(row, state); + }); +} + +function updateTableCreateColumnRules(state) { + normalizeSchemaDialogPrimaryKeyRows(state, "table-create"); + tableCreateDialogRows(state).forEach(function (row) { + syncTableCreateForeignKeyOptions(row, state); + syncTableCreateCustomTypeAndForeignKey(row, state); + syncSchemaDialogDefaultControls(row, "table-create"); + }); + updateTableCreateMoveButtons(state); +} + +async function loadTableCreateForeignKeyTargets(state) { + return loadSchemaDialogForeignKeyTargets( + state, + "table-create", + tableCreateForeignKeyTargetsUrl(), + { filterByType: false }, + ); +} + +function tableCreateDialogSignature(state) { + if (!state || !state.form) { + return ""; + } + return JSON.stringify({ + table: state.tableName.value, + columns: tableCreateDialogRows(state).map(function (row) { + return { + name: row.querySelector(".table-create-column-name").value, + type: row.querySelector(".table-create-column-type").value, + customType: + ( + row.querySelector(".table-create-custom-column-type") || { + value: "", + } + ).value || "", + pk: row.querySelector(".table-create-primary-key-input").checked, + notNull: row.querySelector(".table-create-not-null-input").checked, + defaultValue: row.querySelector(".table-create-default").value, + defaultExpr: row.querySelector(".table-create-default-expr").value, + foreignKey: + ( + row.querySelector(".table-create-foreign-key-target") || { + value: "", + } + ).value || "", + }; + }), + }); +} + +function tableCreateDialogHasChanges(state) { + return ( + !!state && + !state.isSaving && + tableCreateDialogSignature(state) !== state.initialSignature + ); +} + +function clearTableCreateDialogError(state) { + state.error.hidden = true; + state.error.textContent = ""; + state.dialog.removeAttribute("aria-describedby"); +} + +function showTableCreateDialogError(state, message) { + state.error.hidden = false; + state.error.textContent = message; + state.dialog.setAttribute("aria-describedby", "table-create-error"); + state.error.focus(); +} + +function setTableCreateDialogSaving(state, isSaving) { + state.isSaving = isSaving; + state.cancelButton.disabled = isSaving; + state.saveButton.disabled = isSaving; + state.addColumnButton.disabled = isSaving; + state.saveButton.textContent = isSaving ? "Creating..." : "Create table"; + state.columnList + .querySelectorAll("input, select, button") + .forEach(function (control) { + control.disabled = isSaving; + }); + if (!isSaving) { + updateTableCreateColumnRules(state); + } + updateTableCreateMoveButtons(state); +} + +function tableCreateSelectTypeValue(select, type) { + var options = tableCreateColumnTypes(); + populateSqliteColumnTypeSelect(select, type, options); +} + +function updateTableCreateCustomColumnTypePlaceholder(select) { + updateSelectPlaceholder(select, "table-create-input-placeholder"); +} + +function createTableCustomColumnTypeSelect() { + var options = tableCreateCustomColumnTypes(); + return createCustomColumnTypeSelect( + options, + "table-create-input table-create-custom-column-type", + "table-create-input-placeholder", + ); +} + +function syncTableCreateCustomTypeForSqliteType(row) { + var typeSelect = row.querySelector(".table-create-column-type"); + var customTypeSelect = row.querySelector(".table-create-custom-column-type"); + if (!typeSelect || !customTypeSelect || !customTypeSelect.value) { + return; + } + var option = tableCreateCustomColumnType(customTypeSelect.value); + if (!tableCreateCustomTypeAppliesToSqliteType(option, typeSelect.value)) { + customTypeSelect.value = ""; + updateTableCreateCustomColumnTypePlaceholder(customTypeSelect); + } +} + +function createTableColumnRow(state, column) { + var index = state.nextColumnIndex; + state.nextColumnIndex += 1; + + var row = document.createElement("div"); + row.className = "table-create-column-row"; + + var main = document.createElement("div"); + main.className = "table-create-column-main"; + + var details = document.createElement("div"); + details.className = "table-create-column-details"; + details.id = "table-create-column-details-" + index; + details.hidden = !(column && column.expanded); + + var expandButton = createSchemaDialogMoreOptionsButton( + "table-create", + details, + ); + + var nameId = "table-create-column-name-" + index; + var nameLabel = document.createElement("label"); + nameLabel.className = "table-create-column-label"; + nameLabel.setAttribute("for", nameId); + nameLabel.textContent = "Column"; + + var nameInput = document.createElement("input"); + nameInput.id = nameId; + nameInput.className = "table-create-input table-create-column-name"; + nameInput.type = "text"; + nameInput.required = true; + nameInput.autocomplete = "off"; + nameInput.placeholder = "column name"; + nameInput.value = column && column.name ? column.name : ""; + + var typeSelect = document.createElement("select"); + typeSelect.className = "table-create-input table-create-column-type"; + typeSelect.setAttribute("aria-label", "Column type"); + tableCreateSelectTypeValue(typeSelect, column && column.type); + + var customTypeSelect = null; + var customTypeField = null; + if (tableCreateCustomColumnTypes().length) { + var customTypeId = "table-create-column-custom-type-" + index; + customTypeField = document.createElement("div"); + customTypeField.className = + "table-create-detail-field table-create-custom-type-field"; + var customTypeLabel = document.createElement("label"); + customTypeLabel.className = "table-create-detail-label"; + customTypeLabel.setAttribute("for", customTypeId); + customTypeLabel.textContent = "Custom type"; + var customTypeHelpId = "table-create-column-custom-type-help-" + index; + var customTypeHelp = document.createElement("p"); + customTypeHelp.id = customTypeHelpId; + customTypeHelp.className = "table-create-detail-help"; + customTypeHelp.textContent = + "Controls how Datasette displays and edits this column"; + customTypeSelect = createTableCustomColumnTypeSelect(); + customTypeSelect.id = customTypeId; + customTypeSelect.setAttribute("aria-describedby", customTypeHelpId); + customTypeSelect.value = + column && column.customType ? column.customType : ""; + updateTableCreateCustomColumnTypePlaceholder(customTypeSelect); + customTypeField.appendChild(customTypeLabel); + customTypeField.appendChild(customTypeHelp); + customTypeField.appendChild(customTypeSelect); + } + + var pkLabel = document.createElement("label"); + pkLabel.className = "table-create-detail-check table-create-primary-key"; + var pkInput = document.createElement("input"); + pkInput.type = "checkbox"; + pkInput.className = "table-create-primary-key-input"; + pkInput.checked = !!(column && column.primaryKey); + var pkText = document.createElement("span"); + var pkStrong = document.createElement("strong"); + pkStrong.textContent = "Primary key"; + pkText.appendChild(pkStrong); + pkText.appendChild( + document.createTextNode(" This ID uniquely identifies the record"), + ); + pkLabel.appendChild(pkInput); + pkLabel.appendChild(pkText); + + var foreignKeyId = "table-create-column-foreign-key-" + index; + var foreignKeyHelpId = "table-create-column-foreign-key-help-" + index; + var foreignKeyField = document.createElement("div"); + foreignKeyField.className = + "table-create-detail-field table-create-foreign-key-field"; + var foreignKeyLabel = document.createElement("label"); + foreignKeyLabel.className = "table-create-detail-label"; + foreignKeyLabel.setAttribute("for", foreignKeyId); + foreignKeyLabel.textContent = "Foreign key"; + var foreignKeyHelp = document.createElement("p"); + foreignKeyHelp.id = foreignKeyHelpId; + foreignKeyHelp.className = "table-create-detail-help"; + foreignKeyHelp.textContent = "Link this column to another table."; + var foreignKeySelect = document.createElement("select"); + foreignKeySelect.id = foreignKeyId; + foreignKeySelect.className = + "table-create-input table-create-foreign-key-target"; + foreignKeySelect.setAttribute("aria-label", "Foreign key target"); + foreignKeySelect.setAttribute("aria-describedby", foreignKeyHelpId); + foreignKeyField.appendChild(foreignKeyLabel); + foreignKeyField.appendChild(foreignKeyHelp); + foreignKeyField.appendChild(foreignKeySelect); + + var notNullLabel = document.createElement("label"); + notNullLabel.className = "table-create-detail-check table-create-not-null"; + var notNullInput = document.createElement("input"); + notNullInput.type = "checkbox"; + notNullInput.className = "table-create-not-null-input"; + notNullInput.checked = !!(column && column.notNull); + var notNullText = document.createElement("span"); + var notNullStrong = document.createElement("strong"); + notNullStrong.textContent = "Not null"; + notNullText.appendChild(notNullStrong); + notNullText.appendChild( + document.createTextNode(" This value cannot be left unset"), + ); + notNullLabel.appendChild(notNullInput); + notNullLabel.appendChild(notNullText); + + var defaultControls = createSchemaDialogDefaultControls( + "table-create", + index, + tableCreateDefaultExpressions(), + { + defaultValue: column && column.defaultValue, + defaultExpr: column && column.defaultExpr, + }, + ); + + var moveControls = createSchemaDialogMoveControls("table-create"); + + var removeButton = createSchemaDialogIconButton( + "table-create", + "remove-column", + "Remove column", + "Remove column", + COLUMN_MOVE_ICONS.remove, + ); + + main.appendChild(nameLabel); + main.appendChild(nameInput); + main.appendChild(typeSelect); + main.appendChild(moveControls.controls); + main.appendChild(removeButton); + main.appendChild(expandButton); + + if (customTypeField) { + details.appendChild(customTypeField); + } + details.appendChild(defaultControls.controls); + details.appendChild(notNullLabel); + details.appendChild(pkLabel); + details.appendChild(foreignKeyField); + row.appendChild(main); + row.appendChild(details); + + removeButton.addEventListener("click", function () { + if (state.isSaving) { + return; + } + row.remove(); + clearTableCreateDialogError(state); + updateTableCreateColumnRules(state); + var nextInput = state.columnList.querySelector(".table-create-column-name"); + if (nextInput) { + nextInput.focus(); + } else { + state.addColumnButton.focus(); + } + }); + + nameInput.addEventListener("input", function () { + clearTableCreateDialogError(state); + }); + typeSelect.addEventListener("change", function () { + clearTableCreateDialogError(state); + syncTableCreateCustomTypeForSqliteType(row); + syncTableCreateForeignKeyOptions(row, state); + syncTableCreateCustomTypeAndForeignKey(row, state); + }); + if (customTypeSelect) { + customTypeSelect.addEventListener("change", function () { + clearTableCreateDialogError(state); + updateTableCreateCustomColumnTypePlaceholder(customTypeSelect); + if (customTypeSelect.value) { + foreignKeySelect.value = ""; + foreignKeySelect.dataset.selectedKey = ""; + } + var option = tableCreateCustomColumnType(customTypeSelect.value); + if ( + option && + option.fixedSqliteType && + tableCreateColumnTypes().indexOf(option.fixedSqliteType) !== -1 + ) { + typeSelect.value = option.fixedSqliteType; + syncTableCreateForeignKeyOptions(row, state); + } + syncTableCreateCustomTypeAndForeignKey(row, state); + }); + } + pkInput.addEventListener("change", function () { + clearTableCreateDialogError(state); + updateTableCreateColumnRules(state); + }); + notNullInput.addEventListener("change", function () { + clearTableCreateDialogError(state); + }); + defaultControls.defaultInput.addEventListener("input", function () { + if (defaultControls.defaultInput.value) { + defaultControls.defaultExprSelect.value = ""; + syncSchemaDialogDefaultControls(row, "table-create"); + } + clearTableCreateDialogError(state); + }); + defaultControls.defaultExprSelect.addEventListener("change", function () { + if (defaultControls.defaultExprSelect.value) { + defaultControls.defaultInput.value = ""; + } + if ( + applyDefaultExpressionColumnType( + row, + "table-create", + tableCreateDefaultExpressions(), + tableCreateColumnTypes(), + ) + ) { + syncTableCreateCustomTypeForSqliteType(row); + syncTableCreateForeignKeyOptions(row, state); + syncTableCreateCustomTypeAndForeignKey(row, state); + } + syncSchemaDialogDefaultControls(row, "table-create"); + clearTableCreateDialogError(state); + }); + foreignKeySelect.addEventListener("change", function () { + clearTableCreateDialogError(state); + handleSchemaDialogForeignKeyChange(row, state, "table-create", { + columnTypes: tableCreateColumnTypes(), + foreignKeyOptions: { filterByType: false }, + matchType: true, + }); + }); + + expandButton.addEventListener("click", function () { + toggleSchemaDialogMoreOptions(expandButton, details); + }); + + moveControls.topButton.addEventListener("click", function () { + var first = tableCreateFirstNonPrimaryRow(state); + if ( + state.isSaving || + tableCreateRowIsPrimaryKey(row) || + !first || + first === row + ) { + return; + } + state.columnList.insertBefore(row, first); + clearTableCreateDialogError(state); + updateTableCreateColumnRules(state); + row.querySelector(".table-create-column-name").focus(); + }); + + moveControls.upButton.addEventListener("click", function () { + var previous = row.previousElementSibling; + if ( + state.isSaving || + tableCreateRowIsPrimaryKey(row) || + !previous || + tableCreateRowIsPrimaryKey(previous) + ) { + return; + } + state.columnList.insertBefore(row, previous); + clearTableCreateDialogError(state); + updateTableCreateColumnRules(state); + row.querySelector(".table-create-column-name").focus(); + }); + + moveControls.downButton.addEventListener("click", function () { + var next = row.nextElementSibling; + if (state.isSaving || tableCreateRowIsPrimaryKey(row) || !next) { + return; + } + state.columnList.insertBefore(next, row); + clearTableCreateDialogError(state); + updateTableCreateColumnRules(state); + row.querySelector(".table-create-column-name").focus(); + }); + + moveControls.bottomButton.addEventListener("click", function () { + var last = state.columnList.lastElementChild; + if ( + state.isSaving || + tableCreateRowIsPrimaryKey(row) || + !last || + last === row + ) { + return; + } + state.columnList.appendChild(row); + clearTableCreateDialogError(state); + updateTableCreateColumnRules(state); + row.querySelector(".table-create-column-name").focus(); + }); + + syncSchemaDialogDefaultControls(row, "table-create"); + return row; +} + +function addTableCreateColumn(state, column) { + var row = createTableColumnRow(state, column || { type: "text" }); + state.columnList.appendChild(row); + updateTableCreateColumnRules(state); + return row; +} + +function resetTableCreateDialog(state) { + state.nextColumnIndex = 0; + state.tableName.value = ""; + state.columnList.textContent = ""; + addTableCreateColumn(state, { + name: "id", + type: "integer", + primaryKey: true, + }); + addTableCreateColumn(state, { + name: "", + type: "text", + primaryKey: false, + }); + updateTableCreateColumnRules(state); + state.initialSignature = tableCreateDialogSignature(state); +} + +function collectTableCreatePayload(state) { + var payload = { + table: state.tableName.value.trim(), + columns: [], + }; + var primaryKeys = []; + tableCreateDialogRows(state).forEach(function (row) { + var name = row.querySelector(".table-create-column-name").value.trim(); + var type = row.querySelector(".table-create-column-type").value; + var column = { name: name, type: type }; + var foreignKeySelect = row.querySelector( + ".table-create-foreign-key-target", + ); + var foreignKeyOption = + foreignKeySelect && foreignKeySelect.selectedOptions + ? foreignKeySelect.selectedOptions[0] + : null; + if ( + foreignKeyOption && + foreignKeyOption.dataset.fkTable && + foreignKeyOption.dataset.fkColumn + ) { + column.fk_table = foreignKeyOption.dataset.fkTable; + column.fk_column = foreignKeyOption.dataset.fkColumn; + } + if (row.querySelector(".table-create-not-null-input").checked) { + column.not_null = true; + } + var defaultExpr = row.querySelector(".table-create-default-expr").value; + var defaultValue = row.querySelector(".table-create-default").value; + if (defaultExpr) { + column.default_expr = defaultExpr; + } else if (defaultValue) { + column.default = defaultValue; + } + payload.columns.push(column); + if (row.querySelector(".table-create-primary-key-input").checked) { + primaryKeys.push(name); + } + }); + if (primaryKeys.length === 1) { + payload.pk = primaryKeys[0]; + } else if (primaryKeys.length > 1) { + payload.pks = primaryKeys; + } + return payload; +} + +function collectTableCreateColumnTypeAssignments(state) { + var assignments = []; + tableCreateDialogRows(state).forEach(function (row) { + var customTypeSelect = row.querySelector( + ".table-create-custom-column-type", + ); + if (!customTypeSelect || !customTypeSelect.value) { + return; + } + assignments.push({ + column: row.querySelector(".table-create-column-name").value.trim(), + columnType: customTypeSelect.value, + sqliteType: row.querySelector(".table-create-column-type").value, + }); + }); + return assignments; +} + +function validateTableCreatePayload(payload) { + if (!payload.table) { + return "Table name is required."; + } + if (payload.table.indexOf("\n") !== -1) { + return "Table name cannot contain newlines."; + } + if (/^sqlite_/i.test(payload.table)) { + return "Table name cannot start with sqlite_."; + } + if (!payload.columns.length) { + return "At least one column is required."; + } + var seen = {}; + var supportedTypes = tableCreateColumnTypes(); + for (var i = 0; i < payload.columns.length; i += 1) { + var column = payload.columns[i]; + if (!column.name) { + return "Column name is required."; + } + if (column.name.indexOf("\n") !== -1) { + return "Column names cannot contain newlines."; + } + var columnKey = column.name.toLowerCase(); + if (seen[columnKey]) { + return "Duplicate column name: " + column.name; + } + seen[columnKey] = true; + if (supportedTypes.indexOf(column.type) === -1) { + return "Unsupported column type: " + column.type; + } + if (column.default && column.default_expr) { + return "Use either a default value or a default expression."; + } + } + return null; +} + +function validateTableCreateColumnTypeAssignments(assignments) { + for (var i = 0; i < assignments.length; i += 1) { + var assignment = assignments[i]; + var option = tableCreateCustomColumnType(assignment.columnType); + if (!option) { + return "Unknown custom column type: " + assignment.columnType; + } + if ( + !tableCreateCustomTypeAppliesToSqliteType(option, assignment.sqliteType) + ) { + return ( + "Custom type " + + assignment.columnType + + " cannot be used with SQLite type " + + assignment.sqliteType + + "." + ); + } + } + return null; +} + +function fallbackTableUrl(tableName) { + var data = databaseCreateTableData() || {}; + if (!data.path) { + return null; + } + return data.path.replace(/\/-\/create$/, "/" + encodeURIComponent(tableName)); +} + +function tableCreateSetColumnTypeUrl(responseData, payload) { + var tableUrl = + responseData.table_url || + fallbackTableUrl(responseData.table || payload.table); + if (!tableUrl) { + return null; + } + var url = new URL(tableUrl, location.href); + url.hash = ""; + url.search = ""; + url.pathname = url.pathname.replace(/\/$/, "") + "/-/set-column-type"; + return url.toString(); +} + +async function assignTableCreateColumnTypes( + responseData, + payload, + assignments, +) { + if (!assignments.length) { + return; + } + var url = tableCreateSetColumnTypeUrl(responseData, payload); + if (!url) { + throw new Error("Could not find the set column type URL."); + } + for (var i = 0; i < assignments.length; i += 1) { + var assignment = assignments[i]; + var response = await fetch(url, { + method: "POST", + headers: { + "Content-Type": "application/json", + Accept: "application/json", + }, + body: JSON.stringify({ + column: assignment.column, + column_type: { + type: assignment.columnType, + }, + }), + }); + var data = null; + try { + data = await response.json(); + } catch (_error) { + data = null; + } + if (!response.ok || (data && data.ok === false)) { + var error = rowMutationRequestError(response, data); + throw new Error( + "Created table, but could not set custom type for " + + assignment.column + + ": " + + error.message, + ); + } + } +} + +async function saveTableCreateDialog(state) { + if (state.isSaving) { + return; + } + var data = databaseCreateTableData(); + if (!data || !data.path) { + showTableCreateDialogError(state, "Could not find the create table URL."); + return; + } + clearTableCreateDialogError(state); + var payload = collectTableCreatePayload(state); + var columnTypeAssignments = collectTableCreateColumnTypeAssignments(state); + var validationError = validateTableCreatePayload(payload); + if (validationError) { + showTableCreateDialogError(state, validationError); + return; + } + var columnTypeValidationError = validateTableCreateColumnTypeAssignments( + columnTypeAssignments, + ); + if (columnTypeValidationError) { + showTableCreateDialogError(state, columnTypeValidationError); + return; + } + setTableCreateDialogSaving(state, true); + try { + var response = await fetch(data.path, { + method: "POST", + headers: { + "Content-Type": "application/json", + Accept: "application/json", + }, + body: JSON.stringify(payload), + }); + var responseData = null; + try { + responseData = await response.json(); + } catch (_error) { + responseData = null; + } + if (!response.ok || (responseData && responseData.ok === false)) { + throw rowMutationRequestError(response, responseData); + } + await assignTableCreateColumnTypes( + responseData, + payload, + columnTypeAssignments, + ); + var tableUrl = + responseData.table_url || + fallbackTableUrl(responseData.table || payload.table); + state.shouldRestoreFocus = false; + state.dialog.close(); + if (tableUrl) { + location.href = tableUrl; + } else { + location.reload(); + } + } catch (error) { + setTableCreateDialogSaving(state, false); + showTableCreateDialogError( + state, + error.message || "Could not create table", + ); + } +} + +function confirmDiscardTableCreateChanges(state) { + if (!tableCreateDialogHasChanges(state)) { + return true; + } + return window.confirm("Discard this new table?"); +} + +function closeTableCreateDialogIfConfirmed(state) { + if (!state || state.isSaving) { + return false; + } + if (!confirmDiscardTableCreateChanges(state)) { + return false; + } + state.shouldRestoreFocus = true; + state.dialog.close(); + return true; +} + +function ensureTableCreateDialog(manager) { + if (tableCreateDialogState) { + return tableCreateDialogState; + } + if (!window.HTMLDialogElement) { + return null; + } + + var dialog = document.createElement("dialog"); + dialog.id = TABLE_CREATE_DIALOG_ID; + dialog.className = "table-create-dialog"; + dialog.setAttribute("aria-labelledby", "table-create-title"); + dialog.innerHTML = ` + + + +
+
+ + +
+
+ +
+ +
+
+ + + `; + document.body.appendChild(dialog); + + tableCreateDialogState = { + dialog: dialog, + form: dialog.querySelector(".table-create-form"), + title: dialog.querySelector(".modal-title"), + error: dialog.querySelector(".table-create-error"), + fields: dialog.querySelector(".table-create-fields"), + tableName: dialog.querySelector(".table-create-table-name"), + columnList: dialog.querySelector(".table-create-column-list"), + addColumnButton: dialog.querySelector(".table-create-add-column"), + cancelButton: dialog.querySelector(".table-create-cancel"), + saveButton: dialog.querySelector(".table-create-save"), + currentButton: null, + shouldRestoreFocus: true, + isSaving: false, + initialSignature: "", + nextColumnIndex: 0, + foreignKeyTargets: [], + foreignKeyTargetsError: null, + foreignKeyTargetsLoading: false, + manager: manager, + }; + + tableCreateDialogState.form.addEventListener("submit", function (ev) { + ev.preventDefault(); + saveTableCreateDialog(tableCreateDialogState); + }); + + tableCreateDialogState.addColumnButton.addEventListener("click", function () { + if (tableCreateDialogState.isSaving) { + return; + } + var row = addTableCreateColumn(tableCreateDialogState, { type: "text" }); + clearTableCreateDialogError(tableCreateDialogState); + row.querySelector(".table-create-column-name").focus(); + }); + + tableCreateDialogState.cancelButton.addEventListener("click", function () { + closeTableCreateDialogIfConfirmed(tableCreateDialogState); + }); + + tableCreateDialogState.tableName.addEventListener("input", function () { + clearTableCreateDialogError(tableCreateDialogState); + }); + + dialog.addEventListener("click", function (ev) { + if (ev.target === dialog) { + closeTableCreateDialogIfConfirmed(tableCreateDialogState); + } + }); + + dialog.addEventListener("keydown", function (ev) { + if (ev.key !== "Escape") { + return; + } + ev.preventDefault(); + closeTableCreateDialogIfConfirmed(tableCreateDialogState); + }); + + dialog.addEventListener("cancel", function (ev) { + ev.preventDefault(); + closeTableCreateDialogIfConfirmed(tableCreateDialogState); + }); + + dialog.addEventListener("close", function () { + var state = tableCreateDialogState; + clearTableCreateDialogError(state); + setTableCreateDialogSaving(state, false); + if ( + state.shouldRestoreFocus && + state.currentButton && + document.contains(state.currentButton) + ) { + state.currentButton.focus(); + } + }); + + return tableCreateDialogState; +} + +function openTableCreateDialog(button, manager) { + var data = databaseCreateTableData(); + if (!data) { + return; + } + var state = ensureTableCreateDialog(manager); + if (!state) { + return; + } + + var menu = button.closest("details"); + if (menu) { + menu.open = false; + } + state.manager = manager; + state.currentButton = button; + state.shouldRestoreFocus = true; + state.title.textContent = "Create a table in " + data.databaseName; + clearTableCreateDialogError(state); + resetTableCreateDialog(state); + loadTableCreateForeignKeyTargets(state); + if (!state.dialog.open) { + state.dialog.showModal(); + } + state.tableName.focus(); +} + +function initTableCreateActions(manager) { + if ( + !window.fetch || + !window.HTMLDialogElement || + !databaseCreateTableData() + ) { + return; + } + document.addEventListener("click", function (ev) { + var button = ev.target.closest( + 'button[data-database-action="create-table"]', + ); + if (!button) { + return; + } + ev.preventDefault(); + openTableCreateDialog(button, manager); + }); +} + function setRowDeleteDialogBusy(state, isBusy) { state.isBusy = isBusy; state.confirmButton.disabled = isBusy; @@ -141,6 +1793,1622 @@ function tableInsertData() { return tablePageData().insertRow; } +function tableAlterData() { + return tablePageData().alterTable; +} + +function tableAlterColumnTypes() { + var data = tableAlterData() || {}; + return data.columnTypes && data.columnTypes.length + ? data.columnTypes + : ["text", "integer", "float", "blob"]; +} + +function tableAlterDefaultExpressions() { + var data = tableAlterData() || {}; + return data.defaultExpressions || []; +} + +function tableAlterForeignKeyTargetsUrl() { + var data = tableAlterData() || {}; + return data.foreignKeyTargetsPath || null; +} + +function tableAlterCustomColumnTypes() { + var data = tableAlterData() || {}; + return data.customColumnTypes || []; +} + +function tableAlterCustomColumnType(name) { + var options = tableAlterCustomColumnTypes(); + for (var i = 0; i < options.length; i += 1) { + if (options[i].name === name) { + return options[i]; + } + } + return null; +} + +function tableAlterCustomTypeAppliesToSqliteType(option, sqliteType) { + return ( + option && + option.sqliteTypes && + option.sqliteTypes.indexOf(sqliteType) !== -1 + ); +} + +function tableAlterDialogRows(state) { + return schemaDialogRows(state, "table-alter"); +} + +function syncTableAlterForeignKeyOptions(row, state) { + syncSchemaDialogForeignKeyOptions(row, state, "table-alter", { + filterByType: false, + }); +} + +function tableAlterRowSignature(row) { + var foreignKeySelect = row.querySelector(".table-alter-foreign-key-target"); + var foreignKeyValue = foreignKeySelect + ? foreignKeySelect.value || foreignKeySelect.dataset.selectedKey || "" + : ""; + var foreignKeyOption = + foreignKeySelect && foreignKeySelect.selectedOptions + ? foreignKeySelect.selectedOptions[0] + : null; + return { + existing: row.dataset.existing === "1", + originalName: row.dataset.originalName || "", + name: row.querySelector(".table-alter-column-name").value, + type: row.querySelector(".table-alter-column-type").value, + customType: + ( + row.querySelector(".table-alter-custom-column-type") || { + value: "", + } + ).value || "", + notNull: row.querySelector(".table-alter-not-null-input").checked, + defaultValue: row.querySelector(".table-alter-default").value, + defaultExpr: row.querySelector(".table-alter-default-expr").value, + pk: row.querySelector(".table-alter-primary-key-input").checked, + foreignKey: foreignKeyValue, + foreignKeyTable: + foreignKeyOption && foreignKeyOption.dataset.fkTable + ? foreignKeyOption.dataset.fkTable + : "", + foreignKeyColumn: + foreignKeyOption && foreignKeyOption.dataset.fkColumn + ? foreignKeyOption.dataset.fkColumn + : "", + }; +} + +function tableAlterDialogSignature(state) { + if (!state || !state.form) { + return ""; + } + return JSON.stringify({ + tableName: state.tableNameInput ? state.tableNameInput.value.trim() : "", + columns: tableAlterDialogRows(state).map(tableAlterRowSignature), + deletedColumns: state.deletedColumns.slice(), + }); +} + +function tableAlterDialogHasChanges(state) { + return ( + !!state && + !state.isSaving && + tableAlterDialogSignature(state) !== state.initialSignature + ); +} + +function updateTableAlterSaveButtonState(state) { + if (!state || !state.saveButton) { + return; + } + state.saveButton.disabled = + state.isSaving || + (state.mode !== "review" && + tableAlterDialogSignature(state) === state.initialSignature); +} + +function tableAlterRowIsPrimaryKey(row) { + return schemaDialogRowIsPrimaryKey(row, "table-alter"); +} + +function tableAlterFirstNonPrimaryRow(state) { + return schemaDialogFirstNonPrimaryRow(state, "table-alter"); +} + +function updateTableAlterMoveButtons(state) { + updateSchemaDialogMoveButtons(state, "table-alter"); +} + +function normalizeTableAlterPrimaryKeyRows(state) { + normalizeSchemaDialogPrimaryKeyRows(state, "table-alter"); +} + +function clearTableAlterDialogError(state) { + state.error.hidden = true; + state.error.textContent = ""; + state.dialog.removeAttribute("aria-describedby"); +} + +function showTableAlterDialogError(state, message) { + state.error.hidden = false; + state.error.textContent = message; + state.dialog.setAttribute("aria-describedby", "table-alter-error"); + state.error.focus(); +} + +function setTableAlterDialogSaving(state, isSaving) { + state.isSaving = isSaving; + state.cancelButton.disabled = isSaving; + state.addColumnButton.disabled = isSaving; + state.backButton.disabled = isSaving; + state.dropButton.disabled = isSaving; + state.saveButton.textContent = isSaving + ? state.mode === "review" + ? "Applying..." + : "Preparing..." + : tableAlterSaveButtonText(state); + state.fields + .querySelectorAll("input, select, button") + .forEach(function (control) { + control.disabled = isSaving; + }); + if (!isSaving) { + state.columnList + .querySelectorAll(".table-alter-default-expr") + .forEach(function (select) { + syncSchemaDialogDefaultControls( + select.closest(".table-alter-column-row"), + "table-alter", + ); + }); + refreshSchemaDialogForeignKeyControls(state, "table-alter"); + } + updateTableAlterMoveButtons(state); + updateTableAlterSaveButtonState(state); +} + +function tableAlterSaveButtonText(state) { + return state && state.mode === "review" ? "Apply changes" : "Review changes"; +} + +function tableAlterSelectTypeValue(select, type) { + var options = tableAlterColumnTypes(); + populateSqliteColumnTypeSelect(select, type, options); +} + +function updateTableAlterCustomColumnTypePlaceholder(select) { + updateSelectPlaceholder(select, "table-alter-input-placeholder"); +} + +function createTableAlterCustomColumnTypeSelect() { + var options = tableAlterCustomColumnTypes(); + return createCustomColumnTypeSelect( + options, + "table-alter-input table-alter-custom-column-type", + "table-alter-input-placeholder", + ); +} + +function syncTableAlterCustomTypeForSqliteType(row) { + var typeSelect = row.querySelector(".table-alter-column-type"); + var customTypeSelect = row.querySelector(".table-alter-custom-column-type"); + if (!typeSelect || !customTypeSelect || !customTypeSelect.value) { + return; + } + var option = tableAlterCustomColumnType(customTypeSelect.value); + if (!tableAlterCustomTypeAppliesToSqliteType(option, typeSelect.value)) { + customTypeSelect.value = ""; + updateTableAlterCustomColumnTypePlaceholder(customTypeSelect); + } +} + +function createTableAlterColumnRow(state, column) { + var index = state.nextColumnIndex; + state.nextColumnIndex += 1; + var existing = !!(column && column.existing); + var originalName = existing ? column.name || "" : ""; + var originalCustomType = + existing && column.column_type ? column.column_type.type || "" : ""; + var originalDefaultExpr = + existing && column.has_default && column.default_expr + ? column.default_expr + : ""; + var originalDefault = + existing && + column.has_default && + !originalDefaultExpr && + column.default !== null + ? String(column.default) + : ""; + var originalForeignKey = + existing && column.foreign_key + ? foreignKeyTargetKey(column.foreign_key) + : ""; + + var row = document.createElement("div"); + row.className = "table-alter-column-row"; + row.dataset.existing = existing ? "1" : "0"; + row.dataset.originalName = originalName; + row.dataset.originalType = existing ? column.type || "text" : ""; + row.dataset.originalNotNull = existing && column.notnull ? "1" : "0"; + row.dataset.originalHasDefault = existing && column.has_default ? "1" : "0"; + row.dataset.originalDefault = originalDefault; + row.dataset.originalDefaultExpr = originalDefaultExpr; + row.dataset.originalPk = existing && column.is_pk ? "1" : "0"; + row.dataset.originalCustomType = originalCustomType; + row.dataset.originalForeignKey = originalForeignKey; + + var main = document.createElement("div"); + main.className = "table-alter-column-main"; + + var details = document.createElement("div"); + details.className = "table-alter-column-details"; + details.id = "table-alter-column-details-" + index; + details.hidden = !(column && column.expanded); + + var expandButton = createSchemaDialogMoreOptionsButton( + "table-alter", + details, + ); + + var nameId = "table-alter-column-name-" + index; + var nameLabel = document.createElement("label"); + nameLabel.className = "table-alter-column-label"; + nameLabel.setAttribute("for", nameId); + nameLabel.textContent = "Column"; + + var nameInput = document.createElement("input"); + nameInput.id = nameId; + nameInput.className = "table-alter-input table-alter-column-name"; + nameInput.type = "text"; + nameInput.required = true; + nameInput.autocomplete = "off"; + nameInput.placeholder = "column name"; + nameInput.value = column && column.name ? column.name : ""; + + var typeSelect = document.createElement("select"); + typeSelect.className = "table-alter-input table-alter-column-type"; + typeSelect.setAttribute("aria-label", "Column type"); + tableAlterSelectTypeValue(typeSelect, column && column.type); + + var customTypeSelect = null; + var customTypeField = null; + if (tableAlterCustomColumnTypes().length) { + var customTypeId = "table-alter-column-custom-type-" + index; + customTypeField = document.createElement("div"); + customTypeField.className = + "table-alter-detail-field table-alter-custom-type-field"; + var customTypeLabel = document.createElement("label"); + customTypeLabel.className = "table-alter-detail-label"; + customTypeLabel.setAttribute("for", customTypeId); + customTypeLabel.textContent = "Custom type"; + var customTypeHelpId = "table-alter-column-custom-type-help-" + index; + var customTypeHelp = document.createElement("p"); + customTypeHelp.id = customTypeHelpId; + customTypeHelp.className = "table-alter-detail-help"; + customTypeHelp.textContent = + "Controls how Datasette displays and edits this column"; + customTypeSelect = createTableAlterCustomColumnTypeSelect(); + customTypeSelect.id = customTypeId; + customTypeSelect.setAttribute("aria-describedby", customTypeHelpId); + customTypeSelect.value = originalCustomType; + updateTableAlterCustomColumnTypePlaceholder(customTypeSelect); + customTypeField.appendChild(customTypeLabel); + customTypeField.appendChild(customTypeHelp); + customTypeField.appendChild(customTypeSelect); + } + + var notNullLabel = document.createElement("label"); + notNullLabel.className = "table-alter-detail-check table-alter-not-null"; + var notNullInput = document.createElement("input"); + notNullInput.type = "checkbox"; + notNullInput.className = "table-alter-not-null-input"; + notNullInput.checked = !!(column && column.notnull); + var notNullText = document.createElement("span"); + var notNullStrong = document.createElement("strong"); + notNullStrong.textContent = "Not null"; + notNullText.appendChild(notNullStrong); + notNullText.appendChild( + document.createTextNode(" This value cannot be left unset"), + ); + notNullLabel.appendChild(notNullInput); + notNullLabel.appendChild(notNullText); + + var defaultControls = createSchemaDialogDefaultControls( + "table-alter", + index, + tableAlterDefaultExpressions(), + { + defaultValue: originalDefault, + defaultExpr: originalDefaultExpr, + }, + ); + var defaultInput = defaultControls.defaultInput; + var defaultExprSelect = defaultControls.defaultExprSelect; + + var foreignKeyId = "table-alter-column-foreign-key-" + index; + var foreignKeyHelpId = "table-alter-column-foreign-key-help-" + index; + var foreignKeyField = document.createElement("div"); + foreignKeyField.className = + "table-alter-detail-field table-alter-foreign-key-field"; + var foreignKeyLabel = document.createElement("label"); + foreignKeyLabel.className = "table-alter-detail-label"; + foreignKeyLabel.setAttribute("for", foreignKeyId); + foreignKeyLabel.textContent = "Foreign key"; + var foreignKeyHelp = document.createElement("p"); + foreignKeyHelp.id = foreignKeyHelpId; + foreignKeyHelp.className = "table-alter-detail-help"; + foreignKeyHelp.textContent = "Link this column to another table."; + var foreignKeySelect = document.createElement("select"); + foreignKeySelect.id = foreignKeyId; + foreignKeySelect.className = + "table-alter-input table-alter-foreign-key-target"; + foreignKeySelect.setAttribute("aria-label", "Foreign key target"); + foreignKeySelect.setAttribute("aria-describedby", foreignKeyHelpId); + foreignKeySelect.dataset.selectedKey = originalForeignKey; + if (column && column.foreign_key) { + foreignKeySelect.dataset.currentFkTable = column.foreign_key.fk_table; + foreignKeySelect.dataset.currentFkColumn = column.foreign_key.fk_column; + appendForeignKeyTargetOption(foreignKeySelect, { + fk_table: column.foreign_key.fk_table, + fk_column: column.foreign_key.fk_column, + type: column.type || "text", + }); + foreignKeySelect.value = originalForeignKey; + } + foreignKeyField.appendChild(foreignKeyLabel); + foreignKeyField.appendChild(foreignKeyHelp); + foreignKeyField.appendChild(foreignKeySelect); + + var pkLabel = document.createElement("label"); + pkLabel.className = "table-alter-detail-check table-alter-primary-key"; + var pkInput = document.createElement("input"); + pkInput.type = "checkbox"; + pkInput.className = "table-alter-primary-key-input"; + pkInput.checked = !!(column && column.is_pk); + var pkText = document.createElement("span"); + var pkStrong = document.createElement("strong"); + pkStrong.textContent = "Primary key"; + pkText.appendChild(pkStrong); + pkText.appendChild( + document.createTextNode(" This ID uniquely identifies the record"), + ); + pkLabel.appendChild(pkInput); + pkLabel.appendChild(pkText); + + var moveControls = createSchemaDialogMoveControls("table-alter"); + + var removeButton = createSchemaDialogIconButton( + "table-alter", + "remove-column", + existing ? "Drop column" : "Remove column", + existing ? "Drop column" : "Remove column", + COLUMN_MOVE_ICONS.remove, + ); + main.appendChild(nameLabel); + main.appendChild(nameInput); + main.appendChild(typeSelect); + main.appendChild(moveControls.controls); + main.appendChild(removeButton); + main.appendChild(expandButton); + + if (customTypeField) { + details.appendChild(customTypeField); + } + details.appendChild(defaultControls.controls); + details.appendChild(notNullLabel); + details.appendChild(pkLabel); + details.appendChild(foreignKeyField); + row.appendChild(main); + row.appendChild(details); + + var controls = [ + nameInput, + typeSelect, + notNullInput, + defaultInput, + defaultExprSelect, + pkInput, + foreignKeySelect, + ]; + if (customTypeSelect) { + controls.push(customTypeSelect); + } + controls.forEach(function (control) { + control.addEventListener("input", function () { + clearTableAlterDialogError(state); + updateTableAlterSaveButtonState(state); + }); + control.addEventListener("change", function () { + clearTableAlterDialogError(state); + updateTableAlterSaveButtonState(state); + }); + }); + + defaultInput.addEventListener("input", function () { + if (defaultInput.value) { + defaultExprSelect.value = ""; + syncSchemaDialogDefaultControls(row, "table-alter"); + } + updateTableAlterSaveButtonState(state); + }); + defaultExprSelect.addEventListener("change", function () { + if (defaultExprSelect.value) { + defaultInput.value = ""; + } + if ( + applyDefaultExpressionColumnType( + row, + "table-alter", + tableAlterDefaultExpressions(), + tableAlterColumnTypes(), + ) + ) { + syncTableAlterCustomTypeForSqliteType(row); + syncTableAlterForeignKeyOptions(row, state); + syncSchemaDialogCustomTypeAndForeignKey(row, state, "table-alter"); + } + syncSchemaDialogDefaultControls(row, "table-alter"); + updateTableAlterSaveButtonState(state); + }); + pkInput.addEventListener("change", function () { + normalizeTableAlterPrimaryKeyRows(state); + updateTableAlterMoveButtons(state); + updateTableAlterSaveButtonState(state); + }); + + expandButton.addEventListener("click", function () { + toggleSchemaDialogMoreOptions(expandButton, details); + }); + + typeSelect.addEventListener("change", function () { + syncTableAlterCustomTypeForSqliteType(row); + syncTableAlterForeignKeyOptions(row, state); + syncSchemaDialogCustomTypeAndForeignKey(row, state, "table-alter"); + updateTableAlterSaveButtonState(state); + }); + if (customTypeSelect) { + customTypeSelect.addEventListener("change", function () { + updateTableAlterCustomColumnTypePlaceholder(customTypeSelect); + var option = tableAlterCustomColumnType(customTypeSelect.value); + if ( + option && + option.fixedSqliteType && + tableAlterColumnTypes().indexOf(option.fixedSqliteType) !== -1 + ) { + typeSelect.value = option.fixedSqliteType; + syncTableAlterForeignKeyOptions(row, state); + } + syncSchemaDialogCustomTypeAndForeignKey(row, state, "table-alter"); + updateTableAlterSaveButtonState(state); + }); + } + foreignKeySelect.addEventListener("change", function () { + handleSchemaDialogForeignKeyChange(row, state, "table-alter", { + columnTypes: tableAlterColumnTypes(), + foreignKeyOptions: { filterByType: false }, + matchType: true, + }); + updateTableAlterSaveButtonState(state); + }); + + moveControls.topButton.addEventListener("click", function () { + var first = tableAlterFirstNonPrimaryRow(state); + if ( + state.isSaving || + tableAlterRowIsPrimaryKey(row) || + !first || + first === row + ) { + return; + } + state.columnList.insertBefore(row, first); + clearTableAlterDialogError(state); + updateTableAlterMoveButtons(state); + updateTableAlterSaveButtonState(state); + row.querySelector(".table-alter-column-name").focus(); + }); + + moveControls.upButton.addEventListener("click", function () { + var previous = row.previousElementSibling; + if ( + state.isSaving || + tableAlterRowIsPrimaryKey(row) || + !previous || + tableAlterRowIsPrimaryKey(previous) + ) { + return; + } + state.columnList.insertBefore(row, previous); + clearTableAlterDialogError(state); + updateTableAlterMoveButtons(state); + updateTableAlterSaveButtonState(state); + row.querySelector(".table-alter-column-name").focus(); + }); + + moveControls.downButton.addEventListener("click", function () { + var next = row.nextElementSibling; + if (state.isSaving || tableAlterRowIsPrimaryKey(row) || !next) { + return; + } + state.columnList.insertBefore(next, row); + clearTableAlterDialogError(state); + updateTableAlterMoveButtons(state); + updateTableAlterSaveButtonState(state); + row.querySelector(".table-alter-column-name").focus(); + }); + + moveControls.bottomButton.addEventListener("click", function () { + var last = state.columnList.lastElementChild; + if ( + state.isSaving || + tableAlterRowIsPrimaryKey(row) || + !last || + last === row + ) { + return; + } + state.columnList.appendChild(row); + clearTableAlterDialogError(state); + updateTableAlterMoveButtons(state); + updateTableAlterSaveButtonState(state); + row.querySelector(".table-alter-column-name").focus(); + }); + + removeButton.addEventListener("click", function () { + if (state.isSaving) { + return; + } + if (row.dataset.existing === "1") { + state.deletedColumns.push(row.dataset.originalName); + } + row.remove(); + clearTableAlterDialogError(state); + updateTableAlterMoveButtons(state); + updateTableAlterSaveButtonState(state); + var nextInput = state.columnList.querySelector(".table-alter-column-name"); + if (nextInput) { + nextInput.focus(); + } else { + state.addColumnButton.focus(); + } + }); + + syncSchemaDialogDefaultControls(row, "table-alter"); + syncTableAlterForeignKeyOptions(row, state); + syncSchemaDialogCustomTypeAndForeignKey(row, state, "table-alter"); + return row; +} + +function addTableAlterColumn(state, column) { + var row = createTableAlterColumnRow(state, column || { type: "text" }); + state.columnList.appendChild(row); + return row; +} + +function resetTableAlterDialog(state, data) { + state.nextColumnIndex = 0; + state.deletedColumns = []; + state.originalTableName = data.tableName || ""; + state.tableNameInput.value = state.originalTableName; + state.tableOptions.open = false; + state.originalPrimaryKeys = (data.primaryKeys || []).slice(); + state.originalColumnNames = (data.columns || []).map(function (column) { + return column.name; + }); + state.columnList.textContent = ""; + (data.columns || []).forEach(function (column) { + addTableAlterColumn( + state, + Object.assign({}, column, { + existing: true, + }), + ); + }); + normalizeTableAlterPrimaryKeyRows(state); + state.initialSignature = tableAlterDialogSignature(state); + showTableAlterEditor(state); +} + +function collectTableAlterRows(state) { + return tableAlterDialogRows(state).map(function (row) { + var signature = tableAlterRowSignature(row); + signature.originalType = row.dataset.originalType || ""; + signature.originalNotNull = row.dataset.originalNotNull === "1"; + signature.originalHasDefault = row.dataset.originalHasDefault === "1"; + signature.originalDefault = row.dataset.originalDefault || ""; + signature.originalDefaultExpr = row.dataset.originalDefaultExpr || ""; + signature.originalPk = row.dataset.originalPk === "1"; + signature.originalCustomType = row.dataset.originalCustomType || ""; + signature.originalForeignKey = row.dataset.originalForeignKey || ""; + return signature; + }); +} + +function validateTableAlterRows(state, rows) { + var tableName = state.tableNameInput.value.trim(); + if (!tableName) { + return "Table name is required."; + } + if (tableName.indexOf("\n") !== -1) { + return "Table names cannot contain newlines."; + } + if (/^sqlite_/.test(tableName)) { + return "Table names cannot start with sqlite_."; + } + if (!rows.length) { + return "At least one column is required."; + } + var seen = {}; + var supportedTypes = tableAlterColumnTypes(); + for (var i = 0; i < rows.length; i += 1) { + var row = rows[i]; + var name = row.name.trim(); + if (!name) { + return "Column name is required."; + } + if (name.indexOf("\n") !== -1) { + return "Column names cannot contain newlines."; + } + var columnKey = name.toLowerCase(); + if (seen[columnKey]) { + return "Duplicate column name: " + name; + } + seen[columnKey] = true; + if (supportedTypes.indexOf(row.type) === -1) { + return "Unsupported column type: " + row.type; + } + if (row.customType) { + var option = tableAlterCustomColumnType(row.customType); + if (!option) { + return "Unknown custom column type: " + row.customType; + } + if (!tableAlterCustomTypeAppliesToSqliteType(option, row.type)) { + return ( + "Custom type " + + row.customType + + " cannot be used with SQLite type " + + row.type + + "." + ); + } + } + if (row.defaultValue && row.defaultExpr) { + return "Use either a default value or a default expression."; + } + if (!row.existing && row.notNull && !row.defaultValue && !row.defaultExpr) { + return "New NOT NULL columns need a default or default expression."; + } + } + var pkColumns = rows.filter(function (row) { + return row.pk; + }); + if (state.originalPrimaryKeys.length && !pkColumns.length) { + return "At least one primary key column is required."; + } + return null; +} + +function collectTableAlterColumnTypeAssignments(rows) { + var assignments = []; + if (!tableAlterCustomColumnTypes().length) { + return assignments; + } + rows.forEach(function (row) { + var renamed = row.existing && row.name.trim() !== row.originalName; + if (row.customType === row.originalCustomType && !renamed) { + return; + } + if (!row.customType && !row.originalCustomType) { + return; + } + assignments.push({ + column: row.name.trim(), + columnType: row.customType || null, + sqliteType: row.type, + }); + }); + return assignments; +} + +function tableAlterPkIdentityColumns(rows) { + return rows + .filter(function (row) { + return row.pk; + }) + .map(function (row) { + return row.existing ? row.originalName : row.name.trim(); + }); +} + +function tableAlterPkChanged(state, rows) { + return ( + JSON.stringify(tableAlterPkIdentityColumns(rows)) !== + JSON.stringify(state.originalPrimaryKeys) + ); +} + +function tableAlterNaturalColumnOrder(state, rows) { + var existingRowsByOriginalName = {}; + var newRows = []; + rows.forEach(function (row) { + if (row.existing) { + existingRowsByOriginalName[row.originalName] = row; + } else { + newRows.push(row); + } + }); + var naturalOrder = []; + state.originalColumnNames.forEach(function (originalName) { + var row = existingRowsByOriginalName[originalName]; + if (row) { + naturalOrder.push(row.name.trim()); + } + }); + newRows.forEach(function (row) { + naturalOrder.push(row.name.trim()); + }); + return naturalOrder; +} + +function tableAlterColumnsReordered(state, rows) { + var finalOrder = rows.map(function (row) { + return row.name.trim(); + }); + return ( + JSON.stringify(finalOrder) !== + JSON.stringify(tableAlterNaturalColumnOrder(state, rows)) + ); +} + +function tableAlterForeignKeyIdentity(row) { + return [ + row.name.trim(), + row.foreignKeyTable || "", + row.foreignKeyColumn || "", + ].join("\u001f"); +} + +function tableAlterOriginalForeignKeyIdentity(row) { + return [row.originalName || "", row.originalForeignKey].join("\u001f"); +} + +function tableAlterForeignKeyRows(rows) { + return rows + .filter(function (row) { + return row.foreignKey && row.foreignKeyTable && row.foreignKeyColumn; + }) + .map(function (row) { + return { + column: row.name.trim(), + fk_table: row.foreignKeyTable, + fk_column: row.foreignKeyColumn, + }; + }); +} + +function tableAlterForeignKeysChanged(rows) { + var original = rows + .filter(function (row) { + return row.existing && row.originalForeignKey; + }) + .map(tableAlterOriginalForeignKeyIdentity); + var final = rows + .filter(function (row) { + return row.foreignKey && row.foreignKeyTable && row.foreignKeyColumn; + }) + .map(tableAlterForeignKeyIdentity); + return JSON.stringify(original) !== JSON.stringify(final); +} + +function collectTableAlterPayload(state) { + var rows = collectTableAlterRows(state); + var validationError = validateTableAlterRows(state, rows); + if (validationError) { + return { error: validationError }; + } + + var operations = []; + var tableName = state.tableNameInput.value.trim(); + if (tableName !== state.originalTableName) { + operations.push({ + op: "rename_table", + args: { to: tableName }, + }); + } + var columnTypeAssignments = collectTableAlterColumnTypeAssignments(rows); + rows.forEach(function (row) { + var name = row.name.trim(); + if (!row.existing) { + var addArgs = { + name: name, + type: row.type, + not_null: row.notNull, + }; + if (row.defaultExpr) { + addArgs.default_expr = row.defaultExpr; + } else if (row.defaultValue || row.notNull) { + addArgs.default = row.defaultValue; + } + operations.push({ op: "add_column", args: addArgs }); + return; + } + + var originalName = row.originalName; + if (name !== originalName) { + operations.push({ + op: "rename_column", + args: { name: originalName, to: name }, + }); + } + + var alterArgs = { name: originalName }; + if (row.type !== row.originalType) { + alterArgs.type = row.type; + } + if (row.notNull !== row.originalNotNull) { + alterArgs.not_null = row.notNull; + } + if (row.defaultExpr !== row.originalDefaultExpr) { + if (row.defaultExpr) { + alterArgs.default_expr = row.defaultExpr; + } else { + alterArgs.default = row.defaultValue === "" ? null : row.defaultValue; + } + } else if (row.originalHasDefault) { + if (row.defaultValue !== row.originalDefault) { + alterArgs.default = row.defaultValue === "" ? null : row.defaultValue; + } + } else if (row.defaultValue) { + alterArgs.default = row.defaultValue; + } + if (Object.keys(alterArgs).length > 1) { + operations.push({ op: "alter_column", args: alterArgs }); + } + }); + + state.deletedColumns.forEach(function (name) { + operations.push({ op: "drop_column", args: { name: name } }); + }); + + var pkColumns = rows + .filter(function (row) { + return row.pk; + }) + .map(function (row) { + return row.name.trim(); + }); + if (tableAlterPkChanged(state, rows)) { + operations.push({ op: "set_primary_key", args: { columns: pkColumns } }); + } + + if (tableAlterColumnsReordered(state, rows)) { + operations.push({ + op: "reorder_columns", + args: { + columns: rows.map(function (row) { + return row.name.trim(); + }), + }, + }); + } + + if (tableAlterForeignKeysChanged(rows)) { + operations.push({ + op: "set_foreign_keys", + args: { + foreign_keys: tableAlterForeignKeyRows(rows), + }, + }); + } + + if (!operations.length && !columnTypeAssignments.length) { + return { error: "No changes to apply." }; + } + return { + payload: operations.length ? { operations: operations } : null, + columnTypeAssignments: columnTypeAssignments, + }; +} + +function tableAlterQuotedName(name) { + return '"' + name + '"'; +} + +function tableAlterReadableDefaultExpression(value) { + return defaultExpressionLabelForValue(tableAlterDefaultExpressions(), value); +} + +function tableAlterReadableValue(value) { + if (value === null) { + return "NULL"; + } + return '"' + String(value) + '"'; +} + +function tableAlterOperationSummary(operation) { + var args = operation.args || {}; + if (operation.op === "rename_table") { + return { + text: "Rename table to " + tableAlterQuotedName(args.to) + ".", + damaging: false, + }; + } + if (operation.op === "add_column") { + var addDetails = ["as " + args.type]; + if (args.not_null) { + addDetails.push("with values required"); + } + if (args.default_expr) { + addDetails.push( + "defaulting to " + + tableAlterReadableDefaultExpression(args.default_expr), + ); + } else if (Object.prototype.hasOwnProperty.call(args, "default")) { + addDetails.push( + "with default value " + tableAlterReadableValue(args.default), + ); + } + return { + text: + "Add column " + + tableAlterQuotedName(args.name) + + " " + + addDetails.join(", ") + + ".", + damaging: false, + }; + } + if (operation.op === "rename_column") { + return { + text: + "Rename column " + + tableAlterQuotedName(args.name) + + " to " + + tableAlterQuotedName(args.to) + + ".", + damaging: false, + }; + } + if (operation.op === "alter_column") { + var changes = []; + if (args.type) { + changes.push("set type to " + args.type); + } + if (Object.prototype.hasOwnProperty.call(args, "not_null")) { + changes.push( + args.not_null ? "not null (require values)" : "allow unset values", + ); + } + if (args.default_expr) { + changes.push( + "default to " + tableAlterReadableDefaultExpression(args.default_expr), + ); + } else if (Object.prototype.hasOwnProperty.call(args, "default")) { + changes.push( + args.default === null + ? "remove the default value" + : "set default value to " + tableAlterReadableValue(args.default), + ); + } + return { + text: + "Change column " + + tableAlterQuotedName(args.name) + + ": " + + changes.join(", ") + + ".", + damaging: false, + }; + } + if (operation.op === "drop_column") { + return { + text: "Drop column " + tableAlterQuotedName(args.name) + ".", + damaging: true, + }; + } + if (operation.op === "set_primary_key") { + return { + text: + "Set primary key to " + + (args.columns || []).map(tableAlterQuotedName).join(", ") + + ".", + damaging: false, + }; + } + if (operation.op === "reorder_columns") { + return { + text: + "Set column order to " + + (args.columns || []).map(tableAlterQuotedName).join(", ") + + ".", + damaging: false, + }; + } + if (operation.op === "set_foreign_keys") { + var foreignKeys = args.foreign_keys || []; + return { + text: foreignKeys.length + ? "Set foreign keys to " + + foreignKeys + .map(function (foreignKey) { + return ( + tableAlterQuotedName(foreignKey.column) + + " -> " + + foreignKey.fk_table + + "." + + foreignKey.fk_column + ); + }) + .join(", ") + + "." + : "Remove all foreign keys.", + damaging: false, + }; + } + return { + text: "Run " + operation.op + ".", + damaging: false, + }; +} + +function tableAlterColumnTypeAssignmentSummary(assignment) { + return { + text: assignment.columnType + ? "Set custom type for column " + + tableAlterQuotedName(assignment.column) + + " to " + + assignment.columnType + + "." + : "Remove custom type from column " + + tableAlterQuotedName(assignment.column) + + ".", + damaging: false, + }; +} + +function tableAlterReviewItems(result) { + var items = []; + var operations = result.payload ? result.payload.operations || [] : []; + operations.forEach(function (operation) { + items.push(tableAlterOperationSummary(operation)); + }); + (result.columnTypeAssignments || []).forEach(function (assignment) { + items.push(tableAlterColumnTypeAssignmentSummary(assignment)); + }); + return items; +} + +function tableAlterReviewHasDamagingItems(items) { + return items.some(function (item) { + return item.damaging; + }); +} + +function appendTableAlterReviewText(element, text) { + text.split(/("[^"]+")/g).forEach(function (part) { + if (!part) { + return; + } + if (part.charAt(0) === '"' && part.charAt(part.length - 1) === '"') { + var name = document.createElement("code"); + name.className = "table-alter-review-name"; + name.textContent = part.slice(1, -1); + element.appendChild(name); + } else { + element.appendChild(document.createTextNode(part)); + } + }); +} + +function tableAlterSetColumnTypeUrl(tableUrl) { + if (tableUrl) { + return tableUrl.replace(/\/$/, "") + "/-/set-column-type"; + } + var data = tableAlterData(); + if (!data || !data.path) { + return null; + } + var url = new URL(data.path, location.href); + url.pathname = url.pathname.replace(/\/-\/alter\/?$/, "/-/set-column-type"); + return url.toString(); +} + +async function assignTableAlterColumnTypes(assignments, tableUrl) { + if (!assignments.length) { + return; + } + var url = tableAlterSetColumnTypeUrl(tableUrl); + if (!url) { + throw new Error("Could not find the set column type URL."); + } + for (var i = 0; i < assignments.length; i += 1) { + var assignment = assignments[i]; + var response = await fetch(url, { + method: "POST", + headers: { + "Content-Type": "application/json", + Accept: "application/json", + }, + body: JSON.stringify({ + column: assignment.column, + column_type: assignment.columnType + ? { + type: assignment.columnType, + } + : null, + }), + }); + var data = null; + try { + data = await response.json(); + } catch (_error) { + data = null; + } + if (!response.ok || (data && data.ok === false)) { + var error = rowMutationRequestError(response, data); + throw new Error( + "Saved schema changes, but could not set custom type for " + + assignment.column + + ": " + + error.message, + ); + } + } +} + +function tableAlterResultRenamesTable(result) { + return !!( + result && + result.payload && + (result.payload.operations || []).some(function (operation) { + return operation.op === "rename_table"; + }) + ); +} + +function showTableAlterEditor(state) { + state.mode = "edit"; + state.reviewResult = null; + state.dialog.classList.remove("table-alter-reviewing"); + state.fields.hidden = false; + state.review.hidden = true; + state.review.textContent = ""; + state.backButton.hidden = true; + var data = tableAlterData(); + state.dropButton.hidden = !(data && data.dropPath); + state.saveButton.textContent = tableAlterSaveButtonText(state); + updateTableAlterMoveButtons(state); + updateTableAlterSaveButtonState(state); +} + +function showTableAlterReview(state, result) { + var items = tableAlterReviewItems(result); + state.mode = "review"; + state.reviewResult = result; + state.dialog.classList.add("table-alter-reviewing"); + state.fields.hidden = true; + state.review.hidden = false; + state.review.textContent = ""; + state.backButton.hidden = false; + state.dropButton.hidden = true; + state.saveButton.textContent = tableAlterSaveButtonText(state); + updateTableAlterSaveButtonState(state); + + var heading = document.createElement("h3"); + heading.className = "table-alter-review-title"; + heading.tabIndex = -1; + heading.textContent = "Review changes"; + state.review.appendChild(heading); + + var intro = document.createElement("p"); + intro.className = "table-alter-review-intro"; + intro.textContent = "These changes will be applied to the table."; + state.review.appendChild(intro); + + if (tableAlterReviewHasDamagingItems(items)) { + var warning = document.createElement("p"); + warning.className = "table-alter-review-warning"; + warning.setAttribute("role", "alert"); + warning.textContent = + "Warning: data in dropped columns will be permanently lost."; + state.review.appendChild(warning); + } + + var list = document.createElement("ol"); + list.className = "table-alter-review-list"; + items.forEach(function (item) { + var listItem = document.createElement("li"); + appendTableAlterReviewText(listItem, item.text); + if (item.damaging) { + listItem.className = "table-alter-review-damaging"; + } + list.appendChild(listItem); + }); + state.review.appendChild(list); + heading.focus(); +} + +async function applyTableAlterChanges(state, result) { + if (state.isSaving) { + return; + } + if (!result) { + showTableAlterDialogError(state, "Could not find the reviewed changes."); + return; + } + var data = tableAlterData(); + if (!data || !data.path) { + showTableAlterDialogError(state, "Could not find the alter table URL."); + return; + } + clearTableAlterDialogError(state); + if (result.error) { + showTableAlterDialogError(state, result.error); + return; + } + setTableAlterDialogSaving(state, true); + try { + var responseData = null; + if (result.payload) { + var response = await fetch(data.path, { + method: "POST", + headers: { + "Content-Type": "application/json", + Accept: "application/json", + }, + body: JSON.stringify(result.payload), + }); + try { + responseData = await response.json(); + } catch (_error) { + responseData = null; + } + if (!response.ok || (responseData && responseData.ok === false)) { + throw rowMutationRequestError(response, responseData); + } + } + var tableUrl = responseData && responseData.table_url; + await assignTableAlterColumnTypes( + result.columnTypeAssignments || [], + tableUrl, + ); + state.shouldRestoreFocus = false; + state.dialog.close(); + if (tableAlterResultRenamesTable(result) && tableUrl) { + window.location.href = tableUrl; + } else { + location.reload(); + } + } catch (error) { + setTableAlterDialogSaving(state, false); + showTableAlterDialogError(state, error.message || "Could not alter table"); + } +} + +function tableAlterDatabaseUrl() { + var data = tableAlterData(); + if (!data || !data.path) { + return null; + } + var url = new URL(data.path, location.href); + url.pathname = url.pathname.replace(/\/[^/]+\/-\/alter\/?$/, ""); + url.search = ""; + url.hash = ""; + return url.toString(); +} + +async function dropTableFromAlterDialog(state) { + if (state.isSaving) { + return; + } + var data = tableAlterData(); + if (!data || !data.dropPath) { + return; + } + if ( + !window.confirm( + 'Permanently delete the table "' + + data.tableName + + '"? This will delete all of its data and cannot be undone.', + ) + ) { + return; + } + clearTableAlterDialogError(state); + setTableAlterDialogSaving(state, true); + try { + var response = await fetch(data.dropPath, { + method: "POST", + headers: { + "Content-Type": "application/json", + Accept: "application/json", + }, + body: JSON.stringify({ confirm: true }), + }); + var responseData = null; + try { + responseData = await response.json(); + } catch (_error) { + responseData = null; + } + if (!response.ok || (responseData && responseData.ok === false)) { + throw rowMutationRequestError(response, responseData); + } + state.shouldRestoreFocus = false; + state.dialog.close(); + window.location.href = tableAlterDatabaseUrl() || "/"; + } catch (error) { + setTableAlterDialogSaving(state, false); + showTableAlterDialogError(state, error.message || "Could not drop table"); + } +} + +async function saveTableAlterDialog(state) { + if (state.isSaving) { + return; + } + if (state.mode === "review") { + if (!state.reviewResult) { + showTableAlterDialogError(state, "Could not find the reviewed changes."); + return; + } + await applyTableAlterChanges(state, state.reviewResult); + return; + } + clearTableAlterDialogError(state); + var result = collectTableAlterPayload(state); + if (result.error) { + showTableAlterDialogError(state, result.error); + return; + } + showTableAlterReview(state, result); +} + +function confirmDiscardTableAlterChanges(state) { + if (!tableAlterDialogHasChanges(state)) { + return true; + } + return window.confirm("Discard table changes?"); +} + +function closeTableAlterDialogIfConfirmed(state) { + if (!state || state.isSaving) { + return false; + } + if (!confirmDiscardTableAlterChanges(state)) { + return false; + } + state.shouldRestoreFocus = true; + state.dialog.close(); + return true; +} + +function closeTableAlterDialog(state) { + if (!state || state.isSaving) { + return false; + } + state.shouldRestoreFocus = true; + state.dialog.close(); + return true; +} + +function ensureTableAlterDialog(manager) { + if (tableAlterDialogState) { + return tableAlterDialogState; + } + if (!window.HTMLDialogElement) { + return null; + } + + var dialog = document.createElement("dialog"); + dialog.id = TABLE_ALTER_DIALOG_ID; + dialog.className = "table-alter-dialog"; + dialog.setAttribute("aria-labelledby", "table-alter-title"); + dialog.innerHTML = ` + + + +
+
+ +
+ +
+
+ Rename table +
+ + +
+
+
+ + + + `; + document.body.appendChild(dialog); + + tableAlterDialogState = { + dialog: dialog, + form: dialog.querySelector(".table-alter-form"), + title: dialog.querySelector(".modal-title"), + error: dialog.querySelector(".table-alter-error"), + fields: dialog.querySelector(".table-alter-fields"), + tableOptions: dialog.querySelector(".table-alter-table-options"), + tableNameInput: dialog.querySelector(".table-alter-table-name"), + review: dialog.querySelector(".table-alter-review"), + columnList: dialog.querySelector(".table-alter-column-list"), + addColumnButton: dialog.querySelector(".table-alter-add-column"), + backButton: dialog.querySelector(".table-alter-back"), + dropButton: dialog.querySelector(".table-alter-drop"), + cancelButton: dialog.querySelector(".table-alter-cancel"), + saveButton: dialog.querySelector(".table-alter-save"), + currentButton: null, + shouldRestoreFocus: true, + isSaving: false, + initialSignature: "", + originalTableName: "", + nextColumnIndex: 0, + deletedColumns: [], + originalColumnNames: [], + originalPrimaryKeys: [], + foreignKeyTargets: [], + foreignKeyTargetsError: null, + foreignKeyTargetsLoading: false, + mode: "edit", + reviewResult: null, + manager: manager, + }; + + tableAlterDialogState.form.addEventListener("submit", function (ev) { + ev.preventDefault(); + saveTableAlterDialog(tableAlterDialogState); + }); + + tableAlterDialogState.tableNameInput.addEventListener("input", function () { + clearTableAlterDialogError(tableAlterDialogState); + updateTableAlterSaveButtonState(tableAlterDialogState); + }); + + tableAlterDialogState.addColumnButton.addEventListener("click", function () { + if (tableAlterDialogState.isSaving) { + return; + } + var row = addTableAlterColumn(tableAlterDialogState, { + type: "text", + existing: false, + expanded: true, + }); + clearTableAlterDialogError(tableAlterDialogState); + updateTableAlterMoveButtons(tableAlterDialogState); + updateTableAlterSaveButtonState(tableAlterDialogState); + row.querySelector(".table-alter-column-name").focus(); + }); + + tableAlterDialogState.cancelButton.addEventListener("click", function () { + closeTableAlterDialog(tableAlterDialogState); + }); + + tableAlterDialogState.dropButton.addEventListener("click", function () { + dropTableFromAlterDialog(tableAlterDialogState); + }); + + tableAlterDialogState.backButton.addEventListener("click", function () { + if (tableAlterDialogState.isSaving) { + return; + } + clearTableAlterDialogError(tableAlterDialogState); + showTableAlterEditor(tableAlterDialogState); + var firstName = tableAlterDialogState.columnList.querySelector( + ".table-alter-column-name", + ); + if (firstName) { + firstName.focus(); + } + }); + + dialog.addEventListener("click", function (ev) { + if (ev.target === dialog) { + closeTableAlterDialogIfConfirmed(tableAlterDialogState); + } + }); + + dialog.addEventListener("keydown", function (ev) { + if (ev.key !== "Escape") { + return; + } + ev.preventDefault(); + closeTableAlterDialogIfConfirmed(tableAlterDialogState); + }); + + dialog.addEventListener("cancel", function (ev) { + ev.preventDefault(); + closeTableAlterDialogIfConfirmed(tableAlterDialogState); + }); + + dialog.addEventListener("close", function () { + var state = tableAlterDialogState; + clearTableAlterDialogError(state); + setTableAlterDialogSaving(state, false); + if ( + state.shouldRestoreFocus && + state.currentButton && + document.contains(state.currentButton) + ) { + state.currentButton.focus(); + } + }); + + return tableAlterDialogState; +} + +function openTableAlterDialog(button, manager) { + var data = tableAlterData(); + if (!data) { + return; + } + var state = ensureTableAlterDialog(manager); + if (!state) { + return; + } + + var menu = button.closest("details"); + if (menu) { + menu.open = false; + } + state.manager = manager; + state.currentButton = button; + state.shouldRestoreFocus = true; + state.title.textContent = "Alter table " + data.tableName; + clearTableAlterDialogError(state); + resetTableAlterDialog(state, data); + loadSchemaDialogForeignKeyTargets( + state, + "table-alter", + tableAlterForeignKeyTargetsUrl(), + { filterByType: false }, + ); + if (!state.dialog.open) { + state.dialog.showModal(); + } + var firstName = state.columnList.querySelector(".table-alter-column-name"); + if (firstName) { + firstName.focus(); + } +} + +function initTableAlterActions(manager) { + if (!window.fetch || !window.HTMLDialogElement || !tableAlterData()) { + return; + } + document.addEventListener("click", function (ev) { + var button = ev.target.closest('button[data-table-action="alter-table"]'); + if (!button) { + return; + } + ev.preventDefault(); + openTableAlterDialog(button, manager); + }); +} + function tableForeignKeys() { return tablePageData().foreignKeys || {}; } @@ -2017,6 +5285,8 @@ document.addEventListener("datasette_init", function (evt) { const { detail: manager } = evt; registerBuiltinColumnFieldPlugins(manager); + initTableCreateActions(manager); + initTableAlterActions(manager); initRowInsertActions(manager); initRowEditActions(manager); initRowDeleteActions(manager); diff --git a/datasette/templates/database.html b/datasette/templates/database.html index 371f6a22..23eeb571 100644 --- a/datasette/templates/database.html +++ b/datasette/templates/database.html @@ -6,6 +6,10 @@ {{- super() -}} {% include "_codemirror.html" %} {% include "_sql_parameter_styles.html" %} +{% if database_page_data.createTable %} + + +{% endif %} {% endblock %} {% block body_class %}db db-{{ database|to_css_class }}{% endblock %} diff --git a/datasette/views/database.py b/datasette/views/database.py index cd9565c6..8e9c1361 100644 --- a/datasette/views/database.py +++ b/datasette/views/database.py @@ -6,11 +6,8 @@ import itertools import json import markupsafe import os -import re -import sqlite_utils import textwrap -from datasette.events import AlterTableEvent, CreateTableEvent, InsertRowsEvent from datasette.extras import extra_names_from_request from datasette.database import QueryInterrupted from datasette.resources import DatabaseResource, QueryResource @@ -37,13 +34,14 @@ from datasette.utils import ( from datasette.utils.asgi import AsgiFileDownload, NotFound, Response, Forbidden from datasette.plugins import pm -from .base import BaseView, DatasetteError, View, _error, stream_csv +from .base import DatasetteError, View, stream_csv from .query_helpers import _ensure_stored_query_execution_permissions, _table_columns from .table_extras import ( QueryExtraContext, resolve_query_extras, table_extra_registry, ) +from .table_create_alter import _create_table_ui_context from . import Context @@ -117,21 +115,36 @@ class DatabaseView(View): else len(stored_queries) ) + # Resolve the registered database-level actions for this database in + # one batched query, seeding the request permission cache so allowed() + # calls made inside plugin hooks below are served from the cache. + database_action_permissions = await datasette.allowed_many( + actions=[ + name + for name, action in datasette.actions.items() + if action.resource_class is DatabaseResource + ], + resource=DatabaseResource(database), + actor=request.actor, + ) + create_table_ui = await _create_table_ui_context( + datasette, request, db, database, database_action_permissions + ) + async def database_actions(): - # Resolve the registered database-level actions for this - # database in one batched query, seeding the request permission - # cache so that allowed() calls made inside the plugin hooks - # below are served from the cache - await datasette.allowed_many( - actions=[ - name - for name, action in datasette.actions.items() - if action.resource_class is DatabaseResource - ], - resource=DatabaseResource(database), - actor=request.actor, - ) links = [] + if create_table_ui: + links.append( + { + "type": "button", + "label": "Create table", + "description": "Create a new table in this database.", + "attrs": { + "aria-label": "Create table in {}".format(database), + "data-database-action": "create-table", + }, + } + ) for hook in pm.hook.database_actions( datasette=datasette, database=database, @@ -211,6 +224,9 @@ class DatabaseView(View): ), metadata=metadata, database_color=db.color, + database_page_data=( + {"createTable": create_table_ui} if create_table_ui else {} + ), database_actions=database_actions, show_hidden=request.args.get("_show_hidden"), editable=True, @@ -263,6 +279,9 @@ class DatabaseContext(Context): ) metadata: dict = field(metadata={"help": "Metadata for the database"}) database_color: str = field(metadata={"help": "The color assigned to the database"}) + database_page_data: dict = field( + metadata={"help": "JSON data used by JavaScript on the database page"} + ) database_actions: callable = field( metadata={ "help": "Callable returning list of action links for the database menu" @@ -1055,260 +1074,6 @@ class MagicParameters(dict): return super().__getitem__(key) -class TableCreateView(BaseView): - name = "table-create" - - _valid_keys = { - "table", - "rows", - "row", - "columns", - "pk", - "pks", - "ignore", - "replace", - "alter", - } - _supported_column_types = { - "text", - "integer", - "float", - "blob", - } - # Any string that does not contain a newline or start with sqlite_ - _table_name_re = re.compile(r"^(?!sqlite_)[^\n]+$") - - def __init__(self, datasette): - self.ds = datasette - - async def post(self, request): - db = await self.ds.resolve_database(request) - database_name = db.name - - # Must have create-table permission - if not await self.ds.allowed( - action="create-table", - resource=DatabaseResource(database=database_name), - actor=request.actor, - ): - return _error(["Permission denied"], 403) - - try: - data = await request.json() - except json.JSONDecodeError as e: - return _error(["Invalid JSON: {}".format(e)]) - - if not isinstance(data, dict): - return _error(["JSON must be an object"]) - - invalid_keys = set(data.keys()) - self._valid_keys - if invalid_keys: - return _error(["Invalid keys: {}".format(", ".join(invalid_keys))]) - - # ignore and replace are mutually exclusive - if data.get("ignore") and data.get("replace"): - return _error(["ignore and replace are mutually exclusive"]) - - # ignore and replace only allowed with row or rows - if "ignore" in data or "replace" in data: - if not data.get("row") and not data.get("rows"): - return _error(["ignore and replace require row or rows"]) - - # ignore and replace require pk or pks - if "ignore" in data or "replace" in data: - if not data.get("pk") and not data.get("pks"): - return _error(["ignore and replace require pk or pks"]) - - ignore = data.get("ignore") - replace = data.get("replace") - - if replace: - # Must have update-row permission - if not await self.ds.allowed( - action="update-row", - resource=DatabaseResource(database=database_name), - actor=request.actor, - ): - return _error(["Permission denied: need update-row"], 403) - - table_name = data.get("table") - if not table_name: - return _error(["Table is required"]) - - if not self._table_name_re.match(table_name): - return _error(["Invalid table name"]) - - table_exists = await db.table_exists(data["table"]) - columns = data.get("columns") - rows = data.get("rows") - row = data.get("row") - if not columns and not rows and not row: - return _error(["columns, rows or row is required"]) - - if rows and row: - return _error(["Cannot specify both rows and row"]) - - if rows or row: - # Must have insert-row permission - if not await self.ds.allowed( - action="insert-row", - resource=DatabaseResource(database=database_name), - actor=request.actor, - ): - return _error(["Permission denied: need insert-row"], 403) - - alter = False - if rows or row: - if not table_exists: - # if table is being created for the first time, alter=True - alter = True - else: - # alter=True only if they request it AND they have permission - if data.get("alter"): - if not await self.ds.allowed( - action="alter-table", - resource=DatabaseResource(database=database_name), - actor=request.actor, - ): - return _error(["Permission denied: need alter-table"], 403) - alter = True - - if columns: - if rows or row: - return _error(["Cannot specify columns with rows or row"]) - if not isinstance(columns, list): - return _error(["columns must be a list"]) - for column in columns: - if not isinstance(column, dict): - return _error(["columns must be a list of objects"]) - if not column.get("name") or not isinstance(column.get("name"), str): - return _error(["Column name is required"]) - if not column.get("type"): - column["type"] = "text" - if column["type"] not in self._supported_column_types: - return _error( - ["Unsupported column type: {}".format(column["type"])] - ) - # No duplicate column names - dupes = {c["name"] for c in columns if columns.count(c) > 1} - if dupes: - return _error(["Duplicate column name: {}".format(", ".join(dupes))]) - - if row: - rows = [row] - - if rows: - if not isinstance(rows, list): - return _error(["rows must be a list"]) - for row in rows: - if not isinstance(row, dict): - return _error(["rows must be a list of objects"]) - - pk = data.get("pk") - pks = data.get("pks") - - if pk and pks: - return _error(["Cannot specify both pk and pks"]) - if pk: - if not isinstance(pk, str): - return _error(["pk must be a string"]) - if pks: - if not isinstance(pks, list): - return _error(["pks must be a list"]) - for pk in pks: - if not isinstance(pk, str): - return _error(["pks must be a list of strings"]) - - # If table exists already, read pks from that instead - if table_exists: - actual_pks = await db.primary_keys(table_name) - # if pk passed and table already exists check it does not change - bad_pks = False - if len(actual_pks) == 1 and data.get("pk") and data["pk"] != actual_pks[0]: - bad_pks = True - elif ( - len(actual_pks) > 1 - and data.get("pks") - and set(data["pks"]) != set(actual_pks) - ): - bad_pks = True - if bad_pks: - return _error(["pk cannot be changed for existing table"]) - pks = actual_pks - - initial_schema = None - if table_exists: - initial_schema = await db.execute_fn( - lambda conn: sqlite_utils.Database(conn)[table_name].schema - ) - - def create_table(conn): - table = sqlite_utils.Database(conn)[table_name] - if rows: - table.insert_all( - rows, pk=pks or pk, ignore=ignore, replace=replace, alter=alter - ) - else: - table.create( - {c["name"]: c["type"] for c in columns}, - pk=pks or pk, - ) - return table.schema - - try: - schema = await db.execute_write_fn(create_table, request=request) - except Exception as e: - return _error([str(e)]) - - if initial_schema is not None and initial_schema != schema: - await self.ds.track_event( - AlterTableEvent( - request.actor, - database=database_name, - table=table_name, - before_schema=initial_schema, - after_schema=schema, - ) - ) - - table_url = self.ds.absolute_url( - request, self.ds.urls.table(db.name, table_name) - ) - table_api_url = self.ds.absolute_url( - request, self.ds.urls.table(db.name, table_name, format="json") - ) - details = { - "ok": True, - "database": db.name, - "table": table_name, - "table_url": table_url, - "table_api_url": table_api_url, - "schema": schema, - } - if rows: - details["row_count"] = len(rows) - - if not table_exists: - # Only log creation if we created a table - await self.ds.track_event( - CreateTableEvent( - request.actor, database=db.name, table=table_name, schema=schema - ) - ) - if rows: - await self.ds.track_event( - InsertRowsEvent( - request.actor, - database=db.name, - table=table_name, - num_rows=len(rows), - ignore=ignore, - replace=replace, - ) - ) - return Response.json(details, status=201) - - async def display_rows(datasette, database, request, rows, columns): display_rows = [] truncate_cells = datasette.setting("truncate_cells_html") diff --git a/datasette/views/row.py b/datasette/views/row.py index 4d61eb91..e3575b6c 100644 --- a/datasette/views/row.py +++ b/datasette/views/row.py @@ -205,13 +205,14 @@ class RowView(DataView): ], "row_mutation_ui": any(row_action_permissions.values()), "table_page_data": await _table_page_data( - self.ds, - request, - db, - database, - table, - not is_table, - None, + datasette=self.ds, + request=request, + db=db, + database_name=database, + table_name=table, + is_view=not is_table, + table_insert_ui=None, + table_alter_ui=None, ), "row_actions": row_actions, "top_row": make_slot_function( diff --git a/datasette/views/table.py b/datasette/views/table.py index 0e291524..855c62cd 100644 --- a/datasette/views/table.py +++ b/datasette/views/table.py @@ -48,9 +48,18 @@ from datasette.filters import Filters import sqlite_utils from .base import BaseView, DatasetteError, _error, stream_csv from .database import QueryView +from .table_create_alter import ( + ALTER_TABLE_COLUMN_TYPES, + ALTER_TABLE_TYPE_FOR_SQLITE_TYPE, + _custom_column_type_options_for_create_table, + default_expr_for_sql, + default_expression_options, +) from .table_extras import ( TABLE_EXTRA_BUNDLES, TableExtraContext, + precompute_database_action_permissions, + precompute_table_action_permissions, resolve_table_extras, table_extra_registry, ) @@ -280,7 +289,14 @@ async def _foreign_key_autocomplete_urls( async def _table_page_data( - datasette, request, db, database_name, table_name, is_view, table_insert_ui + datasette, + request, + db, + database_name, + table_name, + is_view, + table_insert_ui, + table_alter_ui, ): data = { "database": database_name, @@ -289,6 +305,8 @@ async def _table_page_data( } if table_insert_ui: data["insertRow"] = table_insert_ui + if table_alter_ui: + data["alterTable"] = table_alter_ui if not is_view: foreign_keys = await _foreign_key_autocomplete_urls( datasette, request, db, database_name, table_name @@ -351,6 +369,92 @@ async def _table_insert_ui( } +async def _table_alter_ui( + datasette, request, db, database_name, table_name, is_view, pks +): + if is_view or not db.is_mutable: + return None + + if not await datasette.allowed( + action="alter-table", + resource=TableResource(database=database_name, table=table_name), + actor=request.actor, + ): + return None + + column_types_map = await datasette.get_column_types(database_name, table_name) + foreign_keys_by_column = {} + for fk in await db.foreign_keys_for_table(table_name): + other_column = fk["other_column"] + if other_column is None and await db.table_exists(fk["other_table"]): + other_pks = await db.primary_keys(fk["other_table"]) + if len(other_pks) == 1: + other_column = other_pks[0] + if other_column is None: + continue + foreign_keys_by_column[fk["column"]] = { + "fk_table": fk["other_table"], + "fk_column": other_column, + } + columns = [] + for column in await db.table_column_details(table_name): + if column.hidden: + continue + sqlite_type = SQLiteType.from_declared_type(column.type) + column_type = column_types_map.get(column.name) + default_expr = default_expr_for_sql(column.default_value) + column_data = { + "name": column.name, + "type": ALTER_TABLE_TYPE_FOR_SQLITE_TYPE.get(sqlite_type, "text"), + "sqlite_type": sqlite_type.value, + "notnull": column.notnull, + "default": None if default_expr else column.default_value, + "has_default": column.default_value is not None, + "is_pk": column.name in pks, + "foreign_key": foreign_keys_by_column.get(column.name), + "column_type": ( + {"type": column_type.name, "config": column_type.config} + if column_type is not None + else None + ), + } + if default_expr: + column_data["default_expr"] = default_expr + columns.append(column_data) + + data = { + "path": "{}/-/alter".format(datasette.urls.table(database_name, table_name)), + "tableName": table_name, + "columns": columns, + "primaryKeys": pks, + "columnTypes": ALTER_TABLE_COLUMN_TYPES, + "defaultExpressions": default_expression_options(), + "foreignKeyTargetsPath": "{}/-/foreign-key-targets?table={}".format( + datasette.urls.database(database_name), + urllib.parse.quote(table_name, safe=""), + ), + } + can_set_column_type = await datasette.allowed( + action="set-column-type", + resource=TableResource(database=database_name, table=table_name), + actor=request.actor, + ) + if can_set_column_type: + data["customColumnTypes"] = _custom_column_type_options_for_create_table( + datasette + ) + can_drop_table = await datasette.allowed( + action="drop-table", + resource=TableResource(database=database_name, table=table_name), + actor=request.actor, + ) + if can_drop_table: + data["dropPath"] = "{}/-/drop".format( + datasette.urls.table(database_name, table_name) + ) + return data + + async def display_columns_and_rows( datasette, database_name, @@ -1119,6 +1223,11 @@ class TableDropView(BaseView): actor=request.actor, database=database_name, table=table_name ) ) + self.ds.add_message( + request, + "Table {} dropped".format(table_name), + self.ds.WARNING, + ) return Response.json({"ok": True}, status=200) @@ -1642,6 +1751,15 @@ async def table_view_data( if redirect_response: return redirect_response + if context_for_html_hack: + await precompute_database_action_permissions( + datasette, request.actor, database_name + ) + if not is_view: + await precompute_table_action_permissions( + datasette, request.actor, database_name, table_name + ) + # Introspect columns and primary keys for table pks = await db.primary_keys(table_name) table_columns = await db.table_columns(table_name) @@ -2068,15 +2186,20 @@ async def table_view_data( table_insert_ui = await _table_insert_ui( datasette, request, db, database_name, table_name, is_view, pks ) + table_alter_ui = await _table_alter_ui( + datasette, request, db, database_name, table_name, is_view, pks + ) data["table_insert_ui"] = table_insert_ui + data["table_alter_ui"] = table_alter_ui data["table_page_data"] = await _table_page_data( - datasette, - request, - db, - database_name, - table_name, - is_view, - table_insert_ui, + datasette=datasette, + request=request, + db=db, + database_name=database_name, + table_name=table_name, + is_view=is_view, + table_insert_ui=table_insert_ui, + table_alter_ui=table_alter_ui, ) return data, rows[:page_size], columns, expanded_columns, sql, next_url diff --git a/datasette/views/table_create_alter.py b/datasette/views/table_create_alter.py new file mode 100644 index 00000000..ffeb7f14 --- /dev/null +++ b/datasette/views/table_create_alter.py @@ -0,0 +1,1353 @@ +import json +import re +import time +from typing import Annotated, Any, Literal, Union + +from datasette.database import QueryInterrupted +from pydantic import ( + BaseModel, + ConfigDict, + Field, + ValidationError, + field_validator, + model_validator, +) +from pydantic_core import PydanticCustomError +import sqlite_utils +from sqlite_utils.db import DEFAULT as SQLITE_UTILS_DEFAULT + +from datasette.column_types import SQLiteType +from datasette.events import AlterTableEvent, CreateTableEvent, InsertRowsEvent +from datasette.resources import DatabaseResource, TableResource +from datasette.utils import ( + escape_sqlite, + get_outbound_foreign_keys, + table_column_details, +) +from datasette.utils.asgi import NotFound, Response +from datasette.utils.sqlite import sqlite_hidden_table_names + +from .base import BaseView, _error + +CREATE_TABLE_COLUMN_TYPES = ["text", "integer", "float", "blob"] +CREATE_TABLE_SQLITE_TYPES = { + "text": SQLiteType.TEXT, + "integer": SQLiteType.INTEGER, + "float": SQLiteType.REAL, + "blob": SQLiteType.BLOB, +} +CREATE_TABLE_TYPE_FOR_SQLITE_TYPE = { + sqlite_type: column_type + for column_type, sqlite_type in CREATE_TABLE_SQLITE_TYPES.items() +} +TABLE_NAME_RE = re.compile(r"^(?!sqlite_)[^\n]+$") +ALTER_TABLE_COLUMN_TYPES = CREATE_TABLE_COLUMN_TYPES +ALTER_TABLE_TYPE_FOR_SQLITE_TYPE = { + SQLiteType.TEXT: "text", + SQLiteType.INTEGER: "integer", + SQLiteType.REAL: "float", + SQLiteType.BLOB: "blob", +} +FOREIGN_KEY_SUGGESTION_ROW_LIMIT = 500 +FOREIGN_KEY_SUGGESTION_TIME_LIMIT_MS = 50 +FOREIGN_KEY_SUGGESTION_TOTAL_TIME_LIMIT_MS = 200 +FOREIGN_KEY_TARGETS_SQL = """ +select + m.name as fk_table, + p.name as fk_column, + case + when upper(coalesce(p.type, '')) like '%INT%' then 'integer' + when upper(coalesce(p.type, '')) like '%CHAR%' + or upper(coalesce(p.type, '')) like '%CLOB%' + or upper(coalesce(p.type, '')) like '%TEXT%' then 'text' + when upper(coalesce(p.type, '')) like '%BLOB%' + or coalesce(p.type, '') = '' then 'blob' + when upper(coalesce(p.type, '')) like '%REAL%' + or upper(coalesce(p.type, '')) like '%FLOA%' + or upper(coalesce(p.type, '')) like '%' || 'DOU' || 'B' || '%' then 'real' + else 'numeric' + end as type +from sqlite_master as m +cross join pragma_table_info(m.name) as p +where m.type = 'table' + and m.name not like 'sqlite_%' + and p.pk > 0 + and ( + select count(*) + from pragma_table_info(m.name) as p2 + where p2.pk > 0 + ) = 1 +order by m.name +""" + + +class ForeignKeySuggestionTimedOut(Exception): + pass + + +def _sqlite_type_affinity(type_name): + type_name = (type_name or "").upper() + if "INT" in type_name: + return "integer" + if any(token in type_name for token in ("CHAR", "CLOB", "TEXT")): + return "text" + if "BLOB" in type_name or not type_name: + return "blob" + if any( + token in type_name + for token in ("REAL", "FLOA", "DOUB") # codespell:ignore doub + ): + return "real" + return "numeric" + + +def _foreign_key_type_compatible(source_affinity, target_affinity): + if source_affinity == target_affinity: + return True + numeric_affinities = {"integer", "real", "numeric"} + if source_affinity == "numeric": + return target_affinity in numeric_affinities + if target_affinity == "numeric": + return source_affinity in numeric_affinities + return False + + +def _public_foreign_key_target(target): + return { + "fk_table": target["fk_table"], + "fk_column": target["fk_column"], + "type": target["type"], + } + + +def _singular(name): + if name.endswith("ies") and len(name) > 3: + return name[:-3] + "y" + if name.endswith("s") and len(name) > 1: + return name[:-1] + return name + + +def _foreign_key_name_reasons(source_column, target): + source = source_column.lower() + table = target["fk_table"].lower() + singular_table = _singular(table) + column = target["fk_column"].lower() + possible_names = { + "{}_{}".format(table, column), + "{}_{}".format(singular_table, column), + } + if column == "id": + possible_names.update( + { + "{}_id".format(table), + "{}_id".format(singular_table), + } + ) + return ["name_match"] if source in possible_names else [] + + +def _foreign_key_option_sort_key(source_column, target): + has_name_match = bool(_foreign_key_name_reasons(source_column, target)) + return ( + 0 if has_name_match else 1, + target["fk_table"], + target["fk_column"], + ) + + +def _foreign_key_suggestion_metadata(conn, table_name): + hidden_tables = set(sqlite_hidden_table_names(conn)) + source_columns = [ + { + "column": column.name, + "type": (column.type or "").upper(), + "affinity": _sqlite_type_affinity(column.type), + } + for column in table_column_details(conn, table_name) + if not column.hidden + ] + current_by_column = { + fk["column"]: { + "fk_table": fk["other_table"], + "fk_column": fk["other_column"], + } + for fk in get_outbound_foreign_keys(conn, table_name) + } + table_names = [ + row[0] + for row in conn.execute( + "select name from sqlite_master where type = 'table' order by name" + ).fetchall() + if not row[0].startswith("sqlite_") + ] + targets = [] + for candidate_table in table_names: + if candidate_table == table_name or candidate_table in hidden_tables: + continue + columns = [column for column in table_column_details(conn, candidate_table)] + pks = [column for column in columns if column.is_pk and not column.hidden] + pks.sort(key=lambda column: column.is_pk) + if len(pks) != 1: + continue + pk = pks[0] + targets.append( + { + "fk_table": candidate_table, + "fk_column": pk.name, + "type": (pk.type or "").upper(), + "affinity": _sqlite_type_affinity(pk.type), + } + ) + return source_columns, targets, current_by_column + + +async def _foreign_key_suggestion_samples(db, table_name, columns): + if not columns: + return 0, {} + sql = "select {} from {} limit {}".format( + ", ".join(escape_sqlite(column) for column in columns), + escape_sqlite(table_name), + FOREIGN_KEY_SUGGESTION_ROW_LIMIT, + ) + try: + results = await db.execute( + sql, + custom_time_limit=FOREIGN_KEY_SUGGESTION_TIME_LIMIT_MS, + log_sql_errors=False, + ) + except QueryInterrupted as e: + raise ForeignKeySuggestionTimedOut from e + values_by_column = {column: [] for column in columns} + seen_by_column = {column: set() for column in columns} + for row in results.rows: + for column in columns: + value = row[column] + if value is None or value in seen_by_column[column]: + continue + seen_by_column[column].add(value) + values_by_column[column].append(value) + return len(results.rows), values_by_column + + +async def _foreign_key_suggestion_values_exist(db, target, values, time_limit_ms): + if not values: + return False + sql = "select {} from {} where {} in ({})".format( + escape_sqlite(target["fk_column"]), + escape_sqlite(target["fk_table"]), + escape_sqlite(target["fk_column"]), + ", ".join("?" for _ in values), + ) + try: + results = await db.execute( + sql, + params=values, + custom_time_limit=time_limit_ms, + log_sql_errors=False, + ) + except QueryInterrupted as e: + raise ForeignKeySuggestionTimedOut from e + found = {row[0] for row in results.rows} + return all(value in found for value in values) + + +async def _create_table_ui_context( + datasette, request, db, database_name, database_action_permissions +): + if not db.is_mutable: + return None + if not database_action_permissions.get("create-table"): + return None + data = { + "path": "{}/-/create".format(datasette.urls.database(database_name)), + "foreignKeyTargetsPath": "{}/-/foreign-key-targets".format( + datasette.urls.database(database_name) + ), + "databaseName": database_name, + "columnTypes": CREATE_TABLE_COLUMN_TYPES, + "defaultExpressions": default_expression_options(), + } + can_set_column_type = await datasette.allowed( + action="set-column-type", + resource=TableResource(database=database_name, table="__new_table__"), + actor=request.actor, + ) + if can_set_column_type: + data["customColumnTypes"] = _custom_column_type_options_for_create_table( + datasette + ) + return data + + +def _custom_column_type_options_for_create_table(datasette): + options = [] + for name, ct_cls in sorted(datasette._column_types.items()): + sqlite_types = getattr(ct_cls, "sqlite_types", None) + if sqlite_types is None: + option_sqlite_types = CREATE_TABLE_COLUMN_TYPES[:] + else: + option_sqlite_types = [ + create_table_type + for create_table_type, sqlite_type in CREATE_TABLE_SQLITE_TYPES.items() + if sqlite_type in sqlite_types + ] + if not option_sqlite_types: + continue + option = { + "name": name, + "description": ct_cls.description, + "sqliteTypes": option_sqlite_types, + } + if sqlite_types is not None and len(sqlite_types) == 1: + fixed_sqlite_type = CREATE_TABLE_TYPE_FOR_SQLITE_TYPE.get(sqlite_types[0]) + if fixed_sqlite_type is not None: + option["fixedSqliteType"] = fixed_sqlite_type + options.append(option) + return options + + +SqliteApiType = Literal["text", "integer", "float", "blob"] +DEFAULT_EXPRESSIONS = { + "current_timestamp": { + "sql": "CURRENT_TIMESTAMP", + "label": "Current timestamp in UTC, e.g. 2026-05-01 13:34:00", + "sqliteType": "text", + }, + "current_date": { + "sql": "CURRENT_DATE", + "label": "Current date in UTC, e.g. 2026-05-01", + "sqliteType": "text", + }, + "current_time": { + "sql": "CURRENT_TIME", + "label": "Current time in UTC, e.g. 13:34:00", + "sqliteType": "text", + }, + "current_unixtime": { + "sql": "(CAST(strftime('%s', 'now') AS INTEGER))", + "label": "Current Unix time, integer seconds since the epoch", + "sqliteType": "integer", + }, + "current_unixtime_ms": { + "sql": "(CAST((julianday('now') - 2440587.5) * 86400000 AS INTEGER))", + "label": "Current Unix time, integer milliseconds since the epoch", + "sqliteType": "integer", + }, +} +DefaultExpr = str +DEFAULT_EXPR_SQL = { + name: metadata["sql"] for name, metadata in DEFAULT_EXPRESSIONS.items() +} + + +def _strip_wrapping_parentheses(expression): + expression = expression.strip() + while expression.startswith("(") and expression.endswith(")"): + depth = 0 + in_single_quote = False + wraps_whole_expression = True + i = 0 + while i < len(expression): + char = expression[i] + if char == "'": + if ( + in_single_quote + and i + 1 < len(expression) + and expression[i + 1] == "'" + ): + i += 2 + continue + in_single_quote = not in_single_quote + elif not in_single_quote: + if char == "(": + depth += 1 + elif char == ")": + depth -= 1 + if depth == 0 and i != len(expression) - 1: + wraps_whole_expression = False + break + i += 1 + if not wraps_whole_expression or depth != 0 or in_single_quote: + break + expression = expression[1:-1].strip() + return expression + + +def _default_expression_lookup_key(expression): + return re.sub(r"\s+", " ", _strip_wrapping_parentheses(expression)).lower() + + +DEFAULT_EXPR_BY_SQL = { + _default_expression_lookup_key(sql): name for name, sql in DEFAULT_EXPR_SQL.items() +} + + +def default_expr_for_sql(expression): + if expression is None: + return None + return DEFAULT_EXPR_BY_SQL.get(_default_expression_lookup_key(expression)) + + +def _quoted_options(options): + if len(options) == 1: + return "'{}'".format(options[0]) + return "{} or '{}'".format( + ", ".join("'{}'".format(option) for option in options[:-1]), + options[-1], + ) + + +def _default_expr_error_message(): + return "Input should be {}".format(_quoted_options(list(DEFAULT_EXPRESSIONS))) + + +def default_expression_options(): + return [ + { + "value": value, + "label": metadata["label"], + "sqliteType": metadata["sqliteType"], + } + for value, metadata in DEFAULT_EXPRESSIONS.items() + ] + + +class _StrictPydanticModel(BaseModel): + model_config = ConfigDict(extra="forbid") + + +class _DefaultArgsMixin(_StrictPydanticModel): + default: Any | None = None + default_expr: DefaultExpr | None = None + + @field_validator("default_expr") + @classmethod + def validate_default_expr_value(cls, value): + if value is not None and value not in DEFAULT_EXPRESSIONS: + raise PydanticCustomError("default_expr", _default_expr_error_message()) + return value + + @model_validator(mode="after") + def validate_default_fields(self): + has_default = "default" in self.model_fields_set + has_default_expr = "default_expr" in self.model_fields_set + if has_default and has_default_expr: + raise ValueError("default and default_expr cannot both be provided") + if has_default_expr and self.default_expr is None: + raise ValueError("default_expr cannot be null") + return self + + +class CreateTableColumn(_DefaultArgsMixin): + + name: Any = None + type: Any = "text" + fk_table: str | None = None + fk_column: str | None = None + not_null: bool = False + + @model_validator(mode="after") + def validate_column(self): + if not self.name or not isinstance(self.name, str): + raise PydanticCustomError("create_table", "Column name is required") + if not self.type: + self.type = "text" + elif self.type not in CREATE_TABLE_COLUMN_TYPES: + raise PydanticCustomError( + "create_table", "Unsupported column type: {type}", {"type": self.type} + ) + if self.fk_column and not self.fk_table: + raise PydanticCustomError( + "create_table_with_location", + "fk_column requires fk_table", + ) + return self + + +class CreateTableRequest(_StrictPydanticModel): + table: Any = None + rows: Any = None + row: Any = None + columns: list[CreateTableColumn] | None = None + pk: Any = None + pks: Any = None + ignore: bool | None = None + replace: bool | None = None + alter: bool | None = None + + @field_validator("columns", mode="before") + @classmethod + def validate_columns_list(cls, value): + if value is None: + return value + if not isinstance(value, list): + raise PydanticCustomError("create_table", "columns must be a list") + if not all(isinstance(column, dict) for column in value): + raise PydanticCustomError( + "create_table", "columns must be a list of objects" + ) + return value + + @model_validator(mode="after") + def validate_request(self): + if not self.table: + raise PydanticCustomError("create_table", "Table is required") + if not isinstance(self.table, str) or not TABLE_NAME_RE.match(self.table): + raise PydanticCustomError("create_table", "Invalid table name") + if not self.columns and not self.rows and not self.row: + raise PydanticCustomError( + "create_table", "columns, rows or row is required" + ) + if self.rows and self.row: + raise PydanticCustomError( + "create_table", "Cannot specify both rows and row" + ) + if self.columns and (self.rows or self.row): + raise PydanticCustomError( + "create_table", "Cannot specify columns with rows or row" + ) + if self.columns is not None: + seen = set() + duplicates = [] + for column in self.columns: + if column.name in seen and column.name not in duplicates: + duplicates.append(column.name) + seen.add(column.name) + if duplicates: + raise PydanticCustomError( + "create_table", + "Duplicate column name: {names}", + {"names": ", ".join(duplicates)}, + ) + if self.rows is not None: + if not isinstance(self.rows, list): + raise PydanticCustomError("create_table", "rows must be a list") + if not all(isinstance(row, dict) for row in self.rows): + raise PydanticCustomError( + "create_table", "rows must be a list of objects" + ) + if self.pk is not None and not isinstance(self.pk, str): + raise PydanticCustomError("create_table", "pk must be a string") + if self.pk and self.pks: + raise PydanticCustomError("create_table", "Cannot specify both pk and pks") + if self.pks is not None: + if not isinstance(self.pks, list): + raise PydanticCustomError("create_table", "pks must be a list") + if not all(isinstance(pk, str) for pk in self.pks): + raise PydanticCustomError( + "create_table", "pks must be a list of strings" + ) + if self.ignore and self.replace: + raise PydanticCustomError( + "create_table", "ignore and replace are mutually exclusive" + ) + if {"ignore", "replace"} & self.model_fields_set: + if not self.row and not self.rows: + raise PydanticCustomError( + "create_table", "ignore and replace require row or rows" + ) + if not self.pk and not self.pks: + raise PydanticCustomError( + "create_table", "ignore and replace require pk or pks" + ) + return self + + @property + def rows_list(self): + return [self.row] if self.row else self.rows + + @property + def foreign_keys(self): + if not self.columns: + return None + foreign_keys = [] + for column in self.columns: + if column.fk_table and column.fk_column: + foreign_keys.append((column.name, column.fk_table, column.fk_column)) + elif column.fk_table: + foreign_keys.append((column.name, column.fk_table)) + return foreign_keys or None + + +class AddColumnArgs(_DefaultArgsMixin): + name: str + type: SqliteApiType = "text" + not_null: bool = False + + +class RenameColumnArgs(_StrictPydanticModel): + name: str + to: str + + +class RenameTableArgs(_StrictPydanticModel): + to: str + + @field_validator("to") + @classmethod + def validate_table_name(cls, v): + if not TABLE_NAME_RE.match(v): + raise PydanticCustomError( + "alter_table_rename_table", + "Invalid table name", + ) + return v + + +class AlterColumnArgs(_DefaultArgsMixin): + name: str + type: SqliteApiType | None = None + not_null: bool | None = None + + @model_validator(mode="after") + def require_change(self): + if not ( + {"type", "not_null", "default", "default_expr"} & self.model_fields_set + ): + raise ValueError( + "At least one of type, not_null, default or default_expr must be provided" + ) + return self + + +class DropColumnArgs(_StrictPydanticModel): + name: str + + +class SetPrimaryKeyArgs(_StrictPydanticModel): + columns: list[str] = Field(min_length=1) + + +class ReorderColumnsArgs(_StrictPydanticModel): + columns: list[str] = Field(min_length=1) + + +class ForeignKeyArgs(_StrictPydanticModel): + column: str + fk_table: str | None = None + fk_column: str | None = None + + @model_validator(mode="after") + def validate_foreign_key(self): + if self.fk_column and not self.fk_table: + raise PydanticCustomError( + "alter_table_foreign_key", + "fk_column requires fk_table", + ) + if not self.fk_table: + raise PydanticCustomError( + "alter_table_foreign_key", + "fk_table is required", + ) + return self + + @property + def tuple(self): + if self.fk_column: + return (self.column, self.fk_table, self.fk_column) + return (self.column, self.fk_table) + + +class DropForeignKeyArgs(_StrictPydanticModel): + column: str + + +class SetForeignKeysArgs(_StrictPydanticModel): + foreign_keys: list[ForeignKeyArgs] + + +class AddColumnOperation(_StrictPydanticModel): + op: Literal["add_column"] + args: AddColumnArgs + + +class RenameColumnOperation(_StrictPydanticModel): + op: Literal["rename_column"] + args: RenameColumnArgs + + +class RenameTableOperation(_StrictPydanticModel): + op: Literal["rename_table"] + args: RenameTableArgs + + +class AlterColumnOperation(_StrictPydanticModel): + op: Literal["alter_column"] + args: AlterColumnArgs + + +class DropColumnOperation(_StrictPydanticModel): + op: Literal["drop_column"] + args: DropColumnArgs + + +class SetPrimaryKeyOperation(_StrictPydanticModel): + op: Literal["set_primary_key"] + args: SetPrimaryKeyArgs + + +class ReorderColumnsOperation(_StrictPydanticModel): + op: Literal["reorder_columns"] + args: ReorderColumnsArgs + + +class AddForeignKeyOperation(_StrictPydanticModel): + op: Literal["add_foreign_key"] + args: ForeignKeyArgs + + +class DropForeignKeyOperation(_StrictPydanticModel): + op: Literal["drop_foreign_key"] + args: DropForeignKeyArgs + + +class SetForeignKeysOperation(_StrictPydanticModel): + op: Literal["set_foreign_keys"] + args: SetForeignKeysArgs + + +AlterTableOperation = Annotated[ + Union[ + AddColumnOperation, + RenameColumnOperation, + RenameTableOperation, + AlterColumnOperation, + DropColumnOperation, + SetPrimaryKeyOperation, + ReorderColumnsOperation, + AddForeignKeyOperation, + DropForeignKeyOperation, + SetForeignKeysOperation, + ], + Field(discriminator="op"), +] + + +class AlterTableRequest(_StrictPydanticModel): + operations: list[AlterTableOperation] = Field(min_length=1) + + +def _pydantic_errors(validation_error): + errors = [] + for error in validation_error.errors(): + location = ".".join(str(item) for item in error["loc"]) + message = error["msg"] + errors.append("{}: {}".format(location, message) if location else message) + return errors + + +def _create_table_pydantic_errors(validation_error): + errors = validation_error.errors() + invalid_keys = sorted( + str(error["loc"][0]) + for error in errors + if error["type"] == "extra_forbidden" and len(error["loc"]) == 1 + ) + if invalid_keys: + return ["Invalid keys: {}".format(", ".join(invalid_keys))] + + output = [] + for error in errors: + message = error["msg"] + if error["type"] == "create_table": + output.append(message) + continue + location = ".".join(str(item) for item in error["loc"]) + output.append("{}: {}".format(location, message) if location else message) + return output + + +def _table_schema_from_conn(conn, table_name): + row = conn.execute( + "select sql from sqlite_master where type = 'table' and name = ?", + [table_name], + ).fetchone() + return row[0] if row else None + + +def _primary_key_value(columns): + if len(columns) == 1: + return columns[0] + return tuple(columns) + + +def _default_expression_sql(default_expr): + return DEFAULT_EXPR_SQL[default_expr] + + +def _literal_default(db, value): + if isinstance(value, str): + return db.quote(value) + return value + + +class TableCreateView(BaseView): + name = "table-create" + + def __init__(self, datasette): + self.ds = datasette + + async def post(self, request): + db = await self.ds.resolve_database(request) + database_name = db.name + + # Must have create-table permission + if not await self.ds.allowed( + action="create-table", + resource=DatabaseResource(database=database_name), + actor=request.actor, + ): + return _error(["Permission denied"], 403) + + try: + data = await request.json() + except json.JSONDecodeError as e: + return _error(["Invalid JSON: {}".format(e)]) + + if not isinstance(data, dict): + return _error(["JSON must be an object"]) + + try: + create_request = CreateTableRequest.model_validate(data) + except ValidationError as e: + return _error(_create_table_pydantic_errors(e)) + + ignore = create_request.ignore + replace = create_request.replace + + if replace: + # Must have update-row permission + if not await self.ds.allowed( + action="update-row", + resource=DatabaseResource(database=database_name), + actor=request.actor, + ): + return _error(["Permission denied: need update-row"], 403) + + table_name = create_request.table + table_exists = await db.table_exists(table_name) + columns = create_request.columns + rows = create_request.rows_list + + if rows: + # Must have insert-row permission + if not await self.ds.allowed( + action="insert-row", + resource=DatabaseResource(database=database_name), + actor=request.actor, + ): + return _error(["Permission denied: need insert-row"], 403) + + alter = False + if rows: + if not table_exists: + # if table is being created for the first time, alter=True + alter = True + else: + # alter=True only if they request it AND they have permission + if create_request.alter: + if not await self.ds.allowed( + action="alter-table", + resource=DatabaseResource(database=database_name), + actor=request.actor, + ): + return _error(["Permission denied: need alter-table"], 403) + alter = True + + pk = create_request.pk + pks = create_request.pks + + # If table exists already, read pks from that instead + if table_exists: + actual_pks = await db.primary_keys(table_name) + # if pk passed and table already exists check it does not change + bad_pks = False + if len(actual_pks) == 1 and pk and pk != actual_pks[0]: + bad_pks = True + elif len(actual_pks) > 1 and pks and set(pks) != set(actual_pks): + bad_pks = True + if bad_pks: + return _error(["pk cannot be changed for existing table"]) + pks = actual_pks + + initial_schema = None + if table_exists: + initial_schema = await db.execute_fn( + lambda conn: sqlite_utils.Database(conn)[table_name].schema + ) + + def create_table(conn): + db_for_write = sqlite_utils.Database(conn) + table = db_for_write[table_name] + if rows: + table.insert_all( + rows, pk=pks or pk, ignore=ignore, replace=replace, alter=alter + ) + else: + not_null = [column.name for column in columns if column.not_null] + defaults = {} + for column in columns: + if "default_expr" in column.model_fields_set: + defaults[column.name] = _default_expression_sql( + column.default_expr + ) + elif ( + "default" in column.model_fields_set + and column.default is not None + ): + defaults[column.name] = _literal_default( + db_for_write, column.default + ) + table.create( + {column.name: column.type for column in columns}, + pk=pks or pk, + foreign_keys=create_request.foreign_keys, + not_null=not_null or None, + defaults=defaults or None, + ) + return table.schema + + try: + schema = await db.execute_write_fn(create_table, request=request) + except Exception as e: + return _error([str(e)]) + + if initial_schema is not None and initial_schema != schema: + await self.ds.track_event( + AlterTableEvent( + request.actor, + database=database_name, + table=table_name, + before_schema=initial_schema, + after_schema=schema, + ) + ) + + table_url = self.ds.absolute_url( + request, self.ds.urls.table(db.name, table_name) + ) + table_api_url = self.ds.absolute_url( + request, self.ds.urls.table(db.name, table_name, format="json") + ) + details = { + "ok": True, + "database": db.name, + "table": table_name, + "table_url": table_url, + "table_api_url": table_api_url, + "schema": schema, + } + if rows: + details["row_count"] = len(rows) + + if not table_exists: + # Only log creation if we created a table + await self.ds.track_event( + CreateTableEvent( + request.actor, database=db.name, table=table_name, schema=schema + ) + ) + if rows: + await self.ds.track_event( + InsertRowsEvent( + request.actor, + database=db.name, + table=table_name, + num_rows=len(rows), + ignore=ignore, + replace=replace, + ) + ) + return Response.json(details, status=201) + + +class DatabaseForeignKeyTargetsView(BaseView): + name = "database-foreign-key-targets" + + def __init__(self, datasette): + self.ds = datasette + + async def get(self, request): + db = await self.ds.resolve_database(request) + database_name = db.name + + table_name = request.args.get("table") + can_create_table = await self.ds.allowed( + action="create-table", + resource=DatabaseResource(database=database_name), + actor=request.actor, + ) + can_alter_table = False + if table_name and await db.table_exists(table_name): + can_alter_table = await self.ds.allowed( + action="alter-table", + resource=TableResource(database=database_name, table=table_name), + actor=request.actor, + ) + if not (can_create_table or can_alter_table): + return _error(["Permission denied: need create-table"], 403) + + hidden_tables = await db.execute_fn( + lambda conn: set(sqlite_hidden_table_names(conn)) + ) + targets = [ + target + for target in (await db.execute(FOREIGN_KEY_TARGETS_SQL)).dicts() + if target["fk_table"] not in hidden_tables + ] + return Response.json( + { + "ok": True, + "database": database_name, + "targets": targets, + } + ) + + +class TableForeignKeySuggestionsView(BaseView): + name = "table-foreign-key-suggestions" + + def __init__(self, datasette): + self.ds = datasette + + async def get(self, request): + try: + resolved = await self.ds.resolve_table(request) + except NotFound as e: + return _error([e.args[0]], 404) + + db = resolved.db + database_name = db.name + table_name = resolved.table + + if resolved.is_view: + return _error(["Cannot suggest foreign keys for a view"], 400) + + if not await self.ds.allowed( + action="alter-table", + resource=TableResource(database=database_name, table=table_name), + actor=request.actor, + ): + return _error(["Permission denied: need alter-table"], 403) + + source_columns, targets, current_by_column = await db.execute_fn( + lambda conn: _foreign_key_suggestion_metadata(conn, table_name) + ) + + columns = [] + options_by_column = {} + for source_column in source_columns: + options = sorted( + [ + target + for target in targets + if _foreign_key_type_compatible( + source_column["affinity"], target["affinity"] + ) + ], + key=lambda target: _foreign_key_option_sort_key( + source_column["column"], target + ), + ) + options_by_column[source_column["column"]] = options + columns.append( + { + "column": source_column["column"], + "type": source_column["type"], + "affinity": source_column["affinity"], + "current": current_by_column.get(source_column["column"]), + "suggestions": [], + "options": [ + _public_foreign_key_target(option) for option in options + ], + } + ) + + columns_to_sample = [ + column["column"] + for column in columns + if options_by_column[column["column"]] + ] + row_check = { + "attempted": bool(columns_to_sample), + "status": "completed" if columns_to_sample else "skipped", + "row_limit": FOREIGN_KEY_SUGGESTION_ROW_LIMIT, + "sampled_rows": 0, + "checked_options": 0, + } + + try: + sampled_rows, values_by_column = await _foreign_key_suggestion_samples( + db, table_name, columns_to_sample + ) + row_check["sampled_rows"] = sampled_rows + deadline = time.perf_counter() + ( + FOREIGN_KEY_SUGGESTION_TOTAL_TIME_LIMIT_MS / 1000 + ) + for column_info in columns: + values = values_by_column.get(column_info["column"]) or [] + if not values: + continue + for option in options_by_column[column_info["column"]]: + remaining_ms = int((deadline - time.perf_counter()) * 1000) + if remaining_ms <= 0: + raise ForeignKeySuggestionTimedOut + if await _foreign_key_suggestion_values_exist( + db, + option, + values, + min(FOREIGN_KEY_SUGGESTION_TIME_LIMIT_MS, remaining_ms), + ): + reasons = [ + "type_match", + "sample_values_exist", + ] + _foreign_key_name_reasons(column_info["column"], option) + column_info["suggestions"].append( + { + "fk_table": option["fk_table"], + "fk_column": option["fk_column"], + "confidence": "sampled", + "sampled_values": len(values), + "reasons": reasons, + } + ) + row_check["checked_options"] += 1 + except ForeignKeySuggestionTimedOut: + row_check["status"] = "timed_out" + + return Response.json( + { + "ok": True, + "database": database_name, + "table": table_name, + "row_check": row_check, + "columns": columns, + } + ) + + +class TableAlterView(BaseView): + name = "table-alter" + + def __init__(self, datasette): + self.ds = datasette + + async def post(self, request): + try: + resolved = await self.ds.resolve_table(request) + except NotFound as e: + return _error([e.args[0]], 404) + + db = resolved.db + database_name = db.name + table_name = resolved.table + + if not await self.ds.allowed( + action="alter-table", + resource=TableResource(database=database_name, table=table_name), + actor=request.actor, + ): + return _error(["Permission denied: need alter-table"], 403) + + if not db.is_mutable: + return _error(["Database is immutable"], 403) + + content_type = request.headers.get("content-type") or "" + if not content_type.startswith("application/json"): + return _error(["Invalid content-type, must be application/json"], 400) + + try: + data = await request.json() + except json.JSONDecodeError as e: + return _error(["Invalid JSON: {}".format(e)], 400) + + if not isinstance(data, dict): + return _error(["JSON must be a dictionary"], 400) + + try: + alter_request = AlterTableRequest.model_validate(data) + except ValidationError as e: + return _error(_pydantic_errors(e), 400) + + def alter_table(conn): + before_schema = _table_schema_from_conn(conn, table_name) + + def apply_operations(operation_conn): + db_for_write = sqlite_utils.Database(operation_conn) + table = db_for_write[table_name] + current_table_name = table_name + + add_columns = [] + types = {} + rename = {} + rename_table_to = None + drop = set() + not_null = {} + defaults = {} + column_order = None + pk = SQLITE_UTILS_DEFAULT + add_foreign_keys = [] + drop_foreign_keys = [] + foreign_keys = None + + for operation in alter_request.operations: + args = operation.args + if operation.op == "add_column": + if args.not_null and not ( + ( + "default" in args.model_fields_set + and args.default is not None + ) + or "default_expr" in args.model_fields_set + ): + raise ValueError( + "add_column args.default or args.default_expr is required when not_null is true" + ) + add_columns.append(args) + if "default" in args.model_fields_set and not args.not_null: + defaults[args.name] = _literal_default( + db_for_write, args.default + ) + if ( + "default_expr" in args.model_fields_set + and not args.not_null + ): + defaults[args.name] = _default_expression_sql( + args.default_expr + ) + elif operation.op == "rename_table": + rename_table_to = args.to + elif operation.op == "rename_column": + rename[args.name] = args.to + elif operation.op == "alter_column": + if args.type is not None: + types[args.name] = args.type + if args.not_null is not None: + not_null[args.name] = args.not_null + if "default" in args.model_fields_set: + defaults[args.name] = ( + None + if args.default is None + else _literal_default(db_for_write, args.default) + ) + if "default_expr" in args.model_fields_set: + defaults[args.name] = _default_expression_sql( + args.default_expr + ) + elif operation.op == "drop_column": + drop.add(args.name) + elif operation.op == "set_primary_key": + pk = _primary_key_value(args.columns) + elif operation.op == "reorder_columns": + column_order = args.columns + elif operation.op == "add_foreign_key": + add_foreign_keys.append(args.tuple) + elif operation.op == "drop_foreign_key": + drop_foreign_keys.append(args.column) + elif operation.op == "set_foreign_keys": + foreign_keys = [fk.tuple for fk in args.foreign_keys] + + with operation_conn: + for column in add_columns: + not_null_default = None + if column.not_null: + if "default_expr" in column.model_fields_set: + not_null_default = _default_expression_sql( + column.default_expr + ) + else: + not_null_default = _literal_default( + db_for_write, column.default + ) + table.add_column( + column.name, + column.type, + not_null_default=not_null_default, + ) + + should_transform = any( + ( + types, + rename, + drop, + not_null, + defaults, + column_order is not None, + pk is not SQLITE_UTILS_DEFAULT, + add_foreign_keys, + drop_foreign_keys, + foreign_keys is not None, + ) + ) + if should_transform: + table.transform( + types=types or None, + rename=rename or None, + drop=drop or None, + pk=pk, + not_null=not_null or None, + defaults=defaults or None, + column_order=column_order, + add_foreign_keys=add_foreign_keys or None, + drop_foreign_keys=drop_foreign_keys or None, + foreign_keys=foreign_keys, + ) + if ( + rename_table_to is not None + and rename_table_to != current_table_name + ): + operation_conn.execute( + "alter table {} rename to {}".format( + escape_sqlite(current_table_name), + escape_sqlite(rename_table_to), + ) + ) + current_table_name = rename_table_to + + return current_table_name, _table_schema_from_conn( + operation_conn, current_table_name + ) + + after_table_name, after_schema = apply_operations(conn) + return before_schema, after_schema, after_table_name + + try: + before_schema, after_schema, after_table_name = await db.execute_write_fn( + alter_table, request=request + ) + except Exception as e: + return _error([str(e)], 400) + + altered = before_schema != after_schema + if altered: + await self.ds.track_event( + AlterTableEvent( + request.actor, + database=database_name, + table=after_table_name, + before_schema=before_schema, + after_schema=after_schema, + ) + ) + + table_url = self.ds.absolute_url( + request, self.ds.urls.table(database_name, after_table_name) + ) + table_api_url = self.ds.absolute_url( + request, self.ds.urls.table(database_name, after_table_name, format="json") + ) + return Response.json( + { + "ok": True, + "database": database_name, + "table": after_table_name, + "table_url": table_url, + "table_api_url": table_api_url, + "altered": altered, + "schema": after_schema, + "before_schema": before_schema, + "operations_applied": len(alter_request.operations), + }, + status=200, + ) diff --git a/datasette/views/table_extras.py b/datasette/views/table_extras.py index a0308e49..7cb4d8f0 100644 --- a/datasette/views/table_extras.py +++ b/datasette/views/table_extras.py @@ -367,23 +367,14 @@ class ActionsExtra(Extra): # that allowed() calls made inside the plugin hooks below # are served from the cache datasette = context.datasette - await datasette.allowed_many( - actions=[ - name - for name, action in datasette.actions.items() - if action.resource_class is TableResource - ], - resource=TableResource(context.database_name, context.table_name), - actor=context.request.actor, + await precompute_table_action_permissions( + datasette, + context.request.actor, + context.database_name, + context.table_name, ) - await datasette.allowed_many( - actions=[ - name - for name, action in datasette.actions.items() - if action.resource_class is DatabaseResource - ], - resource=DatabaseResource(context.database_name), - actor=context.request.actor, + await precompute_database_action_permissions( + datasette, context.request.actor, context.database_name ) for hook in method(**kwargs): extra_links = await await_me_maybe(hook) @@ -394,6 +385,32 @@ class ActionsExtra(Extra): return actions +async def precompute_table_action_permissions( + datasette, actor, database_name, table_name +): + await datasette.allowed_many( + actions=[ + name + for name, action in datasette.actions.items() + if action.resource_class is TableResource + ], + resource=TableResource(database_name, table_name), + actor=actor, + ) + + +async def precompute_database_action_permissions(datasette, actor, database_name): + await datasette.allowed_many( + actions=[ + name + for name, action in datasette.actions.items() + if action.resource_class is DatabaseResource + ], + resource=DatabaseResource(database_name), + actor=actor, + ) + + class IsViewExtra(Extra): description = "Whether this resource is a view instead of a table" example = ExtraExample("/fixtures/simple_view.json?_extra=is_view") diff --git a/docs/changelog.rst b/docs/changelog.rst index 1516604e..697df87d 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -4,6 +4,16 @@ Changelog ========= +.. _unreleased: + +Unreleased +---------- + +- New "Create table" interface in the database actions menu, backed by the ``//-/create`` :ref:`JSON API `. It can define columns, primary keys, custom column types, ``NOT NULL`` constraints, literal defaults, expression defaults and single-column foreign keys. (:issue:`2787`) +- New "Alter table" table action and ``//
/-/alter`` :ref:`JSON API ` for changing existing tables: add, rename, reorder and drop columns; change column types, defaults, ``NOT NULL`` constraints, primary keys and foreign keys; and rename the table. The alter table dialog also includes a "Drop table" button. (:issue:`2788`) +- New ``//-/foreign-key-targets`` and ``//
/-/foreign-key-suggestions`` JSON APIs for discovering valid single-column foreign key targets and suggested relationships. +- Create and alter table dialogs share their column-editing controls, including literal and expression defaults, custom column types, foreign keys and column ordering. + .. _v1_0_a34: 1.0a34 (2026-06-16) diff --git a/docs/json_api.rst b/docs/json_api.rst index f7a0caae..b8632bc3 100644 --- a/docs/json_api.rst +++ b/docs/json_api.rst @@ -44,7 +44,7 @@ looks like this: ``"ok"`` is always ``true`` if an error did not occur. -The ``"rows"`` key is a list of objects, each one representing a row. +The ``"rows"`` key is a list of objects, each one representing a row. The ``"truncated"`` key lets you know if the query was truncated. This can happen if a SQL query returns more than 1,000 results (or the :ref:`setting_max_returned_rows` setting). @@ -1968,7 +1968,14 @@ To create a table, make a ``POST`` to ``//-/create``. This requires th }, { "name": "title", - "type": "text" + "type": "text", + "not_null": true, + "default": "Untitled" + }, + { + "name": "created", + "type": "text", + "default_expr": "current_timestamp" } ], "pk": "id" @@ -1981,6 +1988,10 @@ The JSON here describes the table that will be created: - ``name`` is the name of the column. This is required. - ``type`` is the type of the column. This is optional - if not provided, ``text`` will be assumed. The valid types are ``text``, ``integer``, ``float`` and ``blob``. + - ``not_null`` can be set to ``true`` to create this column with a ``NOT NULL`` constraint. + - ``default`` can be used to set a literal default value for this column. + - ``default_expr`` can be used instead of ``default`` to set a SQLite default expression. See :ref:`default_expr values `. + - ``fk_table`` can be used to create a single-column foreign key constraint referencing another table. ``fk_column`` is optional and can be used to specify the referenced column - if omitted, Datasette will use the single primary key of ``fk_table``. * ``pk`` is the primary key for the table. This is optional - if not provided, Datasette will create a SQLite table with a hidden ``rowid`` column. @@ -1993,6 +2004,56 @@ The JSON here describes the table that will be created: * ``replace`` can be set to ``true`` to replace existing rows by primary key if the table already exists. This requires the :ref:`actions_update_row` permission. * ``alter`` can be set to ``true`` if you want to automatically add any missing columns to the table. This requires the :ref:`actions_alter_table` permission. +.. _json_api_default_expr_values: + +``default_expr`` accepts these values: + +.. list-table:: + :header-rows: 1 + + * - Value + - Recommended column type + - Example inserted value + * - ``current_timestamp`` + - ``text`` + - ``2026-05-01 13:34:00`` + * - ``current_date`` + - ``text`` + - ``2026-05-01`` + * - ``current_time`` + - ``text`` + - ``13:34:00`` + * - ``current_unixtime`` + - ``integer`` + - ``1777642440`` + * - ``current_unixtime_ms`` + - ``integer`` + - ``1777642440000`` + +This example creates a foreign key from ``projects.owner_id`` to the single primary key of ``owners``: + +.. code-block:: json + + { + "table": "projects", + "columns": [ + { + "name": "id", + "type": "integer" + }, + { + "name": "owner_id", + "type": "integer", + "fk_table": "owners" + }, + { + "name": "title", + "type": "text" + } + ], + "pk": "id" + } + If the table is successfully created this will return a ``201`` status code and the following response: .. code-block:: json @@ -2003,7 +2064,7 @@ If the table is successfully created this will return a ``201`` status code and "table": "name_of_new_table", "table_url": "http://127.0.0.1:8001/data/name_of_new_table", "table_api_url": "http://127.0.0.1:8001/data/name_of_new_table.json", - "schema": "CREATE TABLE [name_of_new_table] (\n [id] INTEGER PRIMARY KEY,\n [title] TEXT\n)" + "schema": "CREATE TABLE [name_of_new_table] (\n [id] INTEGER PRIMARY KEY,\n [title] TEXT NOT NULL DEFAULT 'Untitled',\n [created] TEXT DEFAULT CURRENT_TIMESTAMP\n)" } .. _TableCreateView_example: @@ -2072,6 +2133,235 @@ To use the ``"replace": true`` option you will also need the :ref:`actions_updat Pass ``"alter": true`` to automatically add any missing columns to the existing table that are present in the rows you are submitting. This requires the :ref:`actions_alter_table` permission. +.. _DatabaseForeignKeyTargetsView: + +Database foreign key targets +~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +The ``//-/foreign-key-targets`` endpoint returns the list of tables in a database that can be referenced by a single-column foreign key. This requires the :ref:`actions_create_table` permission. + +:: + + GET //-/foreign-key-targets + +The response includes only tables with exactly one primary key column. Hidden tables, tables with compound primary keys and tables with no explicit primary key are omitted. + +Each target includes the normalized SQLite type affinity for the primary key column in ``type``. The type is calculated using SQLite's documented affinity rules: ``INT`` maps to ``integer``; ``CHAR``, ``CLOB`` or ``TEXT`` maps to ``text``; ``BLOB`` or no type maps to ``blob``; ``REAL`` and floating-point declared types map to ``real``; everything else maps to ``numeric``. + +.. code-block:: json + + { + "ok": true, + "database": "data", + "targets": [ + { + "fk_table": "owners", + "fk_column": "id", + "type": "integer" + }, + { + "fk_table": "categories", + "fk_column": "slug", + "type": "text" + } + ] + } + +.. _TableForeignKeySuggestionsView: + +Table foreign key suggestions +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +The ``//
/-/foreign-key-suggestions`` endpoint suggests possible single-column foreign key relationships for a table. This requires the :ref:`actions_alter_table` permission. + +:: + + GET //
/-/foreign-key-suggestions + +The response includes every type-compatible single-column primary key target for each column in ``options``. Datasette also performs a bounded data check against up to 500 rows in the table: if the sampled non-null values for a column all exist in a target primary key, that target is included in ``suggestions``. + +If the bounded check takes too long, the endpoint fails open. It still returns the type-compatible ``options`` for each column, but ``row_check.status`` will be ``"timed_out"`` and there may be no ``suggestions``. + +.. code-block:: json + + { + "ok": true, + "database": "data", + "table": "projects", + "row_check": { + "attempted": true, + "status": "completed", + "row_limit": 500, + "sampled_rows": 3, + "checked_options": 4 + }, + "columns": [ + { + "column": "owner_id", + "type": "INTEGER", + "affinity": "integer", + "current": null, + "suggestions": [ + { + "fk_table": "owners", + "fk_column": "id", + "confidence": "sampled", + "sampled_values": 3, + "reasons": [ + "type_match", + "sample_values_exist", + "name_match" + ] + } + ], + "options": [ + { + "fk_table": "owners", + "fk_column": "id", + "type": "INTEGER" + } + ] + } + ] + } + +.. _TableAlterView: + +Altering tables +~~~~~~~~~~~~~~~ + +To alter an existing table, make a ``POST`` to ``//
/-/alter``. This requires the :ref:`actions_alter_table` permission. + +:: + + POST //
/-/alter + Content-Type: application/json + Authorization: Bearer dstok_ + +The request body should include an ``operations`` array. Each operation has the same top-level shape: an ``op`` string and an ``args`` object. + +.. code-block:: json + + { + "operations": [ + { + "op": "add_column", + "args": { + "name": "slug", + "type": "text", + "not_null": true, + "default": "" + } + }, + { + "op": "add_column", + "args": { + "name": "created", + "type": "text", + "default_expr": "current_timestamp" + } + }, + { + "op": "rename_column", + "args": { + "name": "title", + "to": "headline" + } + }, + { + "op": "rename_table", + "args": { + "to": "published_posts" + } + }, + { + "op": "alter_column", + "args": { + "name": "score", + "type": "float" + } + }, + { + "op": "drop_column", + "args": { + "name": "draft_notes" + } + }, + { + "op": "set_primary_key", + "args": { + "columns": ["id"] + } + }, + { + "op": "add_foreign_key", + "args": { + "column": "owner_id", + "fk_table": "owners" + } + }, + { + "op": "drop_foreign_key", + "args": { + "column": "old_owner_id" + } + }, + { + "op": "set_foreign_keys", + "args": { + "foreign_keys": [ + { + "column": "owner_id", + "fk_table": "owners", + "fk_column": "id" + } + ] + } + }, + { + "op": "reorder_columns", + "args": { + "columns": ["id", "headline", "slug", "created", "score"] + } + } + ] + } + +Supported operations: + +* ``add_column`` adds a new column. ``args`` accepts ``name``, optional ``type`` of ``text``, ``integer``, ``float`` or ``blob``, optional ``not_null``, optional literal ``default`` and optional ``default_expr``. If ``not_null`` is ``true`` either a non-null ``default`` or ``default_expr`` is required. +* ``rename_column`` renames a column. ``args`` accepts ``name`` and ``to``. +* ``rename_table`` renames the table. ``args`` accepts ``to``, the new table name. If combined with other operations, Datasette applies the column, primary key, foreign key and column order changes before renaming the table. +* ``alter_column`` changes column properties. ``args`` accepts ``name`` and at least one of ``type``, ``not_null``, literal ``default`` or ``default_expr``. Passing ``"default": null`` removes an existing default. +* ``drop_column`` drops a column. ``args`` accepts ``name``. +* ``set_primary_key`` changes the table primary key. ``args`` accepts ``columns``, a list of one or more column names. +* ``add_foreign_key`` adds a single-column foreign key constraint. ``args`` accepts ``column``, ``fk_table`` and optional ``fk_column``. If ``fk_column`` is omitted, Datasette will use the single primary key of ``fk_table``. +* ``drop_foreign_key`` removes the foreign key constraint for a column. ``args`` accepts ``column``. +* ``set_foreign_keys`` replaces all foreign key constraints on the table. ``args`` accepts ``foreign_keys``, a list of objects that each have ``column``, ``fk_table`` and optional ``fk_column``. An empty list removes all foreign key constraints. +* ``reorder_columns`` reorders columns. ``args`` accepts ``columns``, a list of one or more column names. Columns omitted from this list will appear afterwards in their existing order. + +``default`` is always treated as a literal value. ``default_expr`` accepts the values shown in :ref:`default_expr values ` and is rendered as the corresponding SQLite default expression. + +For foreign key operations that omit ``fk_column``, the referenced ``fk_table`` must have a single-column primary key. Datasette will return an error if it cannot identify a single primary key column for that table. + +A successful response returns the new schema and the previous schema. If the request used ``rename_table``, ``table``, ``table_url`` and ``table_api_url`` will use the new table name. Renaming a table through this endpoint triggers the :class:`~datasette.events.RenameTableEvent` event. + +.. code-block:: json + + { + "ok": true, + "database": "data", + "table": "published_posts", + "table_url": "http://127.0.0.1:8001/data/published_posts", + "table_api_url": "http://127.0.0.1:8001/data/published_posts.json", + "altered": true, + "schema": "CREATE TABLE ...", + "before_schema": "CREATE TABLE ...", + "operations_applied": 11 + } + +Any errors will return ``{"errors": ["... descriptive message ..."], "ok": false}``, and a ``400`` status code for a bad input or a ``403`` status code for an authentication or permission error. + .. _TableSetColumnTypeView: Setting a column type diff --git a/docs/plugins.rst b/docs/plugins.rst index c2eb282a..d2b5c20a 100644 --- a/docs/plugins.rst +++ b/docs/plugins.rst @@ -280,6 +280,15 @@ If you run ``datasette plugins --all`` it will include default plugins that ship "query_actions" ] }, + { + "name": "datasette.default_table_actions", + "static": false, + "templates": false, + "version": null, + "hooks": [ + "table_actions" + ] + }, { "name": "datasette.events", "static": false, diff --git a/pyproject.toml b/pyproject.toml index a19dc957..215b2cca 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -35,10 +35,11 @@ dependencies = [ "PyYAML>=5.3", "mergedeep>=1.1.1", "itsdangerous>=1.1", - "sqlite-utils>=3.30", + "sqlite-utils>=3.30,<4.0", "asyncinject>=0.7", "setuptools", "pip", + "pydantic>=2", ] [project.urls] diff --git a/test-in-pyodide-with-shot-scraper.sh b/test-in-pyodide-with-shot-scraper.sh index 4d1c4968..8d7fa08e 100755 --- a/test-in-pyodide-with-shot-scraper.sh +++ b/test-in-pyodide-with-shot-scraper.sh @@ -1,40 +1,37 @@ #!/bin/bash -set -e -# So the script fails if there are any errors +set -euo pipefail + +read -r -a PYTHON_CMD <<< "${PYTHON:-python3}" +read -r -a SHOT_SCRAPER_CMD <<< "${SHOT_SCRAPER:-shot-scraper}" # Build the wheel -python3 -m build +"${PYTHON_CMD[@]}" -m build -# Find name of wheel, strip off the dist/ -wheel=$(basename $(ls dist/*.whl) | head -n 1) +# Find name of most recently built wheel, strip off the dist/ +wheel=$(basename "$(ls -t dist/*.whl | head -n 1)") # Create a blank index page echo ' - + ' > dist/index.html # Run a server for that dist/ folder -cd dist -python3 -m http.server 8529 & -cd .. +"${PYTHON_CMD[@]}" -m http.server 8529 --directory dist & +server_pid=$! # Register the kill_server function to be called on script exit kill_server() { - pkill -f 'http.server 8529' + kill "$server_pid" 2>/dev/null || true } trap kill_server EXIT -shot-scraper javascript http://localhost:8529/ " +"${SHOT_SCRAPER_CMD[@]}" javascript http://localhost:8529/ " async () => { let pyodide = await loadPyodide(); - await pyodide.loadPackage(['micropip', 'ssl', 'setuptools']); + await pyodide.loadPackage(['micropip', 'setuptools']); let output = await pyodide.runPythonAsync(\` import micropip - await micropip.install('h11==0.12.0') - await micropip.install('httpx==0.23') - # To avoid 'from typing_extensions import deprecated' error: - await micropip.install('typing-extensions>=4.12.2') await micropip.install('http://localhost:8529/$wheel') import ssl import setuptools diff --git a/tests/conftest.py b/tests/conftest.py index 8860d54c..7ec03146 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -260,8 +260,12 @@ def ds_unix_domain_socket_server(tmp_path_factory): # This used to use tmp_path_factory.mktemp("uds") but that turned out to # produce paths that were too long to use as UDS on macOS, see # https://github.com/simonw/datasette/issues/1407 - so I switched to - # using tempfile.gettempdir() - uds = str(pathlib.Path(tempfile.gettempdir()) / "datasette.sock") + # using tempfile.gettempdir() with a per-process filename. + uds = str(pathlib.Path(tempfile.gettempdir()) / f"datasette-{os.getpid()}.sock") + try: + os.unlink(uds) + except FileNotFoundError: + pass ds_proc = subprocess.Popen( [sys.executable, "-m", "datasette", "--memory", "--uds", uds], stdout=subprocess.PIPE, @@ -271,12 +275,26 @@ def ds_unix_domain_socket_server(tmp_path_factory): # Poll until available transport = httpx.HTTPTransport(uds=uds) client = httpx.Client(transport=transport) - wait_until_responds("http://localhost/_memory.json", client=client) - # Check it started successfully - assert not ds_proc.poll(), ds_proc.stdout.read().decode("utf-8") - yield ds_proc, uds - # Shut it down at the end of the pytest session - ds_proc.terminate() + try: + wait_until_responds( + "http://localhost/_memory.json", timeout=30.0, client=client + ) + # Check it started successfully + assert not ds_proc.poll(), ds_proc.stdout.read().decode("utf-8") + yield ds_proc, uds + finally: + client.close() + # Shut it down at the end of the pytest session + ds_proc.terminate() + try: + ds_proc.wait(timeout=5) + except subprocess.TimeoutExpired: + ds_proc.kill() + ds_proc.wait() + try: + os.unlink(uds) + except FileNotFoundError: + pass # Import fixtures from fixtures.py to make them available diff --git a/tests/test_api_write.py b/tests/test_api_write.py index b7ceb6b2..b3a2f6a3 100644 --- a/tests/test_api_write.py +++ b/tests/test_api_write.py @@ -1,4 +1,5 @@ from datasette.app import Datasette +from datasette.events import RenameTableEvent from datasette.utils import sqlite3 from .utils import last_event import pytest @@ -794,6 +795,614 @@ async def test_update_row_alter(ds_write): assert response.json() == {"ok": True} +@pytest.mark.asyncio +async def test_alter_table_operations(ds_write): + token = write_token(ds_write, permissions=["at"]) + db = ds_write.get_database("data") + before_schema = await db.execute_fn( + lambda conn: conn.execute( + "select sql from sqlite_master where type = 'table' and name = 'docs'" + ).fetchone()[0] + ) + + response = await ds_write.client.post( + "/data/docs/-/alter", + json={ + "operations": [ + { + "op": "add_column", + "args": { + "name": "slug", + "type": "text", + "not_null": True, + "default": "", + }, + }, + { + "op": "add_column", + "args": { + "name": "created", + "type": "text", + "default_expr": "current_timestamp", + }, + }, + { + "op": "add_column", + "args": { + "name": "literal_default", + "type": "text", + "default": "hello)", + }, + }, + {"op": "rename_column", "args": {"name": "title", "to": "headline"}}, + { + "op": "alter_column", + "args": {"name": "age", "type": "text", "default": "0"}, + }, + {"op": "drop_column", "args": {"name": "score"}}, + { + "op": "reorder_columns", + "args": { + "columns": [ + "id", + "headline", + "slug", + "created", + "literal_default", + "age", + ] + }, + }, + {"op": "set_primary_key", "args": {"columns": ["id"]}}, + ] + }, + headers=_headers(token), + ) + + assert response.status_code == 200, response.text + data = response.json() + assert data["ok"] is True + assert data["database"] == "data" + assert data["table"] == "docs" + assert data["altered"] is True + assert data["operations_applied"] == 8 + assert data["before_schema"] == before_schema + assert "headline" in data["schema"] + assert "score" not in data["schema"] + assert "DEFAULT CURRENT_TIMESTAMP" in data["schema"] + assert "DEFAULT 'hello)'" in data["schema"] + + columns = ( + await db.execute("select * from pragma_table_info('docs') order by cid") + ).dicts() + assert [column["name"] for column in columns] == [ + "id", + "headline", + "slug", + "created", + "literal_default", + "age", + ] + assert columns[0]["pk"] == 1 + assert columns[2]["notnull"] == 1 + assert columns[2]["dflt_value"] == "''" + assert columns[3]["dflt_value"] == "CURRENT_TIMESTAMP" + assert columns[4]["dflt_value"] == "'hello)'" + assert columns[5]["type"] == "TEXT" + assert columns[5]["dflt_value"] == "'0'" + + event = last_event(ds_write) + assert event.name == "alter-table" + assert event.database == "data" + assert event.table == "docs" + assert event.before_schema == before_schema + assert event.after_schema == data["schema"] + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "default_expr,minimum_value,expected_schema", + ( + ( + "current_unixtime", + 1_600_000_000, + "strftime('%s', 'now')", + ), + ( + "current_unixtime_ms", + 1_600_000_000_000, + "julianday('now')", + ), + ), +) +async def test_alter_table_integer_default_expr( + ds_write, default_expr, minimum_value, expected_schema +): + token = write_token(ds_write, permissions=["at"]) + db = ds_write.get_database("data") + response = await ds_write.client.post( + "/data/docs/-/alter", + json={ + "operations": [ + { + "op": "add_column", + "args": { + "name": "created", + "type": "integer", + "default_expr": default_expr, + }, + } + ] + }, + headers=_headers(token), + ) + assert response.status_code == 200, response.text + data = response.json() + assert expected_schema in data["schema"] + + columns = await db.execute("select * from pragma_table_info('docs')") + created_column = [ + column for column in columns.dicts() if column["name"] == "created" + ][0] + assert created_column["type"] == "INTEGER" + assert expected_schema in created_column["dflt_value"] + + row = await db.execute_write_fn( + lambda conn: conn.execute( + "insert into docs (title) values ('with default') " + "returning created, typeof(created)" + ).fetchone() + ) + assert row[0] > minimum_value + assert row[1] == "integer" + + +@pytest.mark.asyncio +async def test_alter_table_rename_table(ds_write): + token = write_token(ds_write, permissions=["at"]) + db = ds_write.get_database("data") + before_schema = await db.execute_fn( + lambda conn: conn.execute( + "select sql from sqlite_master where type = 'table' and name = 'docs'" + ).fetchone()[0] + ) + + response = await ds_write.client.post( + "/data/docs/-/alter", + json={ + "operations": [ + {"op": "rename_table", "args": {"to": "documents"}}, + ] + }, + headers=_headers(token), + ) + + assert response.status_code == 200, response.text + data = response.json() + assert data["ok"] is True + assert data["database"] == "data" + assert data["table"] == "documents" + assert data["table_url"].endswith("/data/documents") + assert data["table_api_url"].endswith("/data/documents.json") + assert data["altered"] is True + assert data["operations_applied"] == 1 + assert data["before_schema"] == before_schema + assert 'CREATE TABLE "documents"' in data["schema"] + + tables = ( + await db.execute( + "select name from sqlite_master where type = 'table' order by name" + ) + ).dicts() + table_names = [table["name"] for table in tables] + assert "docs" not in table_names + assert "documents" in table_names + + rename_events = [ + event + for event in ds_write._tracked_events + if isinstance(event, RenameTableEvent) + ] + assert len(rename_events) == 1 + assert rename_events[0].database == "data" + assert rename_events[0].old_table == "docs" + assert rename_events[0].new_table == "documents" + + +@pytest.mark.asyncio +async def test_alter_table_foreign_key_operations(ds_write): + token = write_token(ds_write, permissions=["at"]) + db = ds_write.get_database("data") + await db.execute_write("create table owners (id integer primary key)") + await db.execute_write("create table categories (id integer primary key)") + + response = await ds_write.client.post( + "/data/docs/-/alter", + json={ + "operations": [ + {"op": "add_column", "args": {"name": "owner_id", "type": "integer"}}, + { + "op": "add_foreign_key", + "args": {"column": "owner_id", "fk_table": "owners"}, + }, + ] + }, + headers=_headers(token), + ) + assert response.status_code == 200, response.text + data = response.json() + assert data["operations_applied"] == 2 + assert "[owner_id] INTEGER REFERENCES [owners]([id])" in data["schema"] + + response = await ds_write.client.post( + "/data/docs/-/alter", + json={ + "operations": [{"op": "drop_foreign_key", "args": {"column": "owner_id"}}] + }, + headers=_headers(token), + ) + assert response.status_code == 200, response.text + data = response.json() + assert "[owner_id] INTEGER REFERENCES" not in data["schema"] + + response = await ds_write.client.post( + "/data/docs/-/alter", + json={ + "operations": [ + { + "op": "set_foreign_keys", + "args": { + "foreign_keys": [ + { + "column": "owner_id", + "fk_table": "categories", + "fk_column": "id", + } + ] + }, + } + ] + }, + headers=_headers(token), + ) + assert response.status_code == 200, response.text + data = response.json() + assert "[owner_id] INTEGER REFERENCES [categories]([id])" in data["schema"] + + response = await ds_write.client.post( + "/data/docs/-/alter", + json={"operations": [{"op": "set_foreign_keys", "args": {"foreign_keys": []}}]}, + headers=_headers(token), + ) + assert response.status_code == 200, response.text + data = response.json() + assert "[owner_id] INTEGER REFERENCES" not in data["schema"] + + +@pytest.mark.asyncio +async def test_alter_table_foreign_key_requires_fk_table_for_fk_column(ds_write): + response = await ds_write.client.post( + "/data/docs/-/alter", + json={ + "operations": [ + { + "op": "add_foreign_key", + "args": {"column": "age", "fk_column": "id"}, + } + ] + }, + headers=_headers(write_token(ds_write, permissions=["at"])), + ) + assert response.status_code == 400 + assert response.json() == { + "ok": False, + "errors": ["operations.0.add_foreign_key.args: fk_column requires fk_table"], + } + + +@pytest.mark.asyncio +async def test_alter_table_foreign_key_without_fk_column_requires_single_pk(ds_write): + token = write_token(ds_write, permissions=["at"]) + db = ds_write.get_database("data") + await db.execute_write( + "create table accounts (tenant_id integer, id integer, primary key (tenant_id, id))" + ) + + response = await ds_write.client.post( + "/data/docs/-/alter", + json={ + "operations": [ + { + "op": "add_foreign_key", + "args": {"column": "age", "fk_table": "accounts"}, + } + ] + }, + headers=_headers(token), + ) + assert response.status_code == 400 + assert response.json() == { + "ok": False, + "errors": ["Could not detect single primary key for table 'accounts'"], + } + + +@pytest.mark.asyncio +async def test_foreign_key_suggestions(ds_write): + token = write_token(ds_write, permissions=["at"]) + db = ds_write.get_database("data") + await db.execute_write("create table owners (id integer primary key)") + await db.execute_write("insert into owners (id) values (1), (2), (3)") + await db.execute_write("create table categories (slug text primary key)") + await db.execute_write("insert into categories (slug) values ('one'), ('two')") + await db.execute_write("create table numbers (id integer primary key)") + await db.execute_write("insert into numbers (id) values (10), (20)") + await db.execute_write("create table weights (id real primary key)") + await db.execute_write("insert into weights (id) values (1.5), (2.5)") + await db.execute_write( + "insert into docs (id, title, score, age) values " + "(1, 'one', 1.5, 1), (2, 'two', 999.5, 2), (3, null, null, null)" + ) + + response = await ds_write.client.get( + "/data/docs/-/foreign-key-suggestions", + headers=_headers(token), + ) + assert response.status_code == 200, response.text + data = response.json() + assert data["ok"] is True + assert data["database"] == "data" + assert data["table"] == "docs" + assert data["row_check"]["attempted"] is True + assert data["row_check"]["status"] == "completed" + assert data["row_check"]["row_limit"] == 500 + assert data["row_check"]["sampled_rows"] == 3 + + columns = {column["column"]: column for column in data["columns"]} + assert columns["age"]["options"] == [ + {"fk_table": "numbers", "fk_column": "id", "type": "INTEGER"}, + {"fk_table": "owners", "fk_column": "id", "type": "INTEGER"}, + ] + assert columns["age"]["suggestions"] == [ + { + "fk_table": "owners", + "fk_column": "id", + "confidence": "sampled", + "sampled_values": 2, + "reasons": ["type_match", "sample_values_exist"], + } + ] + assert columns["title"]["options"] == [ + {"fk_table": "categories", "fk_column": "slug", "type": "TEXT"} + ] + assert columns["title"]["suggestions"][0]["fk_table"] == "categories" + assert columns["score"]["options"] == [ + {"fk_table": "weights", "fk_column": "id", "type": "REAL"} + ] + assert columns["score"]["suggestions"] == [] + + +@pytest.mark.asyncio +async def test_foreign_key_suggestions_permission_denied(ds_write): + token = write_token(ds_write, permissions=["ir"]) + response = await ds_write.client.get( + "/data/docs/-/foreign-key-suggestions", + headers=_headers(token), + ) + assert response.status_code == 403 + assert response.json() == { + "ok": False, + "errors": ["Permission denied: need alter-table"], + } + + +@pytest.mark.asyncio +async def test_foreign_key_suggestions_fail_open(ds_write, monkeypatch): + token = write_token(ds_write, permissions=["at"]) + db = ds_write.get_database("data") + await db.execute_write("create table owners (id integer primary key)") + + async def raise_timeout(*args, **kwargs): + raise table_create_alter.ForeignKeySuggestionTimedOut + + from datasette.views import table_create_alter + + monkeypatch.setattr( + table_create_alter, + "_foreign_key_suggestion_samples", + raise_timeout, + ) + + response = await ds_write.client.get( + "/data/docs/-/foreign-key-suggestions", + headers=_headers(token), + ) + assert response.status_code == 200, response.text + data = response.json() + assert data["row_check"]["status"] == "timed_out" + columns = {column["column"]: column for column in data["columns"]} + assert columns["age"]["options"] == [ + {"fk_table": "owners", "fk_column": "id", "type": "INTEGER"} + ] + assert columns["age"]["suggestions"] == [] + + +@pytest.mark.asyncio +async def test_foreign_key_targets(ds_write): + token = write_token(ds_write, permissions=["ct"]) + db = ds_write.get_database("data") + await db.execute_write("create table owners (id integer primary key)") + await db.execute_write("create table categories (slug varchar(30) primary key)") + await db.execute_write("create table blob_things (hash blob primary key)") + await db.execute_write( + "create table numeric_codes (code decimal(10,5) primary key)" + ) + await db.execute_write( + 'create table floating_point (value "FLOATING POINT" primary key)' + ) + await db.execute_write( + "create table compound (a integer, b integer, primary key (a, b))" + ) + await db.execute_write("create table no_pk (name text)") + try: + await db.execute_write("create virtual table search_docs using fts5(body)") + except Exception: + pass + + response = await ds_write.client.get( + "/data/-/foreign-key-targets", + headers=_headers(token), + ) + assert response.status_code == 200, response.text + assert response.json() == { + "ok": True, + "database": "data", + "targets": [ + { + "fk_table": "blob_things", + "fk_column": "hash", + "type": "blob", + }, + { + "fk_table": "categories", + "fk_column": "slug", + "type": "text", + }, + { + "fk_table": "docs", + "fk_column": "id", + "type": "integer", + }, + { + "fk_table": "floating_point", + "fk_column": "value", + "type": "integer", + }, + { + "fk_table": "numeric_codes", + "fk_column": "code", + "type": "numeric", + }, + { + "fk_table": "owners", + "fk_column": "id", + "type": "integer", + }, + ], + } + assert not any( + target["fk_table"].startswith("search_docs_") + for target in response.json()["targets"] + ) + + +@pytest.mark.asyncio +async def test_foreign_key_targets_permission_denied(ds_write): + token = write_token(ds_write, permissions=["ir"]) + response = await ds_write.client.get( + "/data/-/foreign-key-targets", + headers=_headers(token), + ) + assert response.status_code == 403 + assert response.json() == { + "ok": False, + "errors": ["Permission denied: need create-table"], + } + + +@pytest.mark.asyncio +async def test_foreign_key_targets_allowed_for_alter_table(ds_write): + token = write_token(ds_write, permissions=["at"]) + response = await ds_write.client.get( + "/data/-/foreign-key-targets?table=docs", + headers=_headers(token), + ) + assert response.status_code == 200, response.text + assert response.json()["ok"] is True + + +@pytest.mark.asyncio +async def test_alter_table_permission_denied(ds_write): + token = write_token(ds_write, permissions=["ir"]) + response = await ds_write.client.post( + "/data/docs/-/alter", + json={"operations": [{"op": "add_column", "args": {"name": "slug"}}]}, + headers=_headers(token), + ) + assert response.status_code == 403 + assert response.json() == { + "ok": False, + "errors": ["Permission denied: need alter-table"], + } + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "body,expected_error", + ( + ( + { + "dry_run": True, + "operations": [ + {"op": "add_column", "args": {"name": "slug", "type": "text"}} + ], + }, + "dry_run: Extra inputs are not permitted", + ), + ( + {"operations": [{"op": "add_column", "args": {"type": "text"}}]}, + "operations.0.add_column.args.name: Field required", + ), + ( + { + "operations": [ + {"op": "add_column", "args": {"name": "x", "type": "bad"}} + ] + }, + "operations.0.add_column.args.type: Input should be 'text', 'integer', 'float' or 'blob'", + ), + ( + { + "operations": [ + { + "op": "add_column", + "args": { + "name": "x", + "default_expr": "datetime('now')", + }, + } + ] + }, + "operations.0.add_column.args.default_expr: Input should be 'current_timestamp', 'current_date', 'current_time', 'current_unixtime' or 'current_unixtime_ms'", + ), + ( + { + "operations": [ + { + "op": "add_column", + "args": { + "name": "x", + "default": "x", + "default_expr": "current_timestamp", + }, + } + ] + }, + "operations.0.add_column.args: Value error, default and default_expr cannot both be provided", + ), + ), +) +async def test_alter_table_validation_errors(ds_write, body, expected_error): + response = await ds_write.client.post( + "/data/docs/-/alter", + json=body, + headers=_headers(write_token(ds_write, permissions=["at"])), + ) + assert response.status_code == 400 + assert response.json()["ok"] is False + assert response.json()["errors"] == [expected_error] + + @pytest.mark.asyncio async def test_execute_write_form_parameter_called_sql(): ds = Datasette(memory=True, default_deny=True) @@ -1409,6 +2018,249 @@ async def test_create_table( assert [e.name for e in events] == expected_events +@pytest.mark.asyncio +async def test_create_table_with_foreign_key(ds_write): + token = write_token(ds_write) + response = await ds_write.client.post( + "/data/-/create", + json={ + "table": "owners", + "columns": [ + {"name": "id", "type": "integer"}, + {"name": "name", "type": "text"}, + ], + "pk": "id", + }, + headers=_headers(token), + ) + assert response.status_code == 201 + + response = await ds_write.client.post( + "/data/-/create", + json={ + "table": "projects", + "columns": [ + {"name": "id", "type": "integer"}, + { + "name": "owner_id", + "type": "integer", + "fk_table": "owners", + }, + {"name": "title", "type": "text"}, + ], + "pk": "id", + }, + headers=_headers(token), + ) + assert response.status_code == 201 + data = response.json() + assert "[owner_id] INTEGER REFERENCES [owners]([id])" in data["schema"] + + +@pytest.mark.asyncio +async def test_create_table_with_column_constraints(ds_write): + token = write_token(ds_write) + response = await ds_write.client.post( + "/data/-/create", + json={ + "table": "constrained", + "columns": [ + {"name": "id", "type": "integer"}, + { + "name": "title", + "type": "text", + "not_null": True, + "default": "Untitled", + }, + { + "name": "created", + "type": "text", + "default_expr": "current_timestamp", + }, + {"name": "score", "type": "integer", "default": 0}, + {"name": "literal_default", "type": "text", "default": "hello)"}, + ], + "pk": "id", + }, + headers=_headers(token), + ) + assert response.status_code == 201, response.text + data = response.json() + assert data["ok"] is True + assert "NOT NULL DEFAULT 'Untitled'" in data["schema"] + assert "DEFAULT CURRENT_TIMESTAMP" in data["schema"] + assert "DEFAULT 0" in data["schema"] + assert "DEFAULT 'hello)'" in data["schema"] + + db = ds_write.get_database("data") + columns = ( + await db.execute("select * from pragma_table_info('constrained') order by cid") + ).dicts() + assert [column["name"] for column in columns] == [ + "id", + "title", + "created", + "score", + "literal_default", + ] + assert columns[0]["pk"] == 1 + assert columns[1]["notnull"] == 1 + assert columns[1]["dflt_value"] == "'Untitled'" + assert columns[2]["dflt_value"] == "CURRENT_TIMESTAMP" + assert columns[3]["dflt_value"] == "0" + assert columns[4]["dflt_value"] == "'hello)'" + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "default_expr,minimum_value,expected_schema", + ( + ( + "current_unixtime", + 1_600_000_000, + "strftime('%s', 'now')", + ), + ( + "current_unixtime_ms", + 1_600_000_000_000, + "julianday('now')", + ), + ), +) +async def test_create_table_integer_default_expr( + ds_write, default_expr, minimum_value, expected_schema +): + token = write_token(ds_write) + table = "default_{}".format(default_expr) + response = await ds_write.client.post( + "/data/-/create", + json={ + "table": table, + "columns": [ + {"name": "id", "type": "integer"}, + { + "name": "created", + "type": "integer", + "default_expr": default_expr, + }, + ], + "pk": "id", + }, + headers=_headers(token), + ) + assert response.status_code == 201, response.text + data = response.json() + assert expected_schema in data["schema"] + + db = ds_write.get_database("data") + columns = (await db.execute("select * from pragma_table_info(?)", [table])).dicts() + assert columns[1]["type"] == "INTEGER" + assert expected_schema in columns[1]["dflt_value"] + + row = await db.execute_write_fn( + lambda conn: conn.execute( + "insert into [{}] default values returning created, typeof(created)".format( + table + ) + ).fetchone() + ) + assert row[0] > minimum_value + assert row[1] == "integer" + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "column,expected_error", + ( + ( + {"name": "owner_id", "type": "integer", "fk_table": "owners"}, + None, + ), + ( + {"name": "owner_id", "type": "integer", "fk_column": "id"}, + "columns.0: fk_column requires fk_table", + ), + ( + { + "name": "created", + "type": "text", + "default_expr": "datetime('now')", + }, + "columns.0.default_expr: Input should be 'current_timestamp', 'current_date', 'current_time', 'current_unixtime' or 'current_unixtime_ms'", + ), + ( + { + "name": "created", + "type": "text", + "default": "x", + "default_expr": "current_timestamp", + }, + "columns.0: Value error, default and default_expr cannot both be provided", + ), + ), +) +async def test_create_table_column_validation(ds_write, column, expected_error): + token = write_token(ds_write) + response = await ds_write.client.post( + "/data/-/create", + json={ + "table": "projects", + "columns": [column], + }, + headers=_headers(token), + ) + if expected_error: + assert response.status_code == 400 + assert response.json() == {"ok": False, "errors": [expected_error]} + else: + assert response.status_code == 400 + assert response.json() == { + "ok": False, + "errors": ["Could not detect single primary key for table 'owners'"], + } + + +@pytest.mark.asyncio +async def test_create_table_foreign_key_without_fk_column_requires_single_pk(ds_write): + token = write_token(ds_write) + response = await ds_write.client.post( + "/data/-/create", + json={ + "table": "accounts", + "columns": [ + {"name": "tenant_id", "type": "integer"}, + {"name": "id", "type": "integer"}, + {"name": "name", "type": "text"}, + ], + "pks": ["tenant_id", "id"], + }, + headers=_headers(token), + ) + assert response.status_code == 201 + + response = await ds_write.client.post( + "/data/-/create", + json={ + "table": "projects", + "columns": [ + {"name": "id", "type": "integer"}, + { + "name": "account_id", + "type": "integer", + "fk_table": "accounts", + }, + ], + "pk": "id", + }, + headers=_headers(token), + ) + assert response.status_code == 400 + assert response.json() == { + "ok": False, + "errors": ["Could not detect single primary key for table 'accounts'"], + } + + @pytest.mark.asyncio @pytest.mark.parametrize( "permissions,body,expected_status,expected_errors", diff --git a/tests/test_datasette_https_server.sh b/tests/test_datasette_https_server.sh index aee262cc..a544b9a8 100755 --- a/tests/test_datasette_https_server.sh +++ b/tests/test_datasette_https_server.sh @@ -40,22 +40,23 @@ curl -f --cacert client.pem $test_url curl_exit_code=$? # Shut down the server -kill $server_pid -waiting=0 -# show all pids -# | find just the $server_pid -# | | don’t match on the previous grep -# | | | we don’t need the output -# | | | | -until ( ! ps ax | grep $server_pid | grep -v grep > /dev/null ); do - if [ $waiting -eq 4 ]; then - echo "$server_pid does still exist, server failed to stop" - cleanup - exit 1 +kill $server_pid 2>/dev/null || true +( + sleep 5 + if kill -0 $server_pid 2>/dev/null; then + kill -9 $server_pid 2>/dev/null || true fi - let waiting=waiting+1 - sleep 1 -done +) & +killer_pid=$! +wait_status=0 +wait $server_pid 2>/dev/null || wait_status=$? +kill $killer_pid 2>/dev/null || true +wait $killer_pid 2>/dev/null || true +if [ $wait_status -eq 137 ]; then + echo "$server_pid did not stop after SIGTERM, server failed to stop" + cleanup + exit 1 +fi # Clean up the certificates cleanup diff --git a/tests/test_playwright.py b/tests/test_playwright.py index a8c5aa4b..ee396de5 100644 --- a/tests/test_playwright.py +++ b/tests/test_playwright.py @@ -98,6 +98,10 @@ def write_playwright_database(db_path): notes text, score integer default 5 ); + create table defaults_demo ( + id integer primary key, + created_ms integer default (CAST((julianday('now') - 2440587.5) * 86400000 AS INTEGER)) + ); insert into projects (title, metadata, logo, notes, score) values ( 'Build Datasette', @@ -117,6 +121,10 @@ def write_playwright_config(config_path): { "databases": { "data": { + "permissions": { + "create-table": True, + "set-column-type": True, + }, "tables": { "projects": { "label_column": "title", @@ -126,11 +134,17 @@ def write_playwright_config(config_path): "notes": "textarea", }, "permissions": { + "alter-table": True, "insert-row": True, "update-row": True, "delete-row": True, }, }, + "defaults_demo": { + "permissions": { + "alter-table": True, + }, + }, }, }, }, @@ -275,6 +289,480 @@ def test_datasette_homepage_contains_datasette(page, datasette_server): assert "Datasette" in page.locator("body").inner_text() +@pytest.mark.playwright +def test_create_table_flow(page, datasette_server): + page.goto(f"{datasette_server}data") + page.locator("details.actions-menu-links summary").click() + page.locator('button[data-database-action="create-table"]').click() + + dialog = page.locator("#table-create-dialog") + dialog.wait_for() + assert dialog.locator(".modal-title").inner_text() == "Create a table in data" + placeholder_select = dialog.locator(".table-create-custom-column-type").nth(0) + assert placeholder_select.input_value() == "" + assert ( + placeholder_select.locator("option:checked").inner_text() == "- custom type -" + ) + assert "table-create-input-placeholder" in placeholder_select.get_attribute("class") + assert ( + dialog.locator(".table-create-column-name").nth(0).get_attribute("placeholder") + == "column name" + ) + assert dialog.locator(".table-create-column-main").first.evaluate("""node => { + const inputHeight = node.querySelector( + ".table-create-column-name" + ).getBoundingClientRect().height; + const selectHeight = node.querySelector( + ".table-create-column-type" + ).getBoundingClientRect().height; + return Math.abs(inputHeight - selectHeight) <= 1; + }""") + dialog.locator('input[name="table"]').fill("playwright_created") + dialog.locator(".table-create-column-name").nth(1).fill("title") + dialog.locator(".table-create-more-options").nth(1).click() + dialog.locator(".table-create-not-null-input").nth(1).check() + title_defaults = dialog.locator(".table-create-default-options").nth(1) + assert title_defaults.locator("summary").inner_text() == "Set a default value" + title_defaults.locator("summary").click() + assert "or default to a specific value" in title_defaults.inner_text() + title_default_expr = title_defaults.locator(".table-create-default-expr") + title_default_input = title_defaults.locator(".table-create-default") + assert ( + "Current timestamp in UTC, e.g. 2026-05-01 13:34:00" + in title_default_expr.locator("option").nth(1).inner_text() + ) + title_default_expr.select_option("current_timestamp") + assert title_default_input.is_enabled() + title_default_input.fill("Untitled") + assert title_default_expr.input_value() == "" + dialog.locator(".table-create-add-column").click() + dialog.locator(".table-create-column-name").nth(2).fill("score") + dialog.locator(".table-create-column-type").nth(2).select_option("integer") + dialog.locator(".table-create-add-column").click() + dialog.locator(".table-create-column-name").nth(3).fill("metadata") + dialog.locator(".table-create-column-type").nth(3).select_option("integer") + dialog.locator(".table-create-more-options").nth(3).click() + dialog.locator(".table-create-custom-column-type").nth(3).select_option("json") + assert dialog.locator(".table-create-column-type").nth(3).input_value() == "text" + assert "table-create-input-placeholder" not in dialog.locator( + ".table-create-custom-column-type" + ).nth(3).get_attribute("class") + + dialog.locator(".table-create-save").click() + page.wait_for_url("**/data/playwright_created") + assert "playwright_created" in page.locator("h1").inner_text() + + response = httpx.get( + f"{datasette_server}data/playwright_created.json?_extra=columns,column_types" + ) + response.raise_for_status() + data = response.json() + assert data["columns"] == [ + "id", + "title", + "score", + "metadata", + ] + assert data["column_types"] == { + "metadata": {"type": "json", "config": None}, + } + schema_response = httpx.get( + f"{datasette_server}data/-/query.json", + params={ + "sql": ( + "select sql from sqlite_master where type = 'table' " + "and name = 'playwright_created'" + ) + }, + ) + schema_response.raise_for_status() + schema = schema_response.json()["rows"][0]["sql"] + assert "title" in schema + assert "NOT NULL DEFAULT 'Untitled'" in schema + + +@pytest.mark.playwright +def test_create_table_foreign_key_selection_updates_column_type(page, datasette_server): + page.goto(f"{datasette_server}data") + page.locator("details.actions-menu-links summary").click() + page.locator('button[data-database-action="create-table"]').click() + + dialog = page.locator("#table-create-dialog") + dialog.wait_for() + dialog.locator(".table-create-more-options").nth(1).click() + + column_name = dialog.locator(".table-create-column-name").nth(1) + type_select = dialog.locator(".table-create-column-type").nth(1) + foreign_key_select = dialog.locator(".table-create-foreign-key-target").nth(1) + assert column_name.input_value() == "" + assert type_select.input_value() == "text" + + foreign_key_select.select_option("projects\u001fid") + assert column_name.input_value() == "projects_id" + assert type_select.input_value() == "integer" + assert foreign_key_select.input_value() == "projects\u001fid" + + +@pytest.mark.playwright +def test_create_table_unix_default_expression_updates_column_type( + page, datasette_server +): + page.goto(f"{datasette_server}data") + page.locator("details.actions-menu-links summary").click() + page.locator('button[data-database-action="create-table"]').click() + + dialog = page.locator("#table-create-dialog") + dialog.wait_for() + row = dialog.locator(".table-create-column-row").nth(1) + row.locator(".table-create-more-options").click() + row.locator(".table-create-default-options summary").click() + + type_select = row.locator(".table-create-column-type") + default_expr = row.locator(".table-create-default-expr") + assert type_select.input_value() == "text" + assert ( + "Current Unix time, integer milliseconds since the epoch" + in default_expr.locator("option").last.inner_text() + ) + + default_expr.select_option("current_unixtime_ms") + assert type_select.input_value() == "integer" + + +@pytest.mark.playwright +def test_alter_table_foreign_key_selection_updates_blank_column(page, datasette_server): + page.goto(f"{datasette_server}data/projects") + page.locator("details.actions-menu-links summary").click() + page.locator('button[data-table-action="alter-table"]').click() + + dialog = page.locator("#table-alter-dialog") + dialog.wait_for() + dialog.locator(".table-alter-add-column").click() + + column_name = dialog.locator(".table-alter-column-name").last + type_select = dialog.locator(".table-alter-column-type").last + foreign_key_select = dialog.locator(".table-alter-foreign-key-target").last + assert column_name.input_value() == "" + assert type_select.input_value() == "text" + + foreign_key_select.select_option("projects\u001fid") + assert column_name.input_value() == "projects_id" + assert type_select.input_value() == "integer" + assert foreign_key_select.input_value() == "projects\u001fid" + + +@pytest.mark.playwright +def test_alter_table_unix_default_expression_updates_column_type( + page, datasette_server +): + page.goto(f"{datasette_server}data/projects") + page.locator("details.actions-menu-links summary").click() + page.locator('button[data-table-action="alter-table"]').click() + + dialog = page.locator("#table-alter-dialog") + dialog.wait_for() + dialog.locator(".table-alter-add-column").click() + row = dialog.locator(".table-alter-column-row").last + row.locator(".table-alter-default-options summary").click() + + type_select = row.locator(".table-alter-column-type") + default_expr = row.locator(".table-alter-default-expr") + assert type_select.input_value() == "text" + assert ( + "Current Unix time, integer seconds since the epoch" + in default_expr.locator("option").all_inner_texts() + ) + + default_expr.select_option("current_unixtime") + assert type_select.input_value() == "integer" + + +@pytest.mark.playwright +def test_alter_table_existing_default_expression_populates_select( + page, datasette_server +): + page.goto(f"{datasette_server}data/defaults_demo") + page.locator("details.actions-menu-links summary").click() + page.locator('button[data-table-action="alter-table"]').click() + + dialog = page.locator("#table-alter-dialog") + dialog.wait_for() + row = dialog.locator(".table-alter-column-row").nth(1) + row.locator(".table-alter-more-options").click() + row.locator(".table-alter-default-options summary").click() + + assert row.locator(".table-alter-default-expr").input_value() == ( + "current_unixtime_ms" + ) + assert row.locator(".table-alter-default").input_value() == "" + + +@pytest.mark.playwright +def test_alter_table_flow(page, datasette_server): + page.goto(f"{datasette_server}data/projects") + page.locator("details.actions-menu-links summary").click() + page.locator('button[data-table-action="alter-table"]').click() + + dialog = page.locator("#table-alter-dialog") + dialog.wait_for() + assert dialog.locator(".modal-title").inner_text() == "Alter table projects" + assert dialog.locator(".table-alter-save").is_disabled() + assert ( + dialog.locator(".table-alter-column-name").first.get_attribute("placeholder") + == "column name" + ) + assert dialog.locator(".table-alter-column-main").first.evaluate("""node => { + const inputHeight = node.querySelector( + ".table-alter-column-name" + ).getBoundingClientRect().height; + const selectHeight = node.querySelector( + ".table-alter-column-type" + ).getBoundingClientRect().height; + return Math.abs(inputHeight - selectHeight) <= 1; + }""") + type_options = dialog.locator(".table-alter-column-type").first.locator("option") + assert type_options.all_inner_texts() == [ + "text", + "integer", + "floating point number", + "blob - binary data", + ] + first_more_options = dialog.locator(".table-alter-more-options").first + assert first_more_options.inner_text() == "> Advanced options" + first_more_options.click() + assert first_more_options.inner_text() == "v Hide options" + expanded_options_text = dialog.locator( + ".table-alter-column-details" + ).first.inner_text() + assert dialog.locator(".table-alter-fields").evaluate( + "node => node.scrollWidth <= node.clientWidth + 1" + ) + assert "Not null" in expanded_options_text + assert "This value cannot be left unset" in expanded_options_text + assert "Set a default value" in expanded_options_text + assert "Primary key" in expanded_options_text + assert "This ID uniquely identifies the record" in expanded_options_text + assert "Foreign key" in expanded_options_text + first_defaults = dialog.locator(".table-alter-default-options").first + first_defaults.locator("summary").click() + assert "or default to a specific value" in first_defaults.inner_text() + first_default_expr = first_defaults.locator(".table-alter-default-expr") + first_default_input = first_defaults.locator(".table-alter-default") + assert ( + "Current timestamp in UTC, e.g. 2026-05-01 13:34:00" + in first_default_expr.locator("option").nth(1).inner_text() + ) + first_default_expr.select_option("current_timestamp") + assert first_default_input.is_enabled() + first_default_input.fill("manual") + assert first_default_expr.input_value() == "" + + dialog.locator(".table-alter-add-column").click() + assert dialog.locator(".table-alter-save").is_enabled() + dialog.locator(".table-alter-column-name").last.fill("status") + dialog.locator(".table-alter-column-type").last.select_option("text") + dialog.locator(".table-alter-default-options").last.locator("summary").click() + dialog.locator(".table-alter-default").last.fill("planned") + dialog.locator(".table-alter-save").click() + review = dialog.locator(".table-alter-review") + review.wait_for() + assert not dialog.locator(".table-alter-column-list").is_visible() + review_text = review.inner_text() + assert "Add column status as text, with default value planned." in review_text + assert "Set column order to" not in review_text + assert dialog.locator(".table-alter-back").is_visible() + assert dialog.locator(".table-alter-save").inner_text() == "Apply changes" + dialog.locator(".table-alter-save").click() + + columns = [] + for _ in range(20): + response = httpx.get(f"{datasette_server}data/projects.json?_extra=columns") + response.raise_for_status() + columns = response.json()["columns"] + if "status" in columns: + break + time.sleep(0.1) + assert "status" in columns + + +@pytest.mark.playwright +def test_alter_table_primary_key_columns_stay_at_top(page, datasette_server): + page.goto(f"{datasette_server}data/projects") + page.locator("details.actions-menu-links summary").click() + page.locator('button[data-table-action="alter-table"]').click() + + dialog = page.locator("#table-alter-dialog") + dialog.wait_for() + rows = dialog.locator(".table-alter-column-row") + assert rows.nth(0).locator(".table-alter-column-name").input_value() == "id" + first_row_move_buttons = rows.nth(0).locator(".table-alter-move-controls button") + for i in range(first_row_move_buttons.count()): + assert first_row_move_buttons.nth(i).is_disabled() + assert ( + first_row_move_buttons.nth(i).get_attribute("title") + == "Primary key columns are always listed first" + ) + + assert rows.nth(1).locator(".table-alter-move-up").is_disabled() + assert rows.nth(1).locator(".table-alter-move-top").get_attribute("title") == ( + "Primary key columns are always listed first" + ) + assert rows.nth(1).locator(".table-alter-move-up").get_attribute("title") == ( + "Primary key columns are always listed first" + ) + last_row = rows.nth(rows.count() - 1) + assert last_row.locator(".table-alter-column-name").input_value() == "score" + last_row.locator(".table-alter-move-top").click() + assert rows.nth(0).locator(".table-alter-column-name").input_value() == "id" + assert rows.nth(1).locator(".table-alter-column-name").input_value() == "score" + + +@pytest.mark.playwright +def test_alter_table_review_rename_primary_key_column(page, datasette_server): + page.goto(f"{datasette_server}data/projects") + page.locator("details.actions-menu-links summary").click() + page.locator('button[data-table-action="alter-table"]').click() + + dialog = page.locator("#table-alter-dialog") + dialog.wait_for() + save = dialog.locator(".table-alter-save") + assert save.is_disabled() + dialog.locator(".table-alter-column-name").first.fill("id3") + assert save.is_enabled() + save.click() + + review = dialog.locator(".table-alter-review") + review.wait_for() + review_text = review.inner_text() + assert "Rename column id to id3." in review_text + assert "Set primary key to" not in review_text + assert dialog.locator(".table-alter-review-name").all_inner_texts() == [ + "id", + "id3", + ] + + +@pytest.mark.playwright +def test_alter_table_review_rename_table(page, datasette_server): + page.goto(f"{datasette_server}data/projects") + page.locator("details.actions-menu-links summary").click() + page.locator('button[data-table-action="alter-table"]').click() + + dialog = page.locator("#table-alter-dialog") + dialog.wait_for() + save = dialog.locator(".table-alter-save") + rename_details = dialog.locator(".table-alter-table-options") + assert rename_details.locator("summary").inner_text() == "Rename table" + assert not dialog.locator(".table-alter-table-name").is_visible() + assert save.is_disabled() + + rename_details.locator("summary").click() + table_name = dialog.locator(".table-alter-table-name") + assert table_name.input_value() == "projects" + assert table_name.get_attribute("placeholder") == "table name" + table_name.fill("projects_archive") + assert save.is_enabled() + save.click() + + review = dialog.locator(".table-alter-review") + review.wait_for() + assert "Rename table to projects_archive." in review.inner_text() + assert dialog.locator(".table-alter-review-name").all_inner_texts() == [ + "projects_archive", + ] + + +@pytest.mark.playwright +def test_alter_table_review_not_null_wording(page, datasette_server): + page.goto(f"{datasette_server}data/projects") + page.locator("details.actions-menu-links summary").click() + page.locator('button[data-table-action="alter-table"]').click() + + dialog = page.locator("#table-alter-dialog") + dialog.wait_for() + dialog.locator(".table-alter-more-options").first.click() + dialog.locator(".table-alter-not-null-input").first.check() + dialog.locator(".table-alter-save").click() + + review = dialog.locator(".table-alter-review") + review.wait_for() + assert "Change column id: not null (require values)." in review.inner_text() + + +@pytest.mark.playwright +def test_alter_table_review_warns_when_dropping_column(page, datasette_server): + page.goto(f"{datasette_server}data/projects") + page.locator("details.actions-menu-links summary").click() + page.locator('button[data-table-action="alter-table"]').click() + + dialog = page.locator("#table-alter-dialog") + dialog.wait_for() + remove_buttons = dialog.locator(".table-alter-remove-column") + remove_buttons.nth(remove_buttons.count() - 1).click() + dialog.locator(".table-alter-save").click() + + review = dialog.locator(".table-alter-review") + review.wait_for() + assert not dialog.locator(".table-alter-column-list").is_visible() + review_text = review.inner_text() + assert "Warning: data in dropped columns will be permanently lost." in review_text + assert "Drop column score." in review_text + assert "Set column order to" not in review_text + assert dialog.locator(".table-alter-review-damaging").inner_text() == ( + "Drop column score." + ) + + dialog.locator(".table-alter-back").click() + assert dialog.locator(".table-alter-column-list").is_visible() + assert dialog.locator(".table-alter-save").inner_text() == "Review changes" + + +@pytest.mark.playwright +def test_alter_table_cancel_skips_discard_prompt(page, datasette_server): + def open_alter_dialog(): + page.locator("details.actions-menu-links").evaluate("node => node.open = true") + page.locator('button[data-table-action="alter-table"]').click() + dialog = page.locator("#table-alter-dialog") + dialog.wait_for() + return dialog + + page.goto(f"{datasette_server}data/projects") + page.evaluate(""" + () => { + window.__discardConfirmMessages = []; + window.confirm = (message) => { + window.__discardConfirmMessages.push(message); + return false; + }; + } + """) + + dialog = open_alter_dialog() + dialog.locator(".table-alter-add-column").click() + dialog.locator(".table-alter-column-name").last.fill("cancel_me") + dialog.locator(".table-alter-cancel").click() + assert dialog.evaluate("node => node.open") is False + assert page.evaluate("() => window.__discardConfirmMessages") == [] + + dialog = open_alter_dialog() + dialog.locator(".table-alter-add-column").click() + dialog.locator(".table-alter-column-name").last.fill("escape_me") + page.keyboard.press("Escape") + assert page.evaluate("() => window.__discardConfirmMessages") == [ + "Discard table changes?" + ] + assert dialog.evaluate("node => node.open") is True + + page.evaluate("() => window.__discardConfirmMessages = []") + dialog.evaluate( + """node => node.dispatchEvent(new MouseEvent("click", {bubbles: true}))""" + ) + assert page.evaluate("() => window.__discardConfirmMessages") == [ + "Discard table changes?" + ] + assert dialog.evaluate("node => node.open") is True + + @pytest.mark.playwright def test_navigation_search_tracks_and_renders_recent_items(page, datasette_server): page.goto(datasette_server) diff --git a/tests/test_table_html.py b/tests/test_table_html.py index aa67bb3f..3af2bb08 100644 --- a/tests/test_table_html.py +++ b/tests/test_table_html.py @@ -23,6 +23,52 @@ def table_data_from_soup(soup): return json.loads(match.group(1)) +def database_data_from_soup(soup): + import json + import re + + database_script = [ + s + for s in soup.find_all("script") + if "_datasetteDatabaseData" in (s.string or "") + ][0] + match = re.search( + r"window\._datasetteDatabaseData\s*=\s*({.*?});", + database_script.string, + re.DOTALL, + ) + return json.loads(match.group(1)) + + +DEFAULT_EXPRESSION_OPTIONS = [ + { + "value": "current_timestamp", + "label": "Current timestamp in UTC, e.g. 2026-05-01 13:34:00", + "sqliteType": "text", + }, + { + "value": "current_date", + "label": "Current date in UTC, e.g. 2026-05-01", + "sqliteType": "text", + }, + { + "value": "current_time", + "label": "Current time in UTC, e.g. 13:34:00", + "sqliteType": "text", + }, + { + "value": "current_unixtime", + "label": "Current Unix time, integer seconds since the epoch", + "sqliteType": "integer", + }, + { + "value": "current_unixtime_ms", + "label": "Current Unix time, integer milliseconds since the epoch", + "sqliteType": "integer", + }, +] + + @pytest.mark.asyncio @pytest.mark.parametrize( "path,expected_definition_sql", @@ -934,6 +980,292 @@ async def test_row_delete_action_data_attributes(): ds.close() +@pytest.mark.asyncio +async def test_database_create_table_action_button_and_data(): + ds = Datasette( + [], + config={ + "databases": { + "data": { + "permissions": { + "create-table": {"id": "root"}, + }, + }, + }, + }, + ) + try: + db = ds.add_database( + Database(ds, memory_name="test_database_create_table_action"), name="data" + ) + await db.execute_write_script(""" + create table items (id integer primary key, name text); + """) + + response = await ds.client.get("/data", actor={"id": "root"}) + assert response.status_code == 200 + soup = Soup(response.text, "html.parser") + + button = soup.select_one( + 'button.action-menu-button[data-database-action="create-table"]' + ) + assert button is not None + assert button["aria-label"] == "Create table in data" + assert button["role"] == "menuitem" + description = button.find("span", class_="dropdown-description") + assert description.text.strip() == "Create a new table in this database." + description.extract() + assert button.text.strip() == "Create table" + assert any( + "edit-tools.js" in script.get("src", "") + for script in soup.find_all("script") + ) + assert database_data_from_soup(soup) == { + "createTable": { + "path": "/data/-/create", + "foreignKeyTargetsPath": "/data/-/foreign-key-targets", + "databaseName": "data", + "columnTypes": ["text", "integer", "float", "blob"], + "defaultExpressions": DEFAULT_EXPRESSION_OPTIONS, + }, + } + assert "customColumnTypes" not in database_data_from_soup(soup)["createTable"] + + response_without_permission = await ds.client.get( + "/data", actor={"id": "someone-else"} + ) + assert response_without_permission.status_code == 200 + soup_without_permission = Soup(response_without_permission.text, "html.parser") + assert ( + soup_without_permission.select_one( + 'button[data-database-action="create-table"]' + ) + is None + ) + assert not any( + "_datasetteDatabaseData" in (script.string or "") + for script in soup_without_permission.find_all("script") + ) + finally: + ds.close() + + +@pytest.mark.asyncio +async def test_database_create_table_data_includes_custom_column_types(): + ds = Datasette( + [], + config={ + "databases": { + "data": { + "permissions": { + "create-table": {"id": "root"}, + "set-column-type": {"id": "root"}, + }, + }, + }, + }, + ) + try: + db = ds.add_database( + Database(ds, memory_name="test_database_create_table_custom_types"), + name="data", + ) + await db.execute_write_script(""" + create table items (id integer primary key, name text); + """) + + response = await ds.client.get("/data", actor={"id": "root"}) + assert response.status_code == 200 + create_table_data = database_data_from_soup(Soup(response.text, "html.parser"))[ + "createTable" + ] + assert create_table_data["customColumnTypes"] == [ + { + "name": "email", + "description": "Email address", + "sqliteTypes": ["text"], + "fixedSqliteType": "text", + }, + { + "name": "json", + "description": "JSON data", + "sqliteTypes": ["text"], + "fixedSqliteType": "text", + }, + { + "name": "textarea", + "description": "Multiline text", + "sqliteTypes": ["text"], + "fixedSqliteType": "text", + }, + { + "name": "url", + "description": "URL", + "sqliteTypes": ["text"], + "fixedSqliteType": "text", + }, + ] + finally: + ds.close() + + +@pytest.mark.asyncio +async def test_table_alter_action_button_and_data(): + ds = Datasette( + [], + config={ + "databases": { + "data": { + "tables": { + "items": { + "permissions": { + "alter-table": {"id": ["root", "alter-only"]}, + "set-column-type": {"id": "root"}, + "drop-table": {"id": "root"}, + }, + "column_types": {"name": "textarea"}, + }, + }, + }, + }, + }, + ) + try: + db = ds.add_database( + Database(ds, memory_name="test_table_alter_action"), name="data" + ) + await db.execute_write_script(""" + create table items ( + id integer primary key, + name text not null, + score integer default 5, + created text default current_timestamp, + created_ms integer default (CAST((julianday('now') - 2440587.5) * 86400000 AS INTEGER)) + ); + """) + response = await ds.client.get("/data/items", actor={"id": "root"}) + assert response.status_code == 200 + soup = Soup(response.text, "html.parser") + + button = soup.select_one( + 'button.action-menu-button[data-table-action="alter-table"]' + ) + assert button is not None + assert button["aria-label"] == "Alter table items" + assert button["role"] == "menuitem" + description = button.find("span", class_="dropdown-description") + assert description.text.strip() == ( + "Change columns and primary key for this table." + ) + description.extract() + assert button.text.strip() == "Alter table" + assert any( + "edit-tools.js" in script.get("src", "") + for script in soup.find_all("script") + ) + + alter_data = table_data_from_soup(soup)["alterTable"] + assert alter_data["path"] == "/data/items/-/alter" + assert alter_data["tableName"] == "items" + assert alter_data["primaryKeys"] == ["id"] + assert alter_data["columnTypes"] == ["text", "integer", "float", "blob"] + assert alter_data["foreignKeyTargetsPath"] == ( + "/data/-/foreign-key-targets?table=items" + ) + assert alter_data["defaultExpressions"] == DEFAULT_EXPRESSION_OPTIONS + assert [option["name"] for option in alter_data["customColumnTypes"]] == [ + "email", + "json", + "textarea", + "url", + ] + assert alter_data["dropPath"] == "/data/items/-/drop" + assert alter_data["columns"] == [ + { + "name": "id", + "type": "integer", + "sqlite_type": "INTEGER", + "notnull": 0, + "default": None, + "has_default": False, + "is_pk": True, + "foreign_key": None, + "column_type": None, + }, + { + "name": "name", + "type": "text", + "sqlite_type": "TEXT", + "notnull": 1, + "default": None, + "has_default": False, + "is_pk": False, + "foreign_key": None, + "column_type": {"type": "textarea", "config": None}, + }, + { + "name": "score", + "type": "integer", + "sqlite_type": "INTEGER", + "notnull": 0, + "default": "5", + "has_default": True, + "is_pk": False, + "foreign_key": None, + "column_type": None, + }, + { + "name": "created", + "type": "text", + "sqlite_type": "TEXT", + "notnull": 0, + "default": None, + "default_expr": "current_timestamp", + "has_default": True, + "is_pk": False, + "foreign_key": None, + "column_type": None, + }, + { + "name": "created_ms", + "type": "integer", + "sqlite_type": "INTEGER", + "notnull": 0, + "default": None, + "default_expr": "current_unixtime_ms", + "has_default": True, + "is_pk": False, + "foreign_key": None, + "column_type": None, + }, + ] + + response_without_permission = await ds.client.get( + "/data/items", actor={"id": "someone-else"} + ) + assert response_without_permission.status_code == 200 + soup_without_permission = Soup(response_without_permission.text, "html.parser") + assert ( + soup_without_permission.select_one( + 'button[data-table-action="alter-table"]' + ) + is None + ) + assert "alterTable" not in table_data_from_soup(soup_without_permission) + + # An actor that can alter but not drop should not get a dropPath + response_alter_only = await ds.client.get( + "/data/items", actor={"id": "alter-only"} + ) + assert response_alter_only.status_code == 200 + alter_only_data = table_data_from_soup( + Soup(response_alter_only.text, "html.parser") + )["alterTable"] + assert "dropPath" not in alter_only_data + finally: + ds.close() + + @pytest.mark.asyncio async def test_table_insert_action_button_and_data(): ds = Datasette(