Files
windmill/frontend/src/lib/components/DBTable.svelte
T
Diego Imbert a45c9ca525 feat: Ducklake native support (#6268)
* upgrade duckdb

* basic ducklake works

* ducklake works with custom db catalogs

* fix: pwsh skip already installed modules outside of cache (#6037)

* improve query performance of user stats

* separate ducklake_catalog db

* ducklake settings

* DucklakeSettings frontend

* Ducklake ws settings saved in backend

* fetch ducklake catalog resource

* Ducklake works with configured s3 storage

* Ducklake as asset

* ducklake asset icon

* Fix duckdb array and object args not working properly (#6254)

* Fix bug with comments in duckdb

* Avoid multiple queries when doing ATTACH ducklake

* trunc sig no longer needed now that comments are trimmed

* cache DuckdbConnectionSettingsResponse

* duplicated code

* transform_attach_ducklake contributes to duckdb_connection_settings_cache

* eliminate the need for used_storages

* nit

* cleaner management of the bigquery credentials file

* DBManagerDrawer refactor to prepare for Ducklake

* get ducklake schema

* implement delete for ducklake

* load column metadata for ducklake

* Select query works for ducklake, basic db explorer works !

* duckdb count query

* Support all db ops for ducklake

* clean migrations

* SQL repl for Ducklake

* fix broken database studio

* nit

* assert function

* Ducklake in Editor Bar

* default ducklake syntax + allow extra args

* DucklakeCatalogWizard UI

* nit + remove extra $

* modal when databases do not exist

* cannot be windmill

* Ducklake works safely with instance database

* Avoid sending instance db credentials on network

* resource leak security

* remove fetch_attach_db_conn_str

* prevent instance pg password leak

* hide asset usage count when not available

* case unsensitivity duckdb

* warnings

* disable instance catalog

* use shorthand syntax when inserting with EditorBar

* Instance ducklake catalog is now safe to use

* use safer argon2 pwd

* update package json parsers

* update package json

* better msgs

* tooltips

* disable explore button until saved

* nit

* fix warnings

* better ducklake_user password management

* nit

* Sanitize passwords from errors in ducklake

* DisplayResult broken in job result

* remove superadmin requirement to check databases_exist

* duckdb_connection_settings_v2_inner

* Ducklake works on agent worker (finally)

* ci

* #[allow(dead_code)]

* fix openapi missing response

* Separate +Database button for DuckDB in EditorBar

* Fix dropdown in ducklake settings

* Attempt to fix migration race condition in CI

* update sqlx failing for some offline queries

* avoid temp password for ducklake_user

* nits

* ducklake settings nits

* update duckdb default script

* fix sql repl resetting text on refresh

* avoid pgcrypto extension

---------

Co-authored-by: HugoCasa <hugo@casademont.ch>
Co-authored-by: Ruben Fiszel <ruben@windmill.dev>
2025-08-06 13:55:36 +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)
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(() => {
sendUserToast('Error deleting row', 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>