mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-09-08 16:03:27 +00:00
feat: go to referenced row from foreign-keyed cells in the database manager (#10998)
* feat: go to referenced row from foreign-keyed cells in the database manager Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0157Kw1ukbQo7eZnmyM63G4t * fix: pin foreign keys to their table and escape backslashes on snowflake Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0157Kw1ukbQo7eZnmyM63G4t * fix: address review on foreign key navigation (stale fetch, qualifiers, chip) Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0157Kw1ukbQo7eZnmyM63G4t * fix: unicode literals on sql server and hide unreachable foreign key targets Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0157Kw1ukbQo7eZnmyM63G4t --------- Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5.1
parent
5da4ea43fb
commit
e2b63d177a
@@ -12,8 +12,8 @@
|
||||
import { Pane, Splitpanes } from 'svelte-splitpanes'
|
||||
import { ClearableInput, Drawer, DrawerContent } from './common'
|
||||
import { sendUserToast } from '$lib/toast'
|
||||
import { type ColumnDef } from './apps/components/display/dbtable/utils'
|
||||
import DBTable from './DBTable.svelte'
|
||||
import { renderDbEqualityFilter, type ColumnDef } from './apps/components/display/dbtable/utils'
|
||||
import DBTable, { type DbForeignKeyTarget, type DbRowFilter } from './DBTable.svelte'
|
||||
import type { IDbSchemaOps, IDbTableOps } from './dbOps'
|
||||
import DropdownV2 from './DropdownV2.svelte'
|
||||
import ConfirmationModal from './common/confirmationModal/ConfirmationModal.svelte'
|
||||
@@ -46,7 +46,12 @@
|
||||
dbSupportsSchemas: boolean
|
||||
databaseIsEmpty?: boolean
|
||||
colDefs: Record<string, ColumnDef[]> | undefined
|
||||
dbTableOpsFactory: (params: { colDefs: ColumnDef[]; tableKey: string }) => IDbTableOps
|
||||
dbTableOpsFactory: (params: {
|
||||
colDefs: ColumnDef[]
|
||||
tableKey: string
|
||||
/** Raw SQL predicate AND-ed into the reads (already escaped). */
|
||||
whereClause?: string
|
||||
}) => IDbTableOps
|
||||
dbSchemaOps: IDbSchemaOps
|
||||
refresh?: () => void
|
||||
initialSchemaKey?: string
|
||||
@@ -214,6 +219,83 @@
|
||||
: selected.tableKey
|
||||
)
|
||||
|
||||
// Set by "Go to row" on a foreign-keyed cell; pinned to the table it was
|
||||
// created for so a schema change can't carry it onto an unrelated table.
|
||||
let rowFilter: (DbRowFilter & { tableKey: string }) | undefined = $state()
|
||||
let activeRowFilter = $derived(rowFilter?.tableKey === tableKey ? rowFilter : undefined)
|
||||
let whereClause = $derived(
|
||||
activeRowFilter
|
||||
? renderDbEqualityFilter(activeRowFilter.column, activeRowFilter.value, dbType)
|
||||
: undefined
|
||||
)
|
||||
|
||||
function selectTable(schemaKey: string | undefined, table: string) {
|
||||
rowFilter = undefined
|
||||
selected = { schemaKey, tableKey: table }
|
||||
}
|
||||
|
||||
/** Where a foreign key's `schema.table` target lives in the sidebar, or
|
||||
* undefined when it cannot be opened from here. */
|
||||
function resolveForeignKeyTarget(
|
||||
targetTable: string
|
||||
): { schemaKey: string; table: string } | undefined {
|
||||
const parts = targetTable.split('.')
|
||||
const table = parts[parts.length - 1]
|
||||
const qualifier = parts.length > 1 ? parts.slice(0, -1).join('.') : undefined
|
||||
// Without schema support the sidebar browses the connection's default
|
||||
// schema only, and unqualified reads would hit a same-named local table.
|
||||
if (!dbSupportsSchemas && qualifier && qualifier !== selected.schemaKey) return undefined
|
||||
const schemaKey = dbSupportsSchemas && qualifier ? qualifier : selected.schemaKey
|
||||
if (!schemaKey || !(table in (dbSchema.schema[schemaKey] ?? {}))) return undefined
|
||||
return { schemaKey, table }
|
||||
}
|
||||
|
||||
function goToRow(target: DbForeignKeyTarget) {
|
||||
const resolved = resolveForeignKeyTarget(target.table)
|
||||
if (!resolved) {
|
||||
sendUserToast(`Table ${target.table} cannot be opened from this schema`, true)
|
||||
return
|
||||
}
|
||||
if (renderDbEqualityFilter(target.column, target.value, dbType) === undefined) {
|
||||
sendUserToast('This value cannot be used as a filter', true)
|
||||
return
|
||||
}
|
||||
const { schemaKey, table } = resolved
|
||||
selectTable(schemaKey, table)
|
||||
rowFilter = {
|
||||
tableKey: dbSupportsSchemas ? `${schemaKey}.${table}` : table,
|
||||
column: target.column,
|
||||
value: target.value
|
||||
}
|
||||
}
|
||||
|
||||
// The result carries the table it was fetched for: `resource` keeps the
|
||||
// previous value while refetching, and a stale list would decorate the new
|
||||
// table's same-named columns as foreign keys.
|
||||
let foreignKeys = resource(
|
||||
[() => selected.tableKey, () => selected.schemaKey, () => colDefs],
|
||||
async ([table, schema], _prev, { signal }) => {
|
||||
if (!table) return undefined
|
||||
const forTableKey = dbSupportsSchemas && schema ? `${schema}.${table}` : table
|
||||
const fks =
|
||||
features?.foreignKeys === false
|
||||
? []
|
||||
: await dbSchemaOps.onFetchForeignKeys({ table, schema })
|
||||
// A newer selection started meanwhile: an AbortError keeps this result
|
||||
// out of `current`, where it would shadow the newer table's keys.
|
||||
if (signal.aborted) throw new DOMException('Superseded', 'AbortError')
|
||||
return { tableKey: forTableKey, foreignKeys: fks }
|
||||
}
|
||||
)
|
||||
// Only keys whose target the sidebar can open get the "Go to row" affordance.
|
||||
let currentForeignKeys = $derived.by(() => {
|
||||
const fetched = foreignKeys.current
|
||||
if (!fetched || fetched.tableKey !== tableKey) return undefined
|
||||
return fetched.foreignKeys.filter(
|
||||
(fk) => fk.targetTable && resolveForeignKeyTarget(fk.targetTable) !== undefined
|
||||
)
|
||||
})
|
||||
|
||||
let askingForConfirmation:
|
||||
| (ConfirmationModal['$$prop_def'] & { onConfirm: () => void })
|
||||
| undefined = $state()
|
||||
@@ -395,14 +477,12 @@
|
||||
role="button"
|
||||
tabindex="0"
|
||||
onclick={() => {
|
||||
selected.schemaKey = schemaKey
|
||||
selected.tableKey = tableKey
|
||||
selectTable(schemaKey, tableKey)
|
||||
toggleTableSelection(schemaKey, tableKey)
|
||||
}}
|
||||
onkeydown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
selected.schemaKey = schemaKey
|
||||
selected.tableKey = tableKey
|
||||
selectTable(schemaKey, tableKey)
|
||||
toggleTableSelection(schemaKey, tableKey)
|
||||
}
|
||||
}}
|
||||
@@ -468,7 +548,7 @@
|
||||
<button
|
||||
class={'w-full text-sm font-normal flex gap-2 items-center h-10 cursor-pointer pl-3 pr-1 ' +
|
||||
(selected.tableKey === tableKey ? 'bg-surface-secondary' : 'hover:bg-surface-hover')}
|
||||
onclick={() => (selected.tableKey = tableKey)}
|
||||
onclick={() => selectTable(selected.schemaKey, tableKey)}
|
||||
>
|
||||
{#if asset}
|
||||
<Star
|
||||
@@ -541,8 +621,15 @@
|
||||
</Pane>
|
||||
<Pane class="p-3 pt-1">
|
||||
{#if tableKey && colDefs?.[tableKey]?.length}
|
||||
{@const dbTableOps = dbTableOpsFactory({ colDefs: colDefs[tableKey], tableKey })}
|
||||
<DBTable {dbTableOps} bind:this={_dbTable} />
|
||||
{@const dbTableOps = dbTableOpsFactory({ colDefs: colDefs[tableKey], tableKey, whereClause })}
|
||||
<DBTable
|
||||
{dbTableOps}
|
||||
foreignKeys={currentForeignKeys}
|
||||
onGoToRow={goToRow}
|
||||
rowFilter={activeRowFilter}
|
||||
onClearRowFilter={() => (rowFilter = undefined)}
|
||||
bind:this={_dbTable}
|
||||
/>
|
||||
{:else if databaseIsEmpty}
|
||||
<div class="h-full w-full center-center flex-col gap-4">
|
||||
<span class="text-hint">Database is empty</span>
|
||||
|
||||
@@ -277,10 +277,11 @@
|
||||
databaseIsEmpty={!Object.values(dbSchema.schema).flatMap((s) => Object.values(s)).length}
|
||||
{dbSchema}
|
||||
colDefs={colDefs.current}
|
||||
dbTableOpsFactory={({ colDefs, tableKey }) =>
|
||||
dbTableOpsFactory={({ colDefs, tableKey, whereClause }) =>
|
||||
dbTableOpsWithPreviewScripts({
|
||||
colDefs,
|
||||
tableKey,
|
||||
whereClause,
|
||||
input: _input,
|
||||
workspace: ws,
|
||||
tag: workerTag
|
||||
|
||||
@@ -1,11 +1,78 @@
|
||||
<script lang="ts" module>
|
||||
import type { ColDef, ITooltipComp, ITooltipParams } from 'ag-grid-community'
|
||||
import type { TableEditorForeignKey } from './apps/components/display/dbtable/tableEditor'
|
||||
import { mount, unmount } from 'svelte'
|
||||
import DbForeignKeyTooltip from './DbForeignKeyTooltip.svelte'
|
||||
|
||||
export type DbRowFilter = { column: string; value: unknown }
|
||||
export type DbForeignKeyTarget = { table: string; column: string; value: unknown }
|
||||
|
||||
type FkTooltipValue = { table: string; column: string; value: unknown }
|
||||
|
||||
/** AG Grid tooltip hosting the "Go to row" popover of a foreign-keyed cell. */
|
||||
class ForeignKeyTooltip implements ITooltipComp {
|
||||
private eGui = document.createElement('div')
|
||||
private component: ReturnType<typeof mount> | undefined
|
||||
|
||||
init(params: ITooltipParams & { onGoToRow: (target: DbForeignKeyTarget) => void }) {
|
||||
const target = params.value as FkTooltipValue
|
||||
this.component = mount(DbForeignKeyTooltip, {
|
||||
target: this.eGui,
|
||||
props: {
|
||||
targetTable: target.table,
|
||||
targetColumn: target.column,
|
||||
onGoToRow: () => {
|
||||
params.hideTooltipCallback?.()
|
||||
params.onGoToRow(target)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
getGui() {
|
||||
return this.eGui
|
||||
}
|
||||
destroy() {
|
||||
if (this.component) unmount(this.component)
|
||||
}
|
||||
}
|
||||
|
||||
const hasForeignKeyValue = (value: unknown) =>
|
||||
value !== null && value !== undefined && value !== ''
|
||||
|
||||
/** Single-column foreign keys only: a composite key has no one cell value to follow. */
|
||||
function foreignKeyColDefs(
|
||||
foreignKeys: TableEditorForeignKey[],
|
||||
onGoToRow: (target: DbForeignKeyTarget) => void
|
||||
): Record<string, Partial<ColDef>> {
|
||||
const out: Record<string, Partial<ColDef>> = {}
|
||||
for (const fk of foreignKeys) {
|
||||
if (fk.columns.length !== 1) continue
|
||||
const { sourceColumn, targetColumn } = fk.columns[0]
|
||||
if (!sourceColumn || !targetColumn || !fk.targetTable) continue
|
||||
const table = fk.targetTable
|
||||
out[sourceColumn] = {
|
||||
tooltipComponent: ForeignKeyTooltip,
|
||||
tooltipComponentParams: { onGoToRow },
|
||||
tooltipValueGetter: (p): FkTooltipValue | null =>
|
||||
hasForeignKeyValue(p.value) ? { table, column: targetColumn, value: p.value } : null,
|
||||
cellClass: (p) =>
|
||||
hasForeignKeyValue(p.value)
|
||||
? 'underline decoration-dotted decoration-tertiary underline-offset-2'
|
||||
: ''
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
</script>
|
||||
|
||||
<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 { Badge, Button } from './common'
|
||||
import { Download, X } from 'lucide-svelte'
|
||||
import Popover from './Popover.svelte'
|
||||
import DebouncedInput from './apps/components/helpers/DebouncedInput.svelte'
|
||||
import InsertRowDrawerButton from './apps/components/display/InsertRowDrawerButton.svelte'
|
||||
@@ -18,8 +85,14 @@
|
||||
|
||||
type Props = {
|
||||
dbTableOps: IDbTableOps
|
||||
/** Foreign keys of the displayed table; their cells get a "Go to row" popover. */
|
||||
foreignKeys?: TableEditorForeignKey[]
|
||||
onGoToRow?: (target: DbForeignKeyTarget) => void
|
||||
/** Equality filter already applied by `dbTableOps`, shown as a dismissable chip. */
|
||||
rowFilter?: DbRowFilter
|
||||
onClearRowFilter?: () => void
|
||||
}
|
||||
let { dbTableOps }: Props = $props()
|
||||
let { dbTableOps, foreignKeys, onGoToRow, rowFilter, onClearRowFilter }: Props = $props()
|
||||
|
||||
let [clientHeight, clientWidth, darkMode, firstRow, lastRow] = $state([0, 0, false, -1, -1])
|
||||
let quicksearch = $state('')
|
||||
@@ -78,6 +151,10 @@
|
||||
}
|
||||
}),
|
||||
onViewportChanged: (e) => ([firstRow, lastRow] = [e.firstRow, e.lastRow]),
|
||||
// Lets the pointer move from a foreign-keyed cell into its "Go to row" popover.
|
||||
tooltipInteraction: true,
|
||||
// AG Grid floors the show delay at 200ms whatever value is given.
|
||||
tooltipShowDelay: 0,
|
||||
cacheBlockSize: 100,
|
||||
cacheOverflowSize: 10,
|
||||
maxBlocksInCache: 20,
|
||||
@@ -93,34 +170,48 @@
|
||||
let prevUpdateKey: any = undefined
|
||||
$effect(() => {
|
||||
if (!$workspaceStore || !api) return
|
||||
const key = { quicksearch, colDefs: dbTableOps.colDefs, refreshCount }
|
||||
const key = { quicksearch, colDefs: dbTableOps.colDefs, refreshCount, rowFilter }
|
||||
if (deepEqual(key, prevUpdateKey)) return
|
||||
prevUpdateKey = key
|
||||
untrack(() => updateGrid())
|
||||
})
|
||||
// Foreign keys arrive after the rows: refresh the column defs alone so the
|
||||
// popovers appear without re-running the row and count queries.
|
||||
let prevForeignKeys: TableEditorForeignKey[] | undefined = undefined
|
||||
$effect(() => {
|
||||
if (!api || foreignKeys === prevForeignKeys) return
|
||||
prevForeignKeys = foreignKeys
|
||||
untrack(() => api?.updateGridOptions({ columnDefs: buildColumnDefs() }))
|
||||
})
|
||||
function buildColumnDefs() {
|
||||
const fkColDefs = foreignKeys && onGoToRow ? foreignKeyColDefs(foreignKeys, onGoToRow) : {}
|
||||
return transformColumnDefs({
|
||||
columnDefs: (dbTableOps.colDefs ?? []).map((c) =>
|
||||
c.field && fkColDefs[c.field] ? { ...c, ...fkColDefs[c.field] } : c
|
||||
),
|
||||
...(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)
|
||||
})
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
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)
|
||||
})
|
||||
}
|
||||
})
|
||||
}),
|
||||
columnDefs: buildColumnDefs(),
|
||||
context: {
|
||||
quicksearch
|
||||
}
|
||||
@@ -132,12 +223,27 @@
|
||||
|
||||
<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..."
|
||||
/>
|
||||
<div class="flex w-full items-center gap-2">
|
||||
<DebouncedInput
|
||||
class="w-full max-w-[300px]"
|
||||
type="text"
|
||||
bind:value={quicksearch}
|
||||
placeholder="Search..."
|
||||
/>
|
||||
{#if rowFilter}
|
||||
<Badge rounded class="pr-0.5 text-xs" data-testid="db-row-filter-chip">
|
||||
<span class="font-mono">{rowFilter.column} = {String(rowFilter.value)}</span>
|
||||
<Button
|
||||
iconOnly
|
||||
unifiedSize="2xs"
|
||||
variant="subtle"
|
||||
startIcon={{ icon: X }}
|
||||
title="Clear filter"
|
||||
onClick={onClearRowFilter}
|
||||
/>
|
||||
</Badge>
|
||||
{/if}
|
||||
</div>
|
||||
{#if dbTableOps.onInsert}
|
||||
<InsertRowDrawerButton
|
||||
columnDefs={dbTableOps.colDefs ?? []}
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
<script lang="ts">
|
||||
import { ArrowRight } from 'lucide-svelte'
|
||||
import Button from './common/button/Button.svelte'
|
||||
|
||||
type Props = {
|
||||
targetTable: string
|
||||
targetColumn: string
|
||||
onGoToRow: () => void
|
||||
}
|
||||
let { targetTable, targetColumn, onGoToRow }: Props = $props()
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="flex flex-col gap-1.5 rounded-md border bg-surface-tertiary p-2 text-xs shadow-md"
|
||||
data-testid="db-fk-tooltip"
|
||||
>
|
||||
<div class="text-secondary">
|
||||
References <span class="font-mono text-primary">{targetTable}.{targetColumn}</span>
|
||||
</div>
|
||||
<Button unifiedSize="xs" variant="default" startIcon={{ icon: ArrowRight }} onClick={onGoToRow}>
|
||||
Go to row
|
||||
</Button>
|
||||
</div>
|
||||
@@ -0,0 +1,48 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { renderDbEqualityFilter, renderDbLiteral } from './utils'
|
||||
|
||||
describe('renderDbLiteral', () => {
|
||||
it('doubles single quotes on every dialect', () => {
|
||||
expect(renderDbLiteral("O'Brien", 'postgresql')).toBe("'O''Brien'")
|
||||
expect(renderDbLiteral("O'Brien", 'mysql')).toBe("'O''Brien'")
|
||||
})
|
||||
|
||||
it('doubles backslashes only where the dialect treats them as escapes', () => {
|
||||
expect(renderDbLiteral('C:\\dir\\', 'postgresql')).toBe("'C:\\dir\\'")
|
||||
expect(renderDbLiteral('C:\\dir\\', 'mysql')).toBe("'C:\\\\dir\\\\'")
|
||||
expect(renderDbLiteral('C:\\dir\\', 'snowflake')).toBe("'C:\\\\dir\\\\'")
|
||||
})
|
||||
|
||||
it('marks SQL Server strings as Unicode constants', () => {
|
||||
expect(renderDbLiteral("Zoë's", 'ms_sql_server')).toBe("N'Zoë''s'")
|
||||
})
|
||||
|
||||
it('renders numbers and booleans without quotes', () => {
|
||||
expect(renderDbLiteral(42, 'postgresql')).toBe('42')
|
||||
expect(renderDbLiteral(true, 'postgresql')).toBe('TRUE')
|
||||
expect(renderDbLiteral(true, 'ms_sql_server')).toBe('1')
|
||||
})
|
||||
|
||||
it('has no literal for values that cannot be compared safely', () => {
|
||||
expect(renderDbLiteral(null, 'postgresql')).toBeUndefined()
|
||||
expect(renderDbLiteral({ a: 1 }, 'postgresql')).toBeUndefined()
|
||||
expect(renderDbLiteral(NaN, 'postgresql')).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('renderDbEqualityFilter', () => {
|
||||
it('quotes the identifier per dialect', () => {
|
||||
expect(renderDbEqualityFilter('user id', 'x', 'postgresql')).toBe(`"user id" = 'x'`)
|
||||
expect(renderDbEqualityFilter('user id', 'x', 'ms_sql_server')).toBe(`[user id] = N'x'`)
|
||||
expect(renderDbEqualityFilter('user id', 'x', 'mysql')).toBe("`user id` = 'x'")
|
||||
expect(renderDbEqualityFilter('user id', null, 'postgresql')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('doubles a delimiter embedded in the identifier', () => {
|
||||
expect(renderDbEqualityFilter('a"b', 1, 'postgresql')).toBe(`"a""b" = 1`)
|
||||
expect(renderDbEqualityFilter('a"b', 1, 'snowflake')).toBe(`"a""b" = 1`)
|
||||
expect(renderDbEqualityFilter('a"b', 1, 'duckdb')).toBe(`"a""b" = 1`)
|
||||
expect(renderDbEqualityFilter('a]b', 1, 'ms_sql_server')).toBe(`[a]]b] = 1`)
|
||||
expect(renderDbEqualityFilter('a`b', 1, 'mysql')).toBe('`a``b` = 1')
|
||||
})
|
||||
})
|
||||
@@ -333,25 +333,58 @@ export function duckdbQuicksearchColumns(columnDefs: ColumnDef[]): string {
|
||||
.join(', ')
|
||||
}
|
||||
|
||||
/** Mirrors the backend's `render_db_quoted_identifier`, including doubling an
|
||||
* embedded delimiter. */
|
||||
export function renderDbQuotedIdentifier(identifier: string, dbType: DbType): string {
|
||||
switch (dbType) {
|
||||
case 'postgresql':
|
||||
return `"${identifier}"` // PostgreSQL uses double quotes for identifiers
|
||||
case 'ms_sql_server':
|
||||
return `[${identifier}]` // MSSQL uses square brackets for identifiers
|
||||
case 'mysql':
|
||||
return `\`${identifier}\`` // MySQL uses backticks
|
||||
case 'snowflake':
|
||||
return `"${identifier}"` // Snowflake uses double quotes for identifiers
|
||||
case 'bigquery':
|
||||
return `\`${identifier}\`` // BigQuery uses backticks
|
||||
case 'duckdb':
|
||||
return `"${identifier}"` // DuckDB uses double quotes for identifiers
|
||||
return `"${identifier.replace(/"/g, '""')}"`
|
||||
case 'ms_sql_server':
|
||||
return `[${identifier.replace(/]/g, ']]')}]`
|
||||
case 'mysql':
|
||||
case 'bigquery':
|
||||
return `\`${identifier.replace(/`/g, '``')}\``
|
||||
default:
|
||||
throw new Error('Unsupported database type: ' + dbType)
|
||||
}
|
||||
}
|
||||
|
||||
/** Renders a cell value as a SQL literal. Returns undefined for values that
|
||||
* have no safe literal form (null, objects, non-finite numbers). */
|
||||
export function renderDbLiteral(value: unknown, dbType: DbType): string | undefined {
|
||||
if (value === null || value === undefined) return undefined
|
||||
if (typeof value === 'number') return Number.isFinite(value) ? String(value) : undefined
|
||||
if (typeof value === 'bigint') return value.toString()
|
||||
if (typeof value === 'boolean') {
|
||||
if (dbType === 'ms_sql_server') return value ? '1' : '0'
|
||||
return value ? 'TRUE' : 'FALSE'
|
||||
}
|
||||
if (typeof value !== 'string') return undefined
|
||||
let escaped = value.replace(/'/g, "''")
|
||||
// MySQL, Snowflake and BigQuery treat a backslash inside a string literal as
|
||||
// an escape character.
|
||||
if (dbType === 'mysql' || dbType === 'snowflake' || dbType === 'bigquery') {
|
||||
escaped = escaped.replace(/\\/g, '\\\\')
|
||||
}
|
||||
// A plain constant is varchar on SQL Server and goes through the database
|
||||
// code page; the N prefix keeps it Unicode against nvarchar columns.
|
||||
return dbType === 'ms_sql_server' ? `N'${escaped}'` : `'${escaped}'`
|
||||
}
|
||||
|
||||
/** `"column" = <literal>` predicate, or undefined when the value can't be
|
||||
* rendered as a literal. */
|
||||
export function renderDbEqualityFilter(
|
||||
column: string,
|
||||
value: unknown,
|
||||
dbType: DbType
|
||||
): string | undefined {
|
||||
const literal = renderDbLiteral(value, dbType)
|
||||
if (literal === undefined) return undefined
|
||||
return `${renderDbQuotedIdentifier(column, dbType)} = ${literal}`
|
||||
}
|
||||
|
||||
export function getLanguageByResourceType(name: string): ScriptLang {
|
||||
const language = {
|
||||
postgresql: 'postgresql',
|
||||
|
||||
@@ -14,6 +14,7 @@ import { WorkspaceService } from '$lib/gen'
|
||||
import { pendingMigrations } from './workspaceSettings/datatableMigrationUtils'
|
||||
import {
|
||||
buildTableEditorValues,
|
||||
type TableEditorForeignKey,
|
||||
type TableEditorValues
|
||||
} from './apps/components/display/dbtable/tableEditor'
|
||||
import { type AlterTableValues } from './apps/components/display/dbtable/queries/alterTable'
|
||||
@@ -250,6 +251,10 @@ export type IDbSchemaOps = {
|
||||
schema?: string
|
||||
colDefs: TableMetadata
|
||||
}) => Promise<TableEditorValues>
|
||||
onFetchForeignKeys: (params: {
|
||||
table: string
|
||||
schema?: string
|
||||
}) => Promise<TableEditorForeignKey[]>
|
||||
}
|
||||
|
||||
/** Thrown by a schema op when the user declines the out-of-order run warning.
|
||||
@@ -396,6 +401,48 @@ export function dbSchemaOpsWithPreviewScripts({
|
||||
}
|
||||
}
|
||||
|
||||
/** Resolves to [] when the database has no foreign key introspection
|
||||
* (BigQuery) or the query fails: callers treat foreign keys as optional. */
|
||||
async function fetchForeignKeys({
|
||||
table,
|
||||
schema
|
||||
}: {
|
||||
table: string
|
||||
schema?: string
|
||||
}): Promise<TableEditorForeignKey[]> {
|
||||
if (dbType === 'bigquery') return []
|
||||
try {
|
||||
const fkContent = makeMarker('FOREIGN_KEYS', { table, schema })
|
||||
const fkResult = await runScriptAndPollResult({
|
||||
workspace,
|
||||
requestBody: { args: dbArg, content: fkContent, language, tag }
|
||||
})
|
||||
|
||||
let rawForeignKeys: RawForeignKey[]
|
||||
if (dbType === 'snowflake') {
|
||||
rawForeignKeys = transformSnowflakeForeignKeys(fkResult as any[])
|
||||
} else {
|
||||
rawForeignKeys = fkResult as RawForeignKey[]
|
||||
if (rawForeignKeys && Array.isArray(rawForeignKeys)) {
|
||||
rawForeignKeys = rawForeignKeys.map((fk) => {
|
||||
const lowerFk: any = {}
|
||||
Object.keys(fk).forEach((key) => {
|
||||
lowerFk[key.toLowerCase()] = fk[key]
|
||||
})
|
||||
return lowerFk
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (rawForeignKeys && Array.isArray(rawForeignKeys)) {
|
||||
return transformForeignKeys(rawForeignKeys)
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('Failed to fetch foreign keys:', e)
|
||||
}
|
||||
return []
|
||||
}
|
||||
|
||||
return {
|
||||
onDelete: async ({ tableKey, schema }) => {
|
||||
const content = makeMarker('DROP_TABLE', { table: tableKey, schema })
|
||||
@@ -454,44 +501,11 @@ export function dbSchemaOpsWithPreviewScripts({
|
||||
const downContent = makeMarker('CREATE_SCHEMA', { schema })
|
||||
await applyDdl(migrationName('drop_schema', schema), content, downContent)
|
||||
},
|
||||
onFetchForeignKeys: fetchForeignKeys,
|
||||
onFetchTableEditorDefinition: async ({ table, schema, colDefs }) => {
|
||||
let foreignKeys: import('./apps/components/display/dbtable/tableEditor').TableEditorForeignKey[] =
|
||||
[]
|
||||
const foreignKeys = await fetchForeignKeys({ table, schema })
|
||||
let pk_constraint_name: string | undefined
|
||||
|
||||
// Fetch foreign keys (not supported for BigQuery)
|
||||
if (dbType !== 'bigquery') {
|
||||
try {
|
||||
const fkContent = makeMarker('FOREIGN_KEYS', { table, schema })
|
||||
const fkResult = await runScriptAndPollResult({
|
||||
workspace,
|
||||
requestBody: { args: dbArg, content: fkContent, language, tag }
|
||||
})
|
||||
|
||||
let rawForeignKeys: RawForeignKey[]
|
||||
if (dbType === 'snowflake') {
|
||||
rawForeignKeys = transformSnowflakeForeignKeys(fkResult as any[])
|
||||
} else {
|
||||
rawForeignKeys = fkResult as RawForeignKey[]
|
||||
if (rawForeignKeys && Array.isArray(rawForeignKeys)) {
|
||||
rawForeignKeys = rawForeignKeys.map((fk) => {
|
||||
const lowerFk: any = {}
|
||||
Object.keys(fk).forEach((key) => {
|
||||
lowerFk[key.toLowerCase()] = fk[key]
|
||||
})
|
||||
return lowerFk
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (rawForeignKeys && Array.isArray(rawForeignKeys)) {
|
||||
foreignKeys = transformForeignKeys(rawForeignKeys)
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('Failed to fetch foreign keys:', e)
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch primary key constraint name (not supported for BigQuery/MySQL)
|
||||
if (dbType !== 'bigquery' && dbType !== 'mysql') {
|
||||
try {
|
||||
|
||||
Reference in New Issue
Block a user