feat: data table schemas (#7353)

* data tables settings ui

* install runed

* zod 4 fixes

* use new toJSONSchema

* Migrate ducklake catalogs to more generic custom instance databases

* fix compilation

* Safety conversion for old duckdb ffi

* data tables settings

* ts client basis

* inline run works

* datatables work

* Revert "datatables work"

This reverts commit 6e1588d59e.

* datatables work (without leaking pg credentials)

* println

* separate sqlUtils.ts

* nit

* Separate custom instance db Select and Wizard components

* nit

* nit wording

* add tags to custom instance dbs

* error when trying to use ducklake as datatable or opposite

* show status in dropdown

* data table instance setup works

* sqk function for ducklake

* factorize logic

* fix temp reactivity

* Data table assetexplore

* Migrate S3 permissions to modal

* Revert "Migrate S3 permissions to modal"

This reverts commit 0631d03cb0.

* nit query -> fetch

* Custom instance setup new look

* run_language_executor separate fn

* run_inline param

* nit wording

* Better typed client

* Data tables display as assets in frontend

* asset db icon

* nit

* cleaner errors

* nit

* Fix sed calls in mac

* run_inline_script_preview in python client

* basic python datatable client

* datatable and datalake parser in python

* ducklake client python

* nit fix

* Fix migration producing NULL instead of {} when no custom databases

* merge conflict fail

* python ducklake client arg fix

* parse or infer sql types in ts client

* ts asset parser, detect datatable & ducklake R/W

* fix sql repl for other read ops than select

* export type SqlTemplateFunction

* rename list_custom_instance_pg_databases

* typecheck datatable and ducklake name in Typescript

* Fix typecheck datatable and ducklake in TS

* declare module overriding instead of extending

* infer_sql_type in python client

* SqlQuery object in python

* fix merge conflicts

* update const_format

* CI fix

* factor out to var_identifiers

* sqlx prepare

* unnecessary security (admin is required)

* clearer comment

* ee repo ref

* nit snake case

* claude step 1: detect var declarations

* move detect_sql_access_type to common mod

* claude step 2: detect when saved vars are queried

* Revert "claude step 2: detect when saved vars are queried"

This reverts commit 1e1f930568.

* Revert "claude step 1: detect var declarations"

This reverts commit f866f4819d.

* remove ducklake/datatable and default

* detect data table assigns in var_identifiers

* Python parser successfully infers R/W/RW from ducklake / datatable

* still register ducklake/datatable if not used as unknown R/W

* Go to settings button in Assets Dropdown on not found

* nit

* sqlx prepare fail

* manual fix, somehow sqlx prepare won't do it

* fix frontend ci

* ee repo ref

* ducklake_user doesnt exist in unit tests

* nit fix

* ui nit

* nit

* nit missing clone

* fork ducklakes and datatables

* fix surface hover bug

* stupid mistake

* better deeply reactive mutable derived

* Ducklake picker

* Editor bar data tables

* DuckDB supports datatables

* datatable in duckdb asset parser

* duckdb asset parser var_identifiers

* Revert "duckdb asset parser var_identifiers"

This reverts commit 88068b1a77.

* sqlx prepare

* Box pin in test_workflow_as_code to fix stack overflow

* stash

* sql asset parser parses most s3 literals

* nit

* Detect attach + handle returning RW

* detect assets used with dot notation

* detect implicit access with USE dl; syntax

* Add assets as unknown if var was never used

* Support default ducklake/datatable main in parser

* ignore asset parsing errors in frontend (avoid flow layout shift)

* super weird duplication (merge conflict ?)

* nits

* fix duckdb parser detecting too much as asset when RW ctx is unknown

* fix transparent assets btn

* missing arg

* nit styling

* asset parser specific table parsing

* fix resource specific table parsing

* More concise asset display in flows + better icons

* fix assets page filtering out resources with added table

* Fix frontend to support specific table assets

* Open DB Manager to specific table

* Specific table parser in Python and TS + unit tests

* Fix UPDATE setting access to None

* fix flow edge rendering on top of output picker

* python parser fix var override bug

* add ts test

* fix compilation

* sqlx prepare

* update parsers version

* fix missing schema key onDelete

* Grant permission to create schemas in custom instance databases

* Update pg query to return empty schemas

* Create schema

* Select nits

* support schemas in sql parser

* ts parser handle schema with sql parser result

* detect .schema() syntax

* detect schema syntax in python

* support .schema() in ts and py SDK

* open db manager to specific schema

* support reassignment in ts parser

* nit better unitest

* : syntax in ts

* datatable:schema syntax in python

* fix client py

* nit select dropdown darkmode

* object | null fetchOne

* ts client nits

* parse_sql_client_name fn

* getImportWmillTsStatement refactor in EditorBar

* text to json() in python client

* update parser versions

* pkg lock
This commit is contained in:
Diego Imbert
2025-12-16 21:16:42 +00:00
committed by GitHub
parent f98e315a56
commit 75fdc2cdc9
24 changed files with 665 additions and 267 deletions
+38 -13
View File
@@ -1,6 +1,6 @@
<script lang="ts">
import { type DBSchema } from '$lib/stores'
import { MoreVertical, Plus, Table2, Trash2Icon } from 'lucide-svelte'
import { ChevronDownIcon, MoreVertical, Plus, Table2, Trash2Icon } from 'lucide-svelte'
import { Pane, Splitpanes } from 'svelte-splitpanes'
import { ClearableInput, Drawer, DrawerContent } from './common'
import { sendUserToast } from '$lib/toast'
@@ -13,6 +13,8 @@
import DbTableEditor from './DBTableEditor.svelte'
import type { DbType } from './dbTypes'
import Portal from './Portal.svelte'
import Select from './select/Select.svelte'
import { safeSelectItems } from './select/utils.svelte'
type Props = {
dbType: DbType
@@ -22,6 +24,7 @@
dbTableOpsFactory: (params: { colDefs: ColumnDef[]; tableKey: string }) => IDbTableOps
dbSchemaOps: IDbSchemaOps
refresh?: () => void
initialSchemaKey?: string
initialTableKey?: string
}
let {
@@ -32,6 +35,7 @@
getColDefs,
dbSupportsSchemas,
refresh,
initialSchemaKey,
initialTableKey
}: Props = $props()
@@ -45,7 +49,8 @@
$effect(() => {
if (!selected.schemaKey && schemaKeys.length) {
let schemaKey =
'public' in dbSchema.schema ? 'public' : 'dbo' in dbSchema.schema ? 'dbo' : schemaKeys[0]
initialSchemaKey ??
('public' in dbSchema.schema ? 'public' : 'dbo' in dbSchema.schema ? 'dbo' : schemaKeys[0])
let tableKey =
initialTableKey && dbSchema.schema?.[schemaKey]?.[initialTableKey]
? initialTableKey
@@ -90,20 +95,40 @@
<Splitpanes>
<Pane size={24} class="relative flex flex-col">
<div class="mx-3 mt-3">
<div class="mx-3 mt-3 flex flex-col gap-2">
{#if dbSupportsSchemas}
<select
value={selected.schemaKey}
onchange={(e) => {
selected = { schemaKey: e.currentTarget.value }
<Select
bind:value={selected.schemaKey}
items={safeSelectItems(schemaKeys)}
transformInputSelectedText={(s) => `Schema: ${s}`}
RightIcon={ChevronDownIcon}
placeholder="Search or create schema..."
showPlaceholderOnOpen
onCreateItem={(schema) => {
schema = schema
.trim()
.toLowerCase()
.replace(/[^a-zA-Z0-9_]/g, '')
askingForConfirmation = {
confirmationText: `Create ${schema}`,
type: 'reload',
title: `This will run 'CREATE SCHEMA ${schema}' on your database. Are you sure ?`,
open: true,
onConfirm: async () => {
askingForConfirmation && (askingForConfirmation.loading = true)
try {
await dbSchemaOps.onCreateSchema({ schema })
refresh?.()
selected.schemaKey = schema
} finally {
askingForConfirmation = undefined
}
}
}
}}
>
{#each schemaKeys as schemaKey}
<option value={schemaKey}>{schemaKey}</option>
{/each}
</select>
/>
{/if}
<ClearableInput wrapperClass="mt-3" bind:value={search} placeholder="Search table..." />
<ClearableInput bind:value={search} placeholder="Search table..." />
</div>
<div class="overflow-x-clip overflow-y-auto relative mt-3 border-y flex-1">
{#each filteredTableKeys as tableKey}
@@ -195,6 +195,7 @@
workspace: $workspaceStore
})}
initialTableKey={input.specificTable}
initialSchemaKey={input.type == 'database' ? input.specificSchema : undefined}
{dbType}
{refresh}
/>
+19 -32
View File
@@ -1,5 +1,16 @@
<script module lang="ts">
export const EDITOR_BAR_WIDTH_THRESHOLD = 1420
function getImportWmillTsStatement(lang: string | undefined) {
if (lang === 'deno') {
return `import * as wmill from "npm:windmill-client@1"\n`
} else if (lang === 'bun' || lang === 'bunnative') {
return `import * as wmill from "windmill-client"\n`
} else if (lang === 'nativets') {
return `import * as wmill from "./windmill.ts"\n`
}
return ''
}
</script>
<script lang="ts">
@@ -516,15 +527,9 @@
bind:this={variablePicker}
pickCallback={(path, name) => {
if (!editor) return
if (lang == 'deno') {
if (['javascript', 'typescript'].includes(scriptLangToEditorLang(lang))) {
if (!editor.getCode().includes('import * as wmill from')) {
editor.insertAtBeginning(`import * as wmill from "npm:windmill-client@1"\n`)
}
editor.insertAtCursor(`(await wmill.getVariable('${path}'))`)
} else if (lang === 'bun' || lang === 'bunnative') {
const code = editor.getCode()
if (!code.includes(`import * as wmill from`)) {
editor.insertAtBeginning(`import * as wmill from "windmill-client"\n`)
editor.insertAtBeginning(getImportWmillTsStatement(lang))
}
editor.insertAtCursor(`(await wmill.getVariable('${path}'))`)
} else if (lang == 'python3') {
@@ -546,12 +551,6 @@
editor.insertAtCursor(
`\nInvoke-RestMethod -Headers $Headers -Uri "$Env:BASE_INTERNAL_URL/api/w/$Env:WM_WORKSPACE/variables/get_value/${path}"`
)
} else if (lang == 'nativets') {
const code = editor.getCode()
if (!code.includes(`import * as wmill from`)) {
editor.insertAtBeginning(`import * as wmill from "./windmill.ts"\n`)
}
editor.insertAtCursor(`(await wmill.getVariable('${path}'))`)
} else if (lang == 'php') {
editor.insertAtCursor(`$ch = curl_init(getenv('BASE_INTERNAL_URL') . '/api/w/' . getenv('WM_WORKSPACE') . '/variables/get_value/${path}');
curl_setopt($ch, CURLOPT_HTTPHEADER, array('Authorization: Bearer ' . getenv('WM_TOKEN')));
@@ -605,15 +604,9 @@ string ${windmillPathToCamelCaseName(path)} = await client.GetStringAsync(uri);
bind:this={resourcePicker}
pickCallback={(path, _, resType) => {
if (!editor) return
if (lang == 'deno') {
if (['javascript', 'typescript'].includes(scriptLangToEditorLang(lang))) {
if (!editor.getCode().includes('import * as wmill from')) {
editor.insertAtBeginning(`import * as wmill from "npm:windmill-client@1"\n`)
}
editor.insertAtCursor(`(await wmill.getResource('${path}'))`)
} else if (lang === 'bun' || lang === 'bunnative') {
const code = editor.getCode()
if (!code.includes(`import * as wmill from`)) {
editor.insertAtBeginning(`import * as wmill from "windmill-client"\n`)
editor.insertAtBeginning(getImportWmillTsStatement(lang))
}
editor.insertAtCursor(`(await wmill.getResource('${path}'))`)
} else if (lang == 'python3') {
@@ -635,12 +628,6 @@ string ${windmillPathToCamelCaseName(path)} = await client.GetStringAsync(uri);
editor.insertAtCursor(
`\nInvoke-RestMethod -Headers $Headers -Uri "$Env:BASE_INTERNAL_URL/api/w/$Env:WM_WORKSPACE/resources/get_value_interpolated/${path}"`
)
} else if (lang == 'nativets') {
const code = editor.getCode()
if (!code.includes(`import * as wmill from`)) {
editor.insertAtBeginning(`import * as wmill from "./windmill.ts"\n`)
}
editor.insertAtCursor(`(await wmill.getResource('${path}'))`)
} else if (lang == 'php') {
editor.insertAtCursor(`$ch = curl_init(getenv('BASE_INTERNAL_URL') . '/api/w/' . getenv('WM_WORKSPACE') . '/resources/get_value_interpolated/${path}');
curl_setopt($ch, CURLOPT_HTTPHEADER, array('Authorization: Bearer ' . getenv('WM_TOKEN')));
@@ -735,7 +722,7 @@ JsonNode ${windmillPathToCamelCaseName(path)} = JsonNode.Parse(await client.GetS
editor?.insertAtCursor(`dl = wmill.ducklake(${name == 'main' ? '' : `'${name}'`})\n`)
} else if (['javascript', 'typescript'].includes(scriptLangToEditorLang(lang))) {
if (!editor?.getCode().includes('import * as wmill from')) {
editor?.insertAtBeginning(`import * as wmill from "npm:windmill-client@1"\n`)
editor?.insertAtBeginning(getImportWmillTsStatement(lang))
}
editor?.insertAtCursor(`let sql = wmill.ducklake(${name == 'main' ? '' : `'${name}'`})\n`)
}
@@ -769,7 +756,7 @@ JsonNode ${windmillPathToCamelCaseName(path)} = JsonNode.Parse(await client.GetS
pickCallback={async (_, name) => {
if (lang === 'duckdb') {
const connStr = name == 'main' ? 'datatable' : `datatable://${name}`
editor?.insertAtCursor(`ATTACH '${connStr}' AS dt;\n`)
editor?.insertAtCursor(`ATTACH '${connStr}' AS dt; USE dt;\n`)
} else if (lang === 'python3') {
if (!editor?.getCode().includes('import wmill')) {
editor?.insertAtBeginning('import wmill\n')
@@ -777,7 +764,7 @@ JsonNode ${windmillPathToCamelCaseName(path)} = JsonNode.Parse(await client.GetS
editor?.insertAtCursor(`db = wmill.datatable(${name == 'main' ? '' : `'${name}'`})\n`)
} else if (['javascript', 'typescript'].includes(scriptLangToEditorLang(lang))) {
if (!editor?.getCode().includes('import * as wmill from')) {
editor?.insertAtBeginning(`import * as wmill from "npm:windmill-client@1"\n`)
editor?.insertAtBeginning(getImportWmillTsStatement(lang))
}
editor?.insertAtCursor(`let sql = wmill.datatable(${name == 'main' ? '' : `'${name}'`})\n`)
}
@@ -844,7 +831,7 @@ JsonNode ${windmillPathToCamelCaseName(path)} = JsonNode.Parse(await client.GetS
editor?.insertAtCursor(`wmill.load_s3_file(${s})`)
} else if (['javascript', 'typescript'].includes(scriptLangToEditorLang(lang))) {
if (!editor?.getCode().includes('import * as wmill from')) {
editor?.insertAtBeginning(`import * as wmill from "npm:windmill-client@1"\n`)
editor?.insertAtBeginning(getImportWmillTsStatement(lang))
}
editor?.insertAtCursor(`wmill.loadS3File(${s})`)
}
@@ -74,12 +74,17 @@
dbManagerDrawer?.openDrawer({ type: 'ducklake', ducklake, specificTable })
} else if (asset.kind === 'datatable') {
let datatable = asset.path.split('/')[0]
let specificTable = asset.path.split('/')[1] as string | undefined
let specificTableSplit = asset.path.split('/')[1]?.split('.') as string[] | undefined
let [specificSchema, specificTable] =
specificTableSplit?.length === 2
? [specificTableSplit[0], specificTableSplit[1]]
: [undefined, specificTableSplit?.[0]]
dbManagerDrawer?.openDrawer({
type: 'database',
resourceType: 'postgresql',
resourcePath: `datatable://${datatable}`,
specificTable
specificTable,
specificSchema
})
}
onClick?.()
@@ -206,7 +206,6 @@
inputClass={selectInputClass}
placeholder={placeholder ?? `${resourceType ?? 'any'} resource`}
itemLabelWrapperClasses="flex-1"
itemButtonWrapperClasses="flex items-center"
>
{#snippet endSnippet({ item, close })}
<Button
@@ -238,7 +238,8 @@
dbPath.split('$res:')[1],
$workspaceStore,
dbSchemas,
() => {}
() => {},
{ useLegacyScripts: true }
)
}
@@ -2,7 +2,13 @@ import { JobService, ResourceService } from '$lib/gen'
import { runScriptAndPollResult } from '$lib/components/jobs/utils'
import type { DbInput } from '$lib/components/dbTypes'
import { getLanguageByResourceType, resourceTypeToLang, scripts, type TableMetadata } from './utils'
import {
getLanguageByResourceType,
resourceTypeToLang,
legacyScripts,
scriptsV2,
type TableMetadata
} from './utils'
import { type Preview } from '$lib/gen'
import type { DBSchema, DBSchemas, GraphqlSchema, SQLSchema } from '$lib/stores'
@@ -289,8 +295,13 @@ export async function getDbSchemas(
resourcePath: string,
workspace: string | undefined,
dbSchemas: DBSchemas,
errorCallback: (message: string) => void
errorCallback: (message: string) => void,
options: {
useLegacyScripts?: boolean // To avoid breaking app policies
} = {}
): Promise<void> {
let scripts = options.useLegacyScripts ? legacyScripts : scriptsV2
if (!scripts[resourceType]) return
return new Promise(async (resolve, reject) => {
@@ -60,7 +60,7 @@ export function resourceTypeToLang(rt: string) {
}
}
const scripts: Record<
const legacyScripts: Record<
string,
{
code: string
@@ -76,7 +76,7 @@ const scripts: Record<
const table_schema = a.table_schema
delete a.table_schema
acc[table_schema] = acc[table_schema] || []
acc[table_schema].push(a)
if (a.table_name || a.column_name) acc[table_schema].push(a)
return acc
}, {})
@@ -266,7 +266,19 @@ return schema
}
}
export { scripts }
// We cannot modify the original legacyScripts because they are used to calculate app policies in AppDbExplorer
// TODO: Refactor the app policy system to avoid this
const scriptsV2: typeof legacyScripts = {
...legacyScripts,
postgresql: {
...legacyScripts.postgresql,
code: `
SELECT table_name, column_name, udt_name, column_default, is_nullable, nsp.nspname AS table_schema FROM information_schema.columns
RIGHT JOIN pg_namespace nsp ON table_schema = nsp.nspname WHERE nsp.nspname NOT IN ('information_schema', 'pg_toast', 'pg_catalog')`
}
}
export { legacyScripts, scriptsV2 }
export function formatSchema(dbSchema: {
lang: SQLSchema['lang']
+11 -1
View File
@@ -115,6 +115,7 @@ export type IDbSchemaOps = {
onDelete: (params: { tableKey: string; schema?: string }) => Promise<void>
onCreate: (params: { values: CreateTableValues; schema?: string }) => Promise<void>
previewCreateSql: (params: { values: CreateTableValues; schema?: string }) => string
onCreateSchema: (params: { schema: string }) => Promise<void>
}
export function dbSchemaOpsWithPreviewScripts({
@@ -144,7 +145,16 @@ export function dbSchemaOpsWithPreviewScripts({
requestBody: { args: dbArg, content: query, language }
})
},
previewCreateSql: ({ values, schema }) => makeCreateTableQuery(values, dbType, schema)
previewCreateSql: ({ values, schema }) => makeCreateTableQuery(values, dbType, schema),
onCreateSchema: async ({ schema }) => {
let createSchemaQuery = `CREATE SCHEMA ${schema};`
if (input.type === 'ducklake')
createSchemaQuery = wrapDucklakeQuery(createSchemaQuery, input.ducklake)
await runScriptAndPollResult({
workspace,
requestBody: { args: { ...dbArg }, language, content: createSchemaQuery }
})
}
}
}
+1
View File
@@ -3,6 +3,7 @@ export type DbInput =
type: 'database'
resourceType: DbType
resourcePath: string
specificSchema?: string
specificTable?: string
}
| {
@@ -42,6 +42,8 @@
itemLabelWrapperClasses,
itemButtonWrapperClasses,
size = 'md',
showPlaceholderOnOpen = false,
transformInputSelectedText,
groupBy,
sortBy,
onFocus,
@@ -74,6 +76,8 @@
itemLabelWrapperClasses?: string
itemButtonWrapperClasses?: string
size?: 'sm' | 'md' | 'lg'
showPlaceholderOnOpen?: boolean
transformInputSelectedText?: (text: string) => string
groupBy?: (item: Item) => string
sortBy?: (a: Item, b: Item) => number
onFocus?: () => void
@@ -119,6 +123,11 @@
if (onClear) onClear()
else value = undefined
}
let inputText = $derived.by(() => {
let text = valueEntry?.label ?? getLabel({ value }) ?? ''
return transformInputSelectedText?.(text) ?? text
})
</script>
<div
@@ -151,15 +160,12 @@
{autofocus}
{disabled}
type="text"
bind:value={
() => (open ? filterText : (valueEntry?.label ?? getLabel({ value }) ?? '')),
(v) => {
if (open) {
filterText = v
}
}
}
placeholder={loading && !value ? 'Loading...' : value ? valueEntry?.label : placeholder}
bind:value={() => (open ? filterText : inputText), (v) => open && (filterText = v)}
placeholder={loading && !value
? 'Loading...'
: value && !showPlaceholderOnOpen
? inputText
: placeholder}
style={containerStyle}
class={twMerge(
inputBaseClass,
@@ -4,6 +4,7 @@
import { untrack, type Snippet } from 'svelte'
import type { ProcessedItem } from './utils.svelte'
import { twMerge } from 'tailwind-merge'
import { PlusIcon } from 'lucide-svelte'
let {
processedItems: _processedItems,
@@ -169,7 +170,7 @@
)}
style="height: {uiState.visible ? dropdownPos.height : 0}px;"
>
<div bind:this={listEl} class="flex flex-col max-h-64 rounded-md bg-surface-tertiary">
<div bind:this={listEl} class="flex flex-col max-h-64 rounded-md bg-surface-input">
{@render header?.()}
{#if processedItems?.length === 0}
<div class="py-8 px-4 text-center text-primary text-xs">{noItemsMsg}</div>
@@ -190,11 +191,10 @@
<button
class={twMerge(
'py-2 px-4 w-full font-normal text-left text-primary text-xs',
itemIndex === keyArrowPos
? 'bg-surface-secondary'
: item.value === value
? 'bg-surface-secondary'
: 'hover:bg-surface-hover',
itemIndex === keyArrowPos || item.value === value
? 'bg-surface-secondary dark:bg-surface-tertiary'
: 'hover:bg-surface-hover',
endSnippet || item.__is_create ? 'flex items-center justify-between gap-2' : '',
itemButtonWrapperClasses,
item.disabled ? 'cursor-not-allowed text-disabled' : ''
)}
@@ -207,7 +207,11 @@
<span class={itemLabelWrapperClasses}>
{item.label || '\xa0'}
</span>
{@render endSnippet?.({ item, close: () => (open = false) })}
{#if item.__is_create}
<PlusIcon class="inline ml-auto" size={16} />
{:else}
{@render endSnippet?.({ item, close: () => (open = false) })}
{/if}
{#if item.subtitle}
<div class="text-2xs text-secondary">{item.subtitle}</div>
{/if}
@@ -49,10 +49,10 @@
class="flex-1"
bind:value
onCreateItem={(i) => (value = i)}
placeholder="PostgreSQL database name"
placeholder="Search or create..."
showPlaceholderOnOpen
items={onlySelectedTags}
disabled={!$isCustomInstanceDbEnabled}
itemButtonWrapperClasses="flex items-center justify-between gap-2"
>
{#snippet endSnippet({ item })}
{#if !customInstanceDbs.current?.[item.value]?.success}
@@ -135,6 +135,7 @@
`GRANT CONNECT ON DATABASE "${dbname}" TO custom_instance_user;\n` +
'GRANT USAGE ON SCHEMA public TO custom_instance_user;\n' +
'GRANT CREATE ON SCHEMA public TO custom_instance_user;\n' +
`GRANT CREATE ON DATABASE "${dbname}" TO custom_instance_user;\n` +
'ALTER DEFAULT PRIVILEGES IN SCHEMA public \n' +
' GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES\n TO custom_instance_user;'
}