Files
windmill/frontend/src/lib/components/SqlRepl.svelte
T
Diego ImbertandClaude Opus 5.5 4fd7d62bd0 feat: rework the db manager: native grid, tabs, sql editor, joined columns (#11340)
* feat: replace ag-grid in the db manager table viewer with a native grid

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* feat: add user-managed data, diagram and sql editor tabs to the db manager

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* feat: add schema autocomplete to the db manager sql editor and polish its layout

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* feat: add joined foreign key columns and draggable tabs to the db manager

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix: move db manager tabs on drop instead of mid-drag

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* feat: open followed foreign keys in a new db manager tab

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix: scroll large tables, drop stale joins and return to the last tab on close

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix: tolerate the db manager tabs going away while switching data table

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix: keep saved joined columns while metadata loads, test joins in every dialect

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix: put the db manager grid on the input surface in dark mode

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix: address db manager review nits on joins, boolean keys and sql quoting

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix: keep the dark border color on the left pinned column edge

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix: tone down the db manager tree menu icons

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix: write json and jsonb values from the db manager through a text cast

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix: dim unrelated diagram tables less on hover

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix: match json columns as text when deleting a db manager row

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix: compare postgres json columns as text in exact db manager filters

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* feat: pick the columns the db manager grid shows

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* feat: toggle every db manager column from one checkbox

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* feat: word joined columns as a view in the db manager

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* feat: keep data table roles and access visible when unavailable, with the reason

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* feat: keep data table and instance roles visible in settings when unavailable

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* feat: hide a db manager column from its header menu

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* feat: format db manager columns with a unit, significant digits and color rules

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix: drop the before/after hints from the db manager unit picker

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix: write the euro after the amount and keep units off non-numeric values

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* feat: color rule presets, bold and italic, layered and reorderable rules in the db manager

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* feat: decimals, thousands separator, compact notation and alignment in db manager column formats

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* feat: compact db manager format controls, a notation toggle group and a reset button

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix: db manager format and filter nits

Keep the value's scale when decimals are auto, read boolean color-rule conditions as
booleans, let a rule's text color reach foreign-key links, close the formatter when the
columns picker opens, and filter BigQuery complex columns through TO_JSON_STRING.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-25 15:58:25 +02:00

275 lines
9.1 KiB
Svelte

<script lang="ts">
import { CornerDownLeft, History, Loader2, X } from 'lucide-svelte'
import Button from './common/button/Button.svelte'
import { runScriptAndPollResult } from './jobs/utils'
import { writingJobOptions } from './jobs/writingJob'
import { sendUserToast } from '$lib/toast'
import { untrack } from 'svelte'
import { getLanguageByResourceType } from './apps/components/display/dbtable/utils'
import StepHistory, { type StepHistoryData } from './flows/propPicker/StepHistory.svelte'
import { getDatabaseArg, getDbType } from './dbOps'
import type { DbInput } from './dbTypes'
import type { DBSchema } from '$lib/stores'
import { wrapDucklakeQuery } from './ducklake'
import { splitSqlStatements, pruneComments } from './sqlDdl'
import DdlMigrationGuard from './DdlMigrationGuard.svelte'
import { useOperatingWorkspace } from '$lib/components/operatingWorkspace.svelte'
const operatingWorkspace = useOperatingWorkspace()
type Props = {
input: DbInput
/** `ranCode` is the editor content that produced `data`, captured when the
* run started, so a caller can discard a late response whose query it has
* moved past. */
onData: (data: Record<string, any>[], ranCode: string) => void
placeholderTableName?: string
/** Called after a migration is run via the DDL guard, so the schema view
* can be refreshed to reflect the applied change. */
onSchemaChange?: () => void
/** Workspace the REPL queries and DDL migrations run against; defaults to
* the nav workspace. */
workspace?: string | undefined
/** Worker tag the queries run on instead of the language's native one. */
tag?: string | undefined
/** Editor content to start from instead of the placeholder query. */
initialCode?: string
onCodeChange?: (code: string) => void
/** The database's schema, for completing table and column names. */
schema?: DBSchema
}
let {
input,
onData,
placeholderTableName,
onSchemaChange,
workspace = undefined,
tag = undefined,
initialCode,
onCodeChange,
schema
}: Props = $props()
let ws = $derived(workspace ?? $operatingWorkspace)
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(untrack(() => initialCode) ?? DEFAULT_SQL)
/** Seed the editor from outside, e.g. a query composed in a drawer. */
export function setCode(newCode: string) {
code = newCode
editor?.setCode?.(newCode)
}
$effect(() => {
const _code = untrack(() => code)
if (placeholderTableName && _code === DEFAULT_SQL) {
code = _code.replace('_', placeholderTableName ?? 'table')
}
})
let isRunning = $state(false)
let runHistory: (StepHistoryData & { code: string; result: Record<string, any>[] })[] = $state([])
async function run({ doPostgresRowToJsonFix }: { doPostgresRowToJsonFix?: boolean } = {}) {
if (isRunning || !ws) 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)
// A migration that was created and run changes the schema; refresh it.
if (res.ranMigration) onSchemaChange?.()
if (!res.proceed) return
if (res.code !== code) code = res.code
if (pruneComments(code).trim() === '') return
}
// Snapshot the code that will actually run: after the DDL guard has stripped
// any migrated statements, and before the execution await during which
// `code` can be re-seeded (setCode). The caller identifies which query a late
// response belongs to by this value, and history restores exactly it.
const ranCode = code
isRunning = true
try {
const statements = splitSqlStatements(pruneComments(code))
if (statements.length === 0) {
sendUserToast('Nothing to run', true)
return
}
// Transform all to JSON in case of select. This fixes the issue of
// custom postgres enum type failing to convert to a rust type in the backend.
// We don't always put the fix by default for row ordering concerns
let transformedCode = code
if (doPostgresRowToJsonFix) {
// Rebuilt from the pruned statements, which drops the leading comment block — and
// with it the `-- role <name>` annotation that decides which login the query runs
// as. Carry it over, or the retry connects as the data table's default role and a
// query the first attempt was denied succeeds on the second.
const leadingAnnotations = code.match(/^(?:[^\S\n]*\n|[^\S\n]*--[^\n]*\n)*/)?.[0] ?? ''
transformedCode =
leadingAnnotations +
statements
.map((statement) => {
if (READ_OPS.some((op) => statement.trim().toUpperCase().startsWith(op))) {
return `SELECT row_to_json(__t__) FROM (${statement}) __t__`
}
return statement
})
.join(';')
}
const dbArg = getDatabaseArg(input)
if (input?.type === 'ducklake') {
transformedCode = wrapDucklakeQuery(transformedCode, input.ducklake)
}
let { job, result } = (await runScriptAndPollResult(
{
workspace: ws,
requestBody: {
language: getLanguageByResourceType(dbType),
content: transformedCode,
args: dbArg,
tag
}
},
// The user types arbitrary SQL here, so treat every run as a write.
{ withJobData: true, ...writingJobOptions }
)) as any
if (statements.length > 1) {
result = result[result.length - 1]
}
if (!Array.isArray(result)) {
sendUserToast('Query result is not an array', true)
return
}
if (doPostgresRowToJsonFix) {
result = result.map((row: any) => row['row_to_json'])
}
if (
READ_OPS.some((op) => statements[statements.length - 1].toUpperCase().trim().startsWith(op))
) {
runHistory.push({
created_at: new Date().toISOString(),
created_by: '',
id: job.id,
success: true,
// The snapshot, not the live `code`: a mid-run setCode() must not
// rebind this entry's result to a different query. Selecting it later
// reseeds the editor with this exact SQL and re-fires onData with it.
code: ranCode,
result
})
onData(result, ranCode)
}
if (doPostgresRowToJsonFix)
sendUserToast('Query failed but recovered with the row_to_json fix')
else sendUserToast('Query executed')
} catch (e) {
console.error(e)
if (dbType === 'postgresql' && !doPostgresRowToJsonFix) {
console.error('Error running query, trying with row_to_json fix')
isRunning = false
return await run({ doPostgresRowToJsonFix: true })
}
sendUserToast(
'Error running query: ' + (e?.body ?? e?.message ?? e?.error?.message ?? e),
true
)
} finally {
isRunning = false
}
}
let editor = $state<any | null>(null)
let historyOpen = $state(true)
</script>
<div class="relative h-full w-full">
{#await import('$lib/components/Editor.svelte')}
<Loader2 class="animate-spin" />
{:then Module}
<Module.default
bind:this={editor}
bind:code={() => code, (v) => ((code = v), onCodeChange?.(v))}
scriptLang="mysql"
sqlSchema={schema}
class="w-full h-full"
cmdEnterAction={run}
/>
{/await}
<Button
wrapperClasses="absolute z-10 bottom-2 right-6"
variant="accent"
destructive={isRunning}
shortCut={{ Icon: CornerDownLeft }}
on:click={() => run()}
>
{isRunning ? 'Running...' : 'Run'}
</Button>
<!-- Floats over the editor rather than taking a pane of its own, so the query keeps the
full width. -->
{#if historyOpen}
<div
class="absolute right-6 top-3 z-10 flex h-96 w-72 flex-col overflow-hidden rounded-lg border bg-surface-tertiary shadow-md"
style="max-height: calc(100% - 4.5rem)"
>
<div class="flex items-center justify-between border-b py-1 pl-3 pr-1">
<span class="text-xs font-semibold text-emphasis">Run history</span>
<Button
variant="subtle"
unifiedSize="xs"
iconOnly
startIcon={{ icon: X }}
title="Hide run history"
onClick={() => (historyOpen = false)}
/>
</div>
<div class="min-h-0 flex-1 overflow-auto">
<StepHistory
staticInputs={runHistory}
on:select={(e) => {
const data = e.detail as (typeof runHistory)[number]
editor?.setCode(data.code)
onData(data.result, data.code)
}}
/>
</div>
</div>
{:else}
<div class="absolute right-6 top-3 z-10">
<Button
variant="default"
unifiedSize="sm"
iconOnly
btnClasses="bg-surface-tertiary"
startIcon={{ icon: History }}
title="Show run history"
onClick={() => (historyOpen = true)}
/>
</div>
{/if}
</div>
{#if datatableName && ws}
<DdlMigrationGuard
bind:this={ddlGuard}
workspace={ws}
datatable={datatableName}
role={input.type === 'database' ? (input.role ?? input.migrationRole) : undefined}
/>
{/if}