feat: prompt to create migration on DDL in datatable SQL editors

This commit is contained in:
Diego Imbert
2026-06-17 18:14:29 +02:00
parent a18fa7ccd6
commit 95ee90fc5a
6 changed files with 373 additions and 154 deletions
@@ -0,0 +1,118 @@
<script lang="ts">
import { Button } from './common'
import Modal2 from './common/modal/Modal2.svelte'
import NewDataTableMigrationModal from './workspaceSettings/NewDataTableMigrationModal.svelte'
import { splitSqlStatements, isDdlStatement } from './sqlDdl'
let { workspace, datatable }: { workspace: string; datatable: string } = $props()
type Choice = 'run' | 'migrate' | 'cancel'
let promptStatement = $state<string | undefined>(undefined)
let promptOpen = $state(false)
let resolvePrompt: ((choice: Choice) => void) | undefined = undefined
let resolveMigrationClosed: (() => void) | undefined = undefined
let newMigrationModal = $state<NewDataTableMigrationModal | undefined>(undefined)
function finishPrompt(choice: Choice) {
const r = resolvePrompt
resolvePrompt = undefined
promptOpen = false
promptStatement = undefined
r?.(choice)
}
// Closing the modal (X / escape) while a prompt is pending counts as cancel.
$effect(() => {
if (!promptOpen && resolvePrompt) {
finishPrompt('cancel')
}
})
function promptDdl(statement: string): Promise<Choice> {
return new Promise((resolve) => {
resolvePrompt = resolve
promptStatement = statement
promptOpen = true
})
}
function handleMigrationClosed() {
const r = resolveMigrationClosed
resolveMigrationClosed = undefined
r?.()
}
// Open the prefilled new-migration modal and resolve once it closes (whether
// the migration was created or the user cancelled).
function openMigrationModal(statement: string): Promise<void> {
return new Promise((resolve) => {
resolveMigrationClosed = resolve
newMigrationModal?.open({ codeUp: statement })
})
}
/**
* Inspect `code` for DDL statements. For each one, prompt the user to run it
* anyway or turn it into a migration (prompts shown one at a time). Returns
* whether to proceed and the code to run (with migrated statements stripped).
*/
export async function guard(code: string): Promise<{ proceed: boolean; code: string }> {
const statements = splitSqlStatements(code)
if (!statements.some((s) => isDdlStatement(s))) {
return { proceed: true, code }
}
const kept: string[] = []
for (const statement of statements) {
if (!isDdlStatement(statement)) {
kept.push(statement)
continue
}
const choice = await promptDdl(statement)
if (choice === 'cancel') {
return { proceed: false, code }
}
if (choice === 'run') {
kept.push(statement)
continue
}
// migrate: strip from the executed code and open the prefilled modal
await openMigrationModal(statement)
}
return { proceed: true, code: kept.join(';\n') }
}
</script>
<Modal2
title="Schema change detected"
fixedWidth="md"
fixedHeight="adaptive"
bind:isOpen={promptOpen}
closeOnOutsideClick={false}
>
<div class="flex flex-col gap-3 w-full">
<p class="text-sm text-secondary">
This looks like a schema-changing (DDL) statement. Schema changes are best tracked as
migrations rather than run ad-hoc. Create a migration for it instead?
</p>
<pre
class="text-xs whitespace-pre-wrap font-mono bg-surface-secondary rounded p-3 max-h-48 overflow-auto"
>{promptStatement ?? ''}</pre
>
<div class="flex justify-end gap-2 pt-2">
<Button variant="default" size="sm" on:click={() => finishPrompt('run')}>Run anyway</Button>
<Button variant="accent" size="sm" on:click={() => finishPrompt('migrate')}>
Create a migration
</Button>
</div>
</div>
</Modal2>
<NewDataTableMigrationModal
bind:this={newMigrationModal}
{workspace}
{datatable}
onClose={handleMigrationClosed}
/>
+30 -1
View File
@@ -43,6 +43,7 @@
import { editorFontSize } from '$lib/editorFontSize.svelte'
import { createHash as randomHash } from '$lib/editorLangUtils'
import { workspaceStore } from '$lib/stores'
import DdlMigrationGuard from './DdlMigrationGuard.svelte'
import {
type Preview,
ResourceService,
@@ -213,6 +214,26 @@
let lang = $state(scriptLangToEditorLang(untrack(() => scriptLang)))
// On a postgres script targeting a datatable, DDL statements are intercepted
// on run (cmd+enter) and offered as migrations instead.
let datatableForMigrations = $derived(
scriptLang === 'postgresql' &&
typeof args?.database === 'string' &&
args.database.startsWith('datatable://')
? args.database.slice('datatable://'.length).split('/')[0]
: undefined
)
let ddlGuard = $state<DdlMigrationGuard | undefined>(undefined)
async function runCmdEnterWithDdlGuard() {
if (datatableForMigrations && ddlGuard) {
const res = await ddlGuard.guard(getCode())
if (!res.proceed) return
if (res.code !== getCode()) setCode(res.code)
}
cmdEnterAction?.()
}
let filePath = $state(computePath(untrack(() => path)))
let initialPath: string | undefined = $state(untrack(() => path))
@@ -1539,7 +1560,8 @@
editor?.addCommand(KeyMod.CtrlCmd | KeyCode.Enter, function () {
updateCode()
shouldBindKey && cmdEnterAction && cmdEnterAction()
if (!shouldBindKey || !cmdEnterAction) return
void runCmdEnterWithDdlGuard()
})
editor?.addCommand(KeyMod.CtrlCmd | KeyMod.Shift | KeyCode.Digit7, function () {
@@ -2067,6 +2089,13 @@
<svelte:window onkeydown={onKeyDown} />
<EditorTheme />
{#if datatableForMigrations && $workspaceStore}
<DdlMigrationGuard
bind:this={ddlGuard}
workspace={$workspaceStore}
datatable={datatableForMigrations}
/>
{/if}
{#if !editor}
<div class="inset-0 absolute overflow-clip">
<FakeMonacoPlaceHolder {code} lineNumbersWidth={51} />
+25 -46
View File
@@ -1,49 +1,3 @@
<script module lang="ts">
// code may be composed of many sql statements separated by ';'
// this function splits them while taking into account that ';' may not
// be the end of a statement (string or escaped)
function splitSqlStatements(code: string) {
const statements: string[] = []
let currentStatement = ''
let inSingleQuote = false
let inDoubleQuote = false
let inBacktick = false
for (let i = 0; i < code.length; i++) {
const char = code[i]
const prevChar = i > 0 ? code[i - 1] : null
if (char === "'" && !inDoubleQuote && !inBacktick && prevChar !== '\\') {
inSingleQuote = !inSingleQuote
} else if (char === '"' && !inSingleQuote && !inBacktick && prevChar !== '\\') {
inDoubleQuote = !inDoubleQuote
} else if (char === '`' && !inSingleQuote && !inDoubleQuote && prevChar !== '\\') {
inBacktick = !inBacktick
}
if (char === ';' && !inSingleQuote && !inDoubleQuote && !inBacktick) {
statements.push(currentStatement.trim())
currentStatement = ''
} else {
currentStatement += char
}
}
if (currentStatement.trim()) {
statements.push(currentStatement.trim())
}
return statements
}
function pruneComments(code: string) {
return code
.replace(/--.*?(\r?\n|$)/g, '')
.replace(/\/\*[\s\S]*?\*\//g, '')
.trim()
}
</script>
<script lang="ts">
import { CornerDownLeft, Loader2 } from 'lucide-svelte'
import Button from './common/button/Button.svelte'
@@ -57,6 +11,8 @@
import { getDatabaseArg, getDbType } from './dbOps'
import type { DbInput } from './dbTypes'
import { wrapDucklakeQuery } from './ducklake'
import { splitSqlStatements, pruneComments } from './sqlDdl'
import DdlMigrationGuard from './DdlMigrationGuard.svelte'
type Props = {
input: DbInput
@@ -66,6 +22,15 @@
let { input, onData, placeholderTableName }: Props = $props()
let dbType = $derived(getDbType(input))
// A datatable REPL targets `datatable://<name>`; surface DDL statements as
// migration prompts instead of running them ad-hoc.
let datatableName = $derived(
input.type === 'database' && input.resourcePath.startsWith('datatable://')
? input.resourcePath.slice('datatable://'.length).split('/')[0]
: undefined
)
let ddlGuard = $state<DdlMigrationGuard | undefined>(undefined)
const DEFAULT_SQL = 'SELECT * FROM _'
let code = $state(DEFAULT_SQL)
$effect(() => {
@@ -81,6 +46,16 @@
async function run({ doPostgresRowToJsonFix }: { doPostgresRowToJsonFix?: boolean } = {}) {
if (isRunning || !$workspaceStore) return
const READ_OPS = ['SELECT', 'WITH', 'SHOW', 'EXPLAIN', 'DESCRIBE']
// On a datatable, intercept DDL statements and offer to make them
// migrations; the user may strip some, leaving the rest to run.
if (datatableName && ddlGuard && !doPostgresRowToJsonFix) {
const res = await ddlGuard.guard(code)
if (!res.proceed) return
if (res.code !== code) code = res.code
if (pruneComments(code).trim() === '') return
}
isRunning = true
try {
const statements = splitSqlStatements(pruneComments(code))
@@ -197,3 +172,7 @@
/>
</Pane>
</Splitpanes>
{#if datatableName && $workspaceStore}
<DdlMigrationGuard bind:this={ddlGuard} workspace={$workspaceStore} datatable={datatableName} />
{/if}
+57
View File
@@ -0,0 +1,57 @@
// Heuristics for splitting a SQL script into individual statements and
// detecting Data Definition Language (DDL) statements. Shared by the datatable
// SQL REPL and the postgres editor to steer schema changes into migrations.
// code may be composed of many sql statements separated by ';'
// this splits them while taking into account that ';' may appear inside a
// string or quoted identifier and is then not the end of a statement.
export function splitSqlStatements(code: string): string[] {
const statements: string[] = []
let currentStatement = ''
let inSingleQuote = false
let inDoubleQuote = false
let inBacktick = false
for (let i = 0; i < code.length; i++) {
const char = code[i]
const prevChar = i > 0 ? code[i - 1] : null
if (char === "'" && !inDoubleQuote && !inBacktick && prevChar !== '\\') {
inSingleQuote = !inSingleQuote
} else if (char === '"' && !inSingleQuote && !inBacktick && prevChar !== '\\') {
inDoubleQuote = !inDoubleQuote
} else if (char === '`' && !inSingleQuote && !inDoubleQuote && prevChar !== '\\') {
inBacktick = !inBacktick
}
if (char === ';' && !inSingleQuote && !inDoubleQuote && !inBacktick) {
statements.push(currentStatement.trim())
currentStatement = ''
} else {
currentStatement += char
}
}
if (currentStatement.trim()) {
statements.push(currentStatement.trim())
}
return statements.filter((s) => s.length > 0)
}
export function pruneComments(code: string): string {
return code
.replace(/--.*?(\r?\n|$)/g, '')
.replace(/\/\*[\s\S]*?\*\//g, '')
.trim()
}
// Schema-changing keywords. GRANT/REVOKE are included since permission changes
// also belong in migrations rather than ad-hoc execution.
const DDL_KEYWORDS = ['CREATE', 'ALTER', 'DROP', 'TRUNCATE', 'RENAME', 'COMMENT', 'GRANT', 'REVOKE']
/** Heuristic: a statement is DDL if its first keyword is schema-changing. */
export function isDdlStatement(statement: string): boolean {
const firstWord = pruneComments(statement).trim().split(/\s+/)[0]?.toUpperCase()
return !!firstWord && DDL_KEYWORDS.includes(firstWord)
}
@@ -1,17 +1,12 @@
<script lang="ts">
import { Button } from '../common'
import Modal2 from '../common/modal/Modal2.svelte'
import Tabs from '../common/tabs/Tabs.svelte'
import Tab from '../common/tabs/Tab.svelte'
import TabContent from '../common/tabs/TabContent.svelte'
import Toggle from '../Toggle.svelte'
import TextInput from '../text_input/TextInput.svelte'
import SimpleEditor from '../SimpleEditor.svelte'
import ConfirmationModal from '../common/confirmationModal/ConfirmationModal.svelte'
import { createAsyncConfirmationModal } from '../common/confirmationModal/asyncConfirmationModal.svelte'
import { ChevronDown, Play, Trash2, Plus, Undo2, Loader2 } from 'lucide-svelte'
import { WorkspaceService, type DatatableMigrationWithStatus } from '$lib/gen'
import { sendUserToast } from '$lib/toast'
import NewDataTableMigrationModal from './NewDataTableMigrationModal.svelte'
let {
workspace,
@@ -25,13 +20,7 @@
let loading = $state(false)
let busy = $state(false)
let newOpen = $state(false)
let newTab = $state('up')
let newName = $state('')
let newCodeUp = $state('')
let enableDown = $state(false)
let newCodeDown = $state('')
let creating = $state(false)
let newMigrationModal = $state<NewDataTableMigrationModal | undefined>(undefined)
const confirmationModal = createAsyncConfirmationModal()
@@ -128,52 +117,6 @@
}
}
function resetNew() {
newName = ''
newCodeUp = ''
newCodeDown = ''
enableDown = false
newTab = 'up'
}
function openNew() {
resetNew()
newOpen = true
}
async function create(run: boolean) {
if (newName.trim() === '') {
sendUserToast('Migration name is required', true)
return
}
creating = true
try {
const created = await WorkspaceService.createDatatableMigration({
workspace,
datatableName: datatable,
requestBody: {
name: newName.trim(),
code_up: newCodeUp,
code_down: enableDown ? newCodeDown : undefined
}
})
if (run) {
await WorkspaceService.runDatatableMigrations({
workspace,
datatableName: datatable,
upTo: created.timestamp
})
}
newOpen = false
sendUserToast(run ? 'Migration created and run' : 'Migration created')
await loadMigrations()
} catch (e) {
sendUserToast(`Failed to create migration: ${e}`, true)
} finally {
creating = false
}
}
const statusColor = {
ran: 'bg-green-500',
not_run: 'bg-orange-500',
@@ -245,7 +188,12 @@
{/if}
</div>
<div class="flex justify-between gap-2 pt-2">
<Button variant="default" size="sm" startIcon={{ icon: Plus }} on:click={openNew}>New</Button>
<Button
variant="default"
size="sm"
startIcon={{ icon: Plus }}
on:click={() => newMigrationModal?.open()}>New</Button
>
<div class="flex gap-2">
<Button
variant="default"
@@ -270,52 +218,11 @@
</div>
</Modal2>
<Modal2 bind:isOpen={newOpen} title="New migration" fixedWidth="md" fixedHeight="lg">
<div class="flex flex-col gap-3 w-full grow min-h-0">
<TextInput
bind:value={newName}
inputProps={{ placeholder: 'Migration name (e.g. add_index_to_customers)' }}
/>
<Tabs bind:selected={newTab} class="grow min-h-0">
<Tab value="up" label="Up" />
<Tab value="down" label="Down" />
{#snippet content()}
<TabContent value="up" class="h-80">
<SimpleEditor class="h-full" lang="sql" bind:code={newCodeUp} />
</TabContent>
<TabContent value="down" class="h-80">
<div class="flex flex-col gap-2 h-full">
<Toggle
bind:checked={enableDown}
options={{ right: 'Enable down migration' }}
size="sm"
/>
{#if enableDown}
<div class="grow min-h-0">
<SimpleEditor class="h-full" lang="sql" bind:code={newCodeDown} />
</div>
{/if}
</div>
</TabContent>
{/snippet}
</Tabs>
<div class="flex justify-end pt-2">
<Button
variant="accent"
size="sm"
disabled={creating}
on:click={() => create(true)}
dropdownItems={[
{
label: 'Create without running',
onClick: () => create(false)
}
]}
>
Create and run
</Button>
</div>
</div>
</Modal2>
<NewDataTableMigrationModal
bind:this={newMigrationModal}
{workspace}
{datatable}
onCreated={loadMigrations}
/>
<ConfirmationModal {...confirmationModal.props} />
@@ -0,0 +1,129 @@
<script lang="ts">
import { Button } from '../common'
import Modal2 from '../common/modal/Modal2.svelte'
import Tabs from '../common/tabs/Tabs.svelte'
import Tab from '../common/tabs/Tab.svelte'
import TabContent from '../common/tabs/TabContent.svelte'
import Toggle from '../Toggle.svelte'
import TextInput from '../text_input/TextInput.svelte'
import SimpleEditor from '../SimpleEditor.svelte'
import { WorkspaceService } from '$lib/gen'
import { sendUserToast } from '$lib/toast'
let {
workspace,
datatable,
onCreated,
onClose
}: {
workspace: string
datatable: string
onCreated?: () => void
onClose?: () => void
} = $props()
let isOpen = $state(false)
// Notify the parent whenever the modal closes (after a create or a cancel),
// so callers that sequence modals (e.g. the DDL guard) can advance.
let prevOpen = false
$effect(() => {
if (prevOpen && !isOpen) onClose?.()
prevOpen = isOpen
})
let tab = $state('up')
let name = $state('')
let codeUp = $state('')
let enableDown = $state(false)
let codeDown = $state('')
let creating = $state(false)
export function open(prefill?: { name?: string; codeUp?: string; codeDown?: string }) {
name = prefill?.name ?? ''
codeUp = prefill?.codeUp ?? ''
codeDown = prefill?.codeDown ?? ''
enableDown = (prefill?.codeDown ?? '') !== ''
tab = 'up'
isOpen = true
}
async function create(run: boolean) {
if (name.trim() === '') {
sendUserToast('Migration name is required', true)
return
}
creating = true
try {
const created = await WorkspaceService.createDatatableMigration({
workspace,
datatableName: datatable,
requestBody: {
name: name.trim(),
code_up: codeUp,
code_down: enableDown ? codeDown : undefined
}
})
if (run) {
await WorkspaceService.runDatatableMigrations({
workspace,
datatableName: datatable,
upTo: created.timestamp
})
}
isOpen = false
sendUserToast(run ? 'Migration created and run' : 'Migration created')
onCreated?.()
} catch (e) {
sendUserToast(`Failed to create migration: ${e}`, true)
} finally {
creating = false
}
}
</script>
<Modal2 bind:isOpen title="New migration — {datatable}" fixedWidth="md" fixedHeight="lg">
<div class="flex flex-col gap-3 w-full grow min-h-0">
<TextInput
bind:value={name}
inputProps={{ placeholder: 'Migration name (e.g. add_index_to_customers)' }}
/>
<Tabs bind:selected={tab} class="grow min-h-0">
<Tab value="up" label="Up" />
<Tab value="down" label="Down" />
{#snippet content()}
<TabContent value="up" class="h-80">
<SimpleEditor class="h-full" lang="sql" bind:code={codeUp} />
</TabContent>
<TabContent value="down" class="h-80">
<div class="flex flex-col gap-2 h-full">
<Toggle
bind:checked={enableDown}
options={{ right: 'Enable down migration' }}
size="sm"
/>
{#if enableDown}
<div class="grow min-h-0">
<SimpleEditor class="h-full" lang="sql" bind:code={codeDown} />
</div>
{/if}
</div>
</TabContent>
{/snippet}
</Tabs>
<div class="flex justify-end pt-2">
<Button
variant="accent"
size="sm"
disabled={creating}
on:click={() => create(true)}
dropdownItems={[
{
label: 'Create without running',
onClick: () => create(false)
}
]}
>
Create and run
</Button>
</div>
</div>
</Modal2>