Files
windmill/frontend/src/lib/components/DBTable.svelte
T
Diego Imbert aa30fd252d fix: Move database manager SQL queries to backend (#8306)
* SQL Query builders in Rust

* Remove frontend sql scripts and substitute at execution

* fix null value bug

* Handle WM_INTERNAL_DB marker for apps deployed prior

* Revert policy handling

* Fix database studio empty string as where clause

* check policy

* Revert "check policy"

This reverts commit 3ea7899979.

* Revert "Fix database studio empty string as where clause"

This reverts commit 432fc87915.

* Revert

* legacy comments

* Move DDL queries to backend

* tests

* move bigquery bun scripts to backend

* expand markers + other nits

* fix: escape sql literals in query builders and async preview sql

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix: quote all user-supplied identifiers in query builders to prevent SQL injection

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: suppress dead_code warnings for deserialization-only fields and test-only helpers

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: correct DDL test assertions and drop_table schema handling for non-schema DBs

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* MySQL fix

* Fix 0/1 bool

* MySQL fix Yes/No casing

* Better error toasts

* Fix ms sql ntext cast

* fix: quote table name in Snowflake SHOW PRIMARY KEYS query

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix: quote schema and table in Snowflake SHOW IMPORTED KEYS query

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix: quote BigQuery dataset name in metadata query

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix: remove invalid + separator in MSSQL CONCAT for count query

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-03-24 09:45:37 +00:00

210 lines
5.9 KiB
Svelte

<script lang="ts">
import { workspaceStore } from '$lib/stores'
import { sendUserToast } from '$lib/toast'
import { createGrid, type GridApi, type IDatasource } from 'ag-grid-community'
import { transformColumnDefs } from './apps/components/display/table/utils'
import DarkModeObserver from './DarkModeObserver.svelte'
import { Button } from './common'
import { Download } from 'lucide-svelte'
import Popover from './Popover.svelte'
import DebouncedInput from './apps/components/helpers/DebouncedInput.svelte'
import InsertRowDrawerButton from './apps/components/display/InsertRowDrawerButton.svelte'
import type { IDbTableOps } from './dbOps'
import { deepEqual } from 'fast-equals'
import 'ag-grid-community/styles/ag-grid.css'
import 'ag-grid-community/styles/ag-theme-alpine.css'
import '$lib/components/apps/components/display/table/theme/windmill-theme.css'
import { untrack } from 'svelte'
type Props = {
dbTableOps: IDbTableOps
}
let { dbTableOps }: Props = $props()
let [clientHeight, clientWidth, darkMode, firstRow, lastRow] = $state([0, 0, false, -1, -1])
let quicksearch = $state('')
let api: GridApi<any> | undefined = $state()
let eGui: HTMLDivElement | undefined = $state()
let datasource: IDatasource = {
getRows: async function (params) {
if (!$workspaceStore) return params.failCallback()
let lastRow = rowCount && rowCount <= params.endRow ? rowCount : -1
const items = await dbTableOps.getRows({
offset: params.startRow,
limit: params.endRow - params.startRow,
quicksearch: params.context.quicksearch,
order_by: params.sortModel?.[0]?.colId ?? dbTableOps.colDefs[0]?.field,
is_desc: params.sortModel?.[0]?.sort === 'desc'
})
if (items.length < params.endRow - params.startRow) lastRow = params.startRow + items.length
params.successCallback(items, lastRow)
}
}
let rowCount = $state(0)
let refreshCount = $state(0)
export const refresh = () => (refreshCount += 1)
$effect(() => eGui && untrack(() => mountGrid()))
function mountGrid() {
if (eGui && !api) {
createGrid(eGui, {
rowModelType: 'infinite',
pagination: false,
...(dbTableOps.onUpdate && {
defaultColDef: {
flex: 1,
minWidth: 150,
editable: true,
onCellValueChanged: (e) => {
if (!$workspaceStore) return
const colDef = e.colDef as unknown as { field: string; datatype: string }
dbTableOps
.onUpdate?.(
{ values: { ...(e.data as object), [colDef.field]: e.oldValue } },
colDef,
e.newValue
)
.then(() => {
sendUserToast('Value updated')
})
.catch(() => {
sendUserToast('Error updating value', true)
refresh?.()
})
}
}
}),
onViewportChanged: (e) => ([firstRow, lastRow] = [e.firstRow, e.lastRow]),
cacheBlockSize: 100,
cacheOverflowSize: 10,
maxBlocksInCache: 20,
suppressColumnMoveAnimation: true,
suppressDragLeaveHidesColumns: true,
onGridReady: (e) => {
api = e.api
}
})
}
}
let prevUpdateKey: any = undefined
$effect(() => {
if (!$workspaceStore || !api) return
const key = { quicksearch, colDefs: dbTableOps.colDefs, refreshCount }
if (deepEqual(key, prevUpdateKey)) return
prevUpdateKey = key
untrack(() => updateGrid())
})
function updateGrid() {
dbTableOps.getCount({ quicksearch }).then((result) => (rowCount = result))
api?.purgeInfiniteCache()
api?.updateGridOptions({
datasource,
columnDefs: transformColumnDefs({
columnDefs: dbTableOps.colDefs ?? [],
...(dbTableOps.onDelete && {
onDelete: (values) => {
if (!$workspaceStore) return
dbTableOps
.onDelete?.({ values })
.then(() => {
refresh?.()
sendUserToast('Row deleted')
})
.catch((e) => {
sendUserToast(`Error deleting row: ${e?.message ?? e}`, true)
})
}
})
}),
context: {
quicksearch
}
})
}
</script>
<DarkModeObserver bind:darkMode />
<div class="h-full relative flex flex-col">
<div class="flex py-2 h-12 justify-between gap-4">
<DebouncedInput
class="w-full max-w-[300px]"
type="text"
bind:value={quicksearch}
placeholder="Search..."
/>
{#if dbTableOps.onInsert}
<InsertRowDrawerButton
columnDefs={dbTableOps.colDefs ?? []}
dbType={dbTableOps.dbType}
onInsert={(values) => {
if (!$workspaceStore) return
dbTableOps.onInsert?.({ values }).then((result) => {
refresh?.()
sendUserToast('Row inserted')
})
}}
/>
{/if}
</div>
<div
class={'flex flex-col flex-1 component-wrapper divide-y wm-aggrid-container'}
bind:clientHeight
bind:clientWidth
>
<div
style:height="{clientHeight}px"
style:width="{clientWidth}px"
class="ag-theme-alpine"
class:ag-theme-alpine-dark={darkMode}
>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
bind:this={eGui}
class={api ? '' : 'opacity-0'}
style:height="100%"
onkeydown={(e) => {
if ((e.ctrlKey || e.metaKey) && e.key === 'c') {
const selectedCell = api?.getFocusedCell()
if (selectedCell) {
const rowIndex = selectedCell.rowIndex
const colId = selectedCell.column?.getId()
const rowNode = api?.getDisplayedRowAtIndex(rowIndex)
const selectedValue = rowNode?.data?.[colId]
navigator.clipboard.writeText(selectedValue)
sendUserToast('Copied cell value to clipboard', false)
}
}
}}
></div>
</div>
<div class="flex gap-1 w-full justify-between items-center text-xs text-primary p-2">
<div>
<Popover>
{#snippet text()}
Download
{/snippet}
<Button
startIcon={{ icon: Download }}
color="light"
size="xs2"
on:click={() => api?.exportDataAsCsv()}
iconOnly
/>
</Popover>
</div>
{#if rowCount}
{firstRow}{'->'}{lastRow + 1} of {rowCount} rows
{:else}
{firstRow}{'->'}{lastRow + 1}
{/if}
</div>
</div>
</div>