mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-08-23 08:00:45 +00:00
3d4f4c6c38
* export_datatable_schema * Propose to fork the datatable on ws fork * dump datatable * Dockerfile * Fix import_datatable_dump * datatable schema fork works! * Option to copy both schema and data * Datatable fork behavior * nit ui * use psql instead * remove fork_datatable route * feat: add fork_pg_database and export_pg_schema routes with DB Manager UI Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * style: pluralize "schema" to "schemas" in DB Manager export/import UI Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * feat: add import mode select (schema only vs schema + data) to DB Manager import Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Select schema or schema+data when important database * fix: prepend $res: prefix to resource paths in DB Manager import/export Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * style: dynamic import button label based on selected mode Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * nits * feat: add warning alert when schema+data import mode is selected Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * nit hide on cloud hosted * refactor: remove fork_behavior from datatable settings Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * refactor: split CreateWorkspace into layout wrapper and CreateWorkspaceInner Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * feat: instantiate CreateWorkspaceInner in globalForkModal Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * nit icons * Data table fork UI * feat: pass per-datatable fork behaviors from UI to backend during workspace fork Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Fix fork overwriting all datatables * UI nits * custom instance db refactor * custom instance db wizard btn for all in dropdown * nit * Delete custom instance database button * Disable forking for resource datatables * Big import buttons when db empty * Revert "Disable forking for resource datatables" This reverts commit9561cc8fd4. * feat: add non_diffable flag to resource table Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * feat: add resource-type datatable fork with CREATE DATABASE Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * feat: tag forked datatables with nonDiffable and forkedFrom Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * feat: diff datatable and ducklake settings individually on workspace merge Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * feat: skip non_diffable resources and datatables in workspace diff Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * feat: default datatable fork behavior to keep_original Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix: make grant permissions non-fatal in instance datatable fork Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix: make datatable and ducklake diffs visible in workspace comparison Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * refactor: remove datatable fork logic from workspace fork route Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix: correct ahead/behind logic for datatable and ducklake diffs Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * Revert "fix: correct ahead/behind logic for datatable and ducklake diffs" This reverts commit6b50884dc6. * revert: remove datatable and ducklake settings diffing logic Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * feat: add datatable clone UI with step-by-step confirmation modal Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * refactor: extract datatable fork UI into ForkDatatableSection component Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * nit * fix: run datatable cloning before workspace fork creation Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * nit disable fork admins * nit fix switching workspace prematurely * fix: use source workspace for forkPgDatabase calls during fork Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * feat: update forked workspace datatable settings after fork creation Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * feat: add forked_from field to DataTable and set it for instance forks Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * nit onFinish * fix: add forked_from to DataTableSettings OpenAPI schema Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * feat: track datatable table DDL changes in workspace_diff Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * Revert "feat: track datatable table DDL changes in workspace_diff" This reverts commit7526dd68b9. * feat: add get_datatable_full_schema endpoint and snapshot schema on fork Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix duplicate migration key * fix: set forked_from on datatable config for both instance and resource types Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * nits * feat: drop forked databases on workspace deletion with confirmation UI Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * refactor: extract drop_forked_datatable_databases from delete_workspace Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix: cast pg char columns to text in FK schema query Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix: show dbname instead of resource type in fork deletion modal Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * ui nit * refactor: extract drop_custom_instance_database into windmill-common Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * feat: add datatable schema diff section to merge UI Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * UI * feat: add review drawer with YAML diff and SQL migration runner Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * refactor: use Monaco DiffEditor for YAML diff in review drawer Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * nit * Revert "refactor: use Monaco DiffEditor for YAML diff in review drawer" This reverts commita86008ba4c. * Revert "feat: add review drawer with YAML diff and SQL migration runner" This reverts commit0a0deb5ddb. * feat: add review drawer with DiffEditor and SQL migration runner Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * ui nits * fix: show diff between forked_from schema and changed side Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix: re-fetch target live schema after migration for correct baseline Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * revert * nit auto next * feat: add confirmation modal before deploying migration to parent Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix: handle missing columns/foreignKeys in schema conversion Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * nits * refactor: use temp file on disk for pg_dump instead of in-memory string Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * Don't replace postgres dbname * fix: add validation to drop_custom_instance_database and use source db for CREATE/DROP Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * refactor: type DataTable.forked_from as DataTableForkedFrom struct Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * refactor: simplify fork_pg_database to take source + target_dbname Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * dead code * feat: enforce schema_and_data admin-only and extract create_custom_instance_database Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * refactor: rename fork_pg_database to import_pg_database with source/target/override params Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * nit * refactor: remove original_dbname/original_resource from forked_from, resolve from parent Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * nit * fix: resolve forked dbname from fork workspace when dropping resource databases Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * nits * fix: always clean up global_settings even if database doesn't exist Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix: check datatable resource_type from config instead of URL prefix Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix: wrap PG default value expressions in braces to prevent CAST quoting Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * Revert "fix: wrap PG default value expressions in braces to prevent CAST quoting" This reverts commit77f5a2c4e8. * refactor: reuse columnDefToTableEditorValuesColumn for default value handling Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix: store raw API schema in forked_from to avoid double transformation Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * Revert "fix: store raw API schema in forked_from to avoid double transformation" This reverts commite326197a20. * Revert "refactor: reuse columnDefToTableEditorValuesColumn for default value handling" This reverts commitbd8f071d9f. * fix: validate dbname with strict regex to prevent SQL injection Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix default value * always validate dbname * refactor: move get_datatable_full_schema structs and logic to query_builders.rs Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * refactor: split import_pg_database into create_pg_database + import_pg_database Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * refactor: extract drop_forked_datatable_databases into its own route Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix: require admin when using $res: resource paths in import_pg_database Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix: use UserDB for $res: resource access and restrict dbname creation - resolve_pg_source_checked uses UserDB (row-level security) for $res: paths - transform_json_unchecked is now pub(crate) to prevent misuse - Non-superadmins can only create databases with wm_fork_ prefix - datatable:// remains accessible to everyone Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix: refuse to drop forked databases unless name starts with wm_fork_ Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * refactor: remove resolve_pg_source, use resolve_pg_source_checked everywhere Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * Fix permissions * sqlx prepare * compilation nits * sqlx prepare * sqlx prepare * wrong route syntax * fix: allow workspace owner to edit datatable config for fork setup Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * Revert "fix: allow workspace owner to edit datatable config for fork setup" This reverts commitab683e637b. * refactor: move datatable fork setup into create_workspace_fork backend Instead of updating datatable settings from the frontend after fork creation (which required admin/owner access), pass forked_datatables info to create_workspace_fork and handle it atomically in the same transaction. Removes applyPostForkDatatableUpdates from frontend. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix: snapshot schema in backend during fork instead of frontend The schema snapshot is now taken by the backend in apply_forked_datatable via snapshot_datatable_schema, which connects to the parent workspace's datatable and runs pg_get_full_schema. This removes the need for the frontend to call getDatatableFullSchema and pass the schema through. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix: use get_resource_value_interpolated_internal for $res: to resolve $var: references Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * nit * sqlx prepare * fix: add permission check to drop_forked_datatable_databases, validate dbnames, restrict temp file perms - drop_forked_datatable_databases: same permission as delete_workspace (fork owner or super admin) - validate_dbname on target_dbname_override and ForkedDatatableInfo.new_dbname - Enforce wm_fork_ prefix on forked datatable new_dbname - DumpFile: set /tmp/windmill/ to 0700 and create files with 0600 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * nit CLI * Rename to ws_specific * sqlx prepare * nit always validate dbname * fix: include foreign keys in CREATE TABLE migration for added tables Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix: detect nextval defaults and use SERIAL/BIGSERIAL types in CREATE TABLE Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * Update frontend/src/lib/components/DBManagerDrawer.svelte Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com> * Update backend/windmill-common/src/lib.rs Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com> * Update backend/windmill-common/src/lib.rs Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com> * fix: sort foreign keys by constraint name for deterministic schema output Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * sqlx prepare * rename migration to update timestamp --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com> Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com>
748 lines
25 KiB
Svelte
748 lines
25 KiB
Svelte
<script lang="ts">
|
|
import { superadmin, userStore, type DBSchema } from '$lib/stores'
|
|
import {
|
|
ChevronDownIcon,
|
|
EditIcon,
|
|
Loader2,
|
|
Plus,
|
|
Table2,
|
|
Trash2Icon,
|
|
UploadIcon
|
|
} from 'lucide-svelte'
|
|
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 type { IDbSchemaOps, IDbTableOps } from './dbOps'
|
|
import DropdownV2 from './DropdownV2.svelte'
|
|
import ConfirmationModal from './common/confirmationModal/ConfirmationModal.svelte'
|
|
import Button from './common/button/Button.svelte'
|
|
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'
|
|
import type { Snippet } from 'svelte'
|
|
import {
|
|
dbSupportsTransactionalDdl,
|
|
diffTableEditorValues
|
|
} from './apps/components/display/dbtable/queries/alterTable'
|
|
import { resource } from 'runed'
|
|
import { capitalize, onlyAlphaNumAndUnderscore, pluralize } from '$lib/utils'
|
|
import type { DbFeatures } from './apps/components/display/dbtable/dbFeatures'
|
|
import Star from './Star.svelte'
|
|
import type { Asset } from '$lib/gen'
|
|
|
|
/** Represents a selected table with its schema */
|
|
export interface SelectedTable {
|
|
schema: string
|
|
table: string
|
|
}
|
|
|
|
type Props = {
|
|
dbType: DbType
|
|
dbSchema: DBSchema
|
|
dbSupportsSchemas: boolean
|
|
databaseIsEmpty?: boolean
|
|
colDefs: Record<string, ColumnDef[]> | undefined
|
|
dbTableOpsFactory: (params: { colDefs: ColumnDef[]; tableKey: string }) => IDbTableOps
|
|
dbSchemaOps: IDbSchemaOps
|
|
refresh?: () => void
|
|
initialSchemaKey?: string
|
|
initialTableKey?: string
|
|
selectedSchemaKey?: string | undefined
|
|
selectedTableKey?: string | undefined
|
|
dbSelector?: Snippet<[]>
|
|
/** Enable multi-select mode with checkboxes in sidebar */
|
|
multiSelectMode?: boolean
|
|
/** Selected tables in multi-select mode */
|
|
selectedTables?: SelectedTable[]
|
|
/** Tables that are already added and should show as disabled */
|
|
disabledTables?: SelectedTable[]
|
|
features?: DbFeatures
|
|
asset?: Asset
|
|
onImport?: (mode: 'schema_and_data' | 'schema_only') => void
|
|
}
|
|
let {
|
|
dbType,
|
|
dbSchema,
|
|
dbTableOpsFactory,
|
|
dbSchemaOps,
|
|
dbSupportsSchemas,
|
|
databaseIsEmpty,
|
|
colDefs,
|
|
refresh,
|
|
initialSchemaKey,
|
|
initialTableKey,
|
|
selectedSchemaKey = $bindable(undefined),
|
|
selectedTableKey = $bindable(undefined),
|
|
dbSelector,
|
|
multiSelectMode = false,
|
|
selectedTables = $bindable([]),
|
|
disabledTables = [],
|
|
features,
|
|
asset,
|
|
onImport
|
|
}: Props = $props()
|
|
|
|
// Helper to check if a table is selected in multi-select mode
|
|
function isTableSelected(schema: string, table: string): boolean {
|
|
return selectedTables.some((t) => t.schema === schema && t.table === table)
|
|
}
|
|
|
|
// Helper to check if a table is disabled (already added)
|
|
function isTableDisabled(schema: string, table: string): boolean {
|
|
return disabledTables.some((t) => t.schema === schema && t.table === table)
|
|
}
|
|
|
|
// Toggle table selection in multi-select mode
|
|
function toggleTableSelection(schema: string, table: string) {
|
|
if (isTableDisabled(schema, table)) return
|
|
|
|
const idx = selectedTables.findIndex((t) => t.schema === schema && t.table === table)
|
|
if (idx >= 0) {
|
|
selectedTables = selectedTables.filter((_, i) => i !== idx)
|
|
} else {
|
|
selectedTables = [...selectedTables, { schema, table }]
|
|
}
|
|
}
|
|
|
|
// Get tables for a schema (filtered by search)
|
|
function getTablesForSchema(schema: string): string[] {
|
|
const tables = Object.keys(dbSchema.schema[schema] ?? {})
|
|
if (search) {
|
|
return tables.filter((t) => t.toLowerCase().includes(search.toLowerCase())).sort()
|
|
}
|
|
return tables.sort()
|
|
}
|
|
|
|
// Check if all selectable tables in a schema are selected
|
|
function isSchemaFullySelected(schema: string): boolean {
|
|
const tables = getTablesForSchema(schema)
|
|
if (tables.length === 0) return false
|
|
const selectableTables = tables.filter((t) => !isTableDisabled(schema, t))
|
|
if (selectableTables.length === 0) return true // All disabled means "fully selected"
|
|
return selectableTables.every((t) => isTableSelected(schema, t))
|
|
}
|
|
|
|
// Check if some (but not all) tables in a schema are selected
|
|
function isSchemaPartiallySelected(schema: string): boolean {
|
|
const tables = getTablesForSchema(schema)
|
|
const selectableTables = tables.filter((t) => !isTableDisabled(schema, t))
|
|
const selectedCount = selectableTables.filter((t) => isTableSelected(schema, t)).length
|
|
return selectedCount > 0 && selectedCount < selectableTables.length
|
|
}
|
|
|
|
// Toggle all tables in a schema
|
|
function toggleSchemaSelection(schema: string) {
|
|
const tables = getTablesForSchema(schema)
|
|
const selectableTables = tables.filter((t) => !isTableDisabled(schema, t))
|
|
|
|
if (isSchemaFullySelected(schema)) {
|
|
// Deselect all selectable tables in this schema
|
|
selectedTables = selectedTables.filter((t) => t.schema !== schema)
|
|
} else {
|
|
// Select all selectable tables in this schema
|
|
const newSelections = selectableTables
|
|
.filter((t) => !isTableSelected(schema, t))
|
|
.map((t) => ({ schema, table: t }))
|
|
selectedTables = [...selectedTables, ...newSelections]
|
|
}
|
|
}
|
|
|
|
let schemaKeys = $derived(Object.keys(dbSchema.schema ?? {}))
|
|
let search = $state('')
|
|
let selected: {
|
|
schemaKey?: undefined | string
|
|
tableKey?: undefined | string
|
|
} = $state({})
|
|
|
|
$effect(() => {
|
|
if (!selected.schemaKey && schemaKeys.length) {
|
|
let schemaKey =
|
|
initialSchemaKey ??
|
|
('public' in dbSchema.schema ? 'public' : 'dbo' in dbSchema.schema ? 'dbo' : schemaKeys[0])
|
|
let tableKey =
|
|
initialTableKey && dbSchema.schema?.[schemaKey]?.[initialTableKey]
|
|
? initialTableKey
|
|
: undefined
|
|
selected = { schemaKey, tableKey }
|
|
}
|
|
})
|
|
|
|
// Sync selected state with bindable props
|
|
$effect(() => {
|
|
if (selected.schemaKey) {
|
|
selectedSchemaKey = selected.schemaKey
|
|
}
|
|
if (selected.tableKey) {
|
|
selectedTableKey = selected.tableKey
|
|
}
|
|
})
|
|
|
|
let tableKeys = $derived.by(() => {
|
|
if (dbSchema.lang === 'graphql') {
|
|
sendUserToast('graphql not supported by DBExplorerTable', true)
|
|
return []
|
|
}
|
|
if (!selected.schemaKey) return []
|
|
return Object.keys(dbSchema.schema[selected.schemaKey] ?? {})
|
|
})
|
|
|
|
$effect(() => {
|
|
if (tableKeys.length && !selected.tableKey) {
|
|
selected.tableKey = filteredTableKeys[0]
|
|
}
|
|
})
|
|
|
|
let filteredTableKeys = $derived.by(() => {
|
|
const l = tableKeys.filter((tk) => tk.includes(search))
|
|
l.sort()
|
|
return l
|
|
})
|
|
|
|
let tableKey = $derived(
|
|
dbSupportsSchemas && selected.schemaKey
|
|
? `${selected.schemaKey}.${selected.tableKey}`
|
|
: selected.tableKey
|
|
)
|
|
|
|
let askingForConfirmation:
|
|
| (ConfirmationModal['$$prop_def'] & { onConfirm: () => void })
|
|
| undefined = $state()
|
|
|
|
let dbTableEditorState:
|
|
| { open: boolean; alterTableKey?: undefined }
|
|
| { open: true; alterTableKey: string } = $state({
|
|
open: false
|
|
})
|
|
let dbTableEditorAlterTableData = resource(
|
|
[() => dbTableEditorState.alterTableKey, () => colDefs],
|
|
async ([table]) => {
|
|
if (!table) return
|
|
let tableKey2 =
|
|
dbSupportsSchemas && selected.schemaKey ? `${selected.schemaKey}.${table}` : table
|
|
if (!colDefs?.[tableKey2]) return
|
|
return await dbSchemaOps.onFetchTableEditorDefinition({
|
|
table: table,
|
|
schema: selected.schemaKey,
|
|
colDefs: colDefs[tableKey2]
|
|
})
|
|
}
|
|
)
|
|
|
|
let newSchemaDialogOpen = $state(false)
|
|
let newSchemaName = $state('')
|
|
|
|
// Check if the sanitized schema name already exists
|
|
const sanitizedNewSchemaName = $derived.by(() => {
|
|
let s = newSchemaName.trim().replace(/[^a-zA-Z0-9_]/g, '')
|
|
if (dbType === 'snowflake') s = s.toUpperCase()
|
|
return s
|
|
})
|
|
const schemaAlreadyExists = $derived(
|
|
sanitizedNewSchemaName !== '' &&
|
|
schemaKeys.map((s) => s.toLowerCase()).includes(sanitizedNewSchemaName.toLowerCase())
|
|
)
|
|
|
|
let _dbTable: DBTable | undefined = $state()
|
|
export const dbTable = () => _dbTable
|
|
</script>
|
|
|
|
<Splitpanes>
|
|
<Pane size={24} class="relative flex flex-col">
|
|
<div class="mx-3 mt-3 flex flex-col gap-2">
|
|
{#if dbSelector}
|
|
{@render dbSelector()}
|
|
{/if}
|
|
{#if dbSupportsSchemas && !multiSelectMode}
|
|
<Select
|
|
bind:value={selected.schemaKey}
|
|
items={safeSelectItems(schemaKeys)}
|
|
id="db-schema-select"
|
|
transformInputSelectedText={(s) => `Schema: ${s}`}
|
|
RightIcon={ChevronDownIcon}
|
|
placeholder="Search or create schema..."
|
|
showPlaceholderOnOpen
|
|
onCreateItem={(schema) => {
|
|
schema = schema.trim().replace(/[^a-zA-Z0-9_]/g, '')
|
|
if (dbType === 'snowflake') schema = schema.toUpperCase()
|
|
askingForConfirmation = {
|
|
confirmationText: `Create ${schema}`,
|
|
type: 'reload',
|
|
title: `This will run 'CREATE SCHEMA ${schema}' on your database. Are you sure ?`,
|
|
open: true,
|
|
id: 'db-create-schema-confirmation-modal',
|
|
onConfirm: async () => {
|
|
askingForConfirmation && (askingForConfirmation.loading = true)
|
|
try {
|
|
await dbSchemaOps.onCreateSchema({ schema })
|
|
refresh?.()
|
|
selected.schemaKey = schema
|
|
} finally {
|
|
askingForConfirmation = undefined
|
|
}
|
|
}
|
|
}
|
|
}}
|
|
/>
|
|
{/if}
|
|
<ClearableInput bind:value={search} placeholder="Search table..." />
|
|
</div>
|
|
<div class="overflow-x-clip overflow-y-auto relative mt-3 border-y flex-1">
|
|
{#if multiSelectMode}
|
|
<!-- Multi-select mode: show all schemas with their tables -->
|
|
{#if dbSupportsSchemas}
|
|
<!-- New schema button -->
|
|
<button
|
|
class="w-full text-sm font-medium flex gap-2 items-center h-9 cursor-pointer pl-3 pr-1 hover:bg-gray-500/10 border-b border-surface-secondary text-tertiary"
|
|
onclick={() => (newSchemaDialogOpen = true)}
|
|
>
|
|
<Plus class="shrink-0" size={14} />
|
|
<span class="text-xs">New schema</span>
|
|
</button>
|
|
{/if}
|
|
{#each schemaKeys as schemaKey}
|
|
{@const schemaTables = getTablesForSchema(schemaKey)}
|
|
{@const isFullySelected = isSchemaFullySelected(schemaKey)}
|
|
{@const isPartiallySelected = isSchemaPartiallySelected(schemaKey)}
|
|
{@const hasNoTables = schemaTables.length === 0}
|
|
<!-- Schema header with checkbox (or just label if empty) -->
|
|
<div
|
|
class="group w-full text-sm font-medium flex gap-2 items-center h-9 cursor-pointer pl-3 pr-1 hover:bg-gray-500/10 border-b border-surface-secondary"
|
|
role="button"
|
|
tabindex="0"
|
|
onclick={() => {
|
|
if (!hasNoTables) {
|
|
toggleSchemaSelection(schemaKey)
|
|
}
|
|
}}
|
|
onkeydown={(e) => {
|
|
if (e.key === 'Enter' || e.key === ' ') {
|
|
if (!hasNoTables) {
|
|
toggleSchemaSelection(schemaKey)
|
|
}
|
|
}
|
|
}}
|
|
>
|
|
{#if hasNoTables}
|
|
<!-- Empty schema: no checkbox, just indent space -->
|
|
<span class="shrink-0 w-4"></span>
|
|
{:else}
|
|
<span class="shrink-0">
|
|
<input
|
|
type="checkbox"
|
|
checked={isFullySelected}
|
|
indeterminate={isPartiallySelected}
|
|
class="w-4 h-4 cursor-pointer"
|
|
onclick={(e) => e.stopPropagation()}
|
|
onchange={() => toggleSchemaSelection(schemaKey)}
|
|
/>
|
|
</span>
|
|
{/if}
|
|
<span class="truncate text-ellipsis grow text-left text-tertiary text-xs"
|
|
>{schemaKey}</span
|
|
>
|
|
<span class="text-2xs text-tertiary mr-2 group-hover:hidden">
|
|
{schemaTables.length}
|
|
</span>
|
|
<!-- Delete schema button (on hover) -->
|
|
<button
|
|
class="hidden group-hover:flex p-1 hover:bg-red-100 dark:hover:bg-red-900/30 rounded transition-colors mr-1"
|
|
title="Delete schema"
|
|
onclick={(e) => {
|
|
e.stopPropagation()
|
|
askingForConfirmation = {
|
|
title: `Are you sure you want to delete schema "${schemaKey}"? This will drop all tables in this schema. This action is irreversible.`,
|
|
confirmationText: 'Drop schema',
|
|
open: true,
|
|
onConfirm: async () => {
|
|
askingForConfirmation && (askingForConfirmation.loading = true)
|
|
try {
|
|
await dbSchemaOps.onDeleteSchema({ schema: schemaKey })
|
|
refresh?.()
|
|
sendUserToast(`Schema '${schemaKey}' deleted successfully`)
|
|
} catch (e) {
|
|
let msg: string | undefined = (e as Error).message
|
|
if (typeof msg !== 'string') msg = e ? JSON.stringify(e) : undefined
|
|
sendUserToast(msg ?? 'Action failed!', true)
|
|
}
|
|
askingForConfirmation = undefined
|
|
}
|
|
}
|
|
}}
|
|
>
|
|
<Trash2Icon size={12} class="text-red-500" />
|
|
</button>
|
|
</div>
|
|
<!-- Tables under this schema -->
|
|
{#each schemaTables as tableKey}
|
|
{@const isDisabled = isTableDisabled(schemaKey, tableKey)}
|
|
{@const isChecked = isTableSelected(schemaKey, tableKey) || isDisabled}
|
|
{@const isCurrentPreview =
|
|
selected.schemaKey === schemaKey && selected.tableKey === tableKey}
|
|
<div
|
|
class={'group w-full text-sm font-normal flex gap-2 items-center h-8 cursor-pointer pl-7 pr-1 ' +
|
|
(isCurrentPreview ? 'bg-gray-500/25' : 'hover:bg-gray-500/10') +
|
|
(isDisabled ? ' opacity-50' : '')}
|
|
role="button"
|
|
tabindex="0"
|
|
onclick={() => {
|
|
selected.schemaKey = schemaKey
|
|
selected.tableKey = tableKey
|
|
toggleTableSelection(schemaKey, tableKey)
|
|
}}
|
|
onkeydown={(e) => {
|
|
if (e.key === 'Enter' || e.key === ' ') {
|
|
selected.schemaKey = schemaKey
|
|
selected.tableKey = tableKey
|
|
toggleTableSelection(schemaKey, tableKey)
|
|
}
|
|
}}
|
|
>
|
|
<span class="shrink-0">
|
|
<input
|
|
type="checkbox"
|
|
checked={isChecked}
|
|
disabled={isDisabled}
|
|
class="w-4 h-4 cursor-pointer"
|
|
onclick={(e) => e.stopPropagation()}
|
|
onchange={() => toggleTableSelection(schemaKey, tableKey)}
|
|
/>
|
|
</span>
|
|
<Table2 class="text-primary shrink-0" size={14} />
|
|
<p class="truncate text-ellipsis grow text-left text-emphasis text-xs">{tableKey}</p>
|
|
<!-- Delete table button (on hover) -->
|
|
<button
|
|
class="hidden group-hover:flex p-1 hover:bg-red-100 dark:hover:bg-red-900/30 rounded transition-colors mr-1"
|
|
title="Delete table"
|
|
onclick={(e) => {
|
|
e.stopPropagation()
|
|
askingForConfirmation = {
|
|
title: `Are you sure you want to delete table "${tableKey}"? This action is irreversible.`,
|
|
confirmationText: 'Drop table',
|
|
open: true,
|
|
onConfirm: async () => {
|
|
askingForConfirmation && (askingForConfirmation.loading = true)
|
|
try {
|
|
await dbSchemaOps.onDelete({ tableKey, schema: schemaKey })
|
|
refresh?.()
|
|
sendUserToast(`Table '${tableKey}' deleted successfully`)
|
|
} catch (e) {
|
|
let msg: string | undefined = (e as Error).message
|
|
if (typeof msg !== 'string') msg = e ? JSON.stringify(e) : undefined
|
|
sendUserToast(msg ?? 'Action failed!', true)
|
|
}
|
|
askingForConfirmation = undefined
|
|
}
|
|
}
|
|
}}
|
|
>
|
|
<Trash2Icon size={12} class="text-red-500" />
|
|
</button>
|
|
</div>
|
|
{/each}
|
|
<!-- New table button for this schema -->
|
|
<button
|
|
class="w-full text-sm font-normal flex gap-2 items-center h-8 cursor-pointer pl-7 pr-1 hover:bg-gray-500/10 text-tertiary"
|
|
onclick={() => {
|
|
selected.schemaKey = schemaKey
|
|
dbTableEditorState = { open: true }
|
|
}}
|
|
>
|
|
<Plus class="shrink-0" size={14} />
|
|
<span class="text-xs">New table</span>
|
|
</button>
|
|
{/each}
|
|
{:else}
|
|
<!-- Normal mode: show tables for selected schema -->
|
|
{#each filteredTableKeys as tableKey}
|
|
<!-- PLACEHOLDER -->
|
|
<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)}
|
|
>
|
|
{#if asset}
|
|
<Star
|
|
kind="asset"
|
|
path={`${asset.kind}://${asset.path == 'main' ? '' : asset.path}/${selected.schemaKey}.${tableKey}`}
|
|
/>
|
|
{:else}
|
|
<Table2 class="text-primary shrink-0" size={14} />
|
|
{/if}
|
|
|
|
<p
|
|
class="db-manager-table-key truncate text-ellipsis grow text-left text-emphasis text-xs"
|
|
>
|
|
{tableKey}
|
|
</p>
|
|
<DropdownV2
|
|
items={() => [
|
|
{
|
|
displayName: 'Delete table',
|
|
icon: Trash2Icon,
|
|
action: () =>
|
|
(askingForConfirmation = {
|
|
title: `Are you sure you want to delete ${tableKey} ? This action is irreversible`,
|
|
confirmationText: 'Delete permanently',
|
|
open: true,
|
|
id: 'db-manager-delete-table-confirmation-modal',
|
|
onConfirm: async () => {
|
|
askingForConfirmation && (askingForConfirmation.loading = true)
|
|
try {
|
|
await dbSchemaOps.onDelete({ tableKey, schema: selected.schemaKey })
|
|
refresh?.()
|
|
sendUserToast(`Table '${tableKey}' deleted successfully`)
|
|
} catch (e) {
|
|
let msg: string | undefined = (e as Error).message
|
|
if (typeof msg !== 'string') msg = e ? JSON.stringify(e) : undefined
|
|
sendUserToast(msg ?? 'Action failed!', true)
|
|
}
|
|
askingForConfirmation = undefined
|
|
}
|
|
})
|
|
},
|
|
{
|
|
displayName: 'Alter table',
|
|
icon: EditIcon,
|
|
action: () => {
|
|
dbTableEditorState = {
|
|
open: true,
|
|
alterTableKey: tableKey
|
|
}
|
|
}
|
|
}
|
|
]}
|
|
class="w-fit"
|
|
btnId={'db-manager-table-actions-' + onlyAlphaNumAndUnderscore(tableKey)}
|
|
/>
|
|
</button>
|
|
{/each}
|
|
{/if}
|
|
</div>
|
|
{#if !multiSelectMode}
|
|
<Button
|
|
on:click={() => (dbTableEditorState = { open: true })}
|
|
wrapperClasses="mx-2 my-2 text-sm"
|
|
startIcon={{ icon: Plus }}
|
|
variant={tableKeys.length === 0 ? 'accent' : 'default'}
|
|
>
|
|
New table
|
|
</Button>
|
|
{/if}
|
|
</Pane>
|
|
<Pane class="p-3 pt-1">
|
|
{#if tableKey && colDefs?.[tableKey]?.length}
|
|
{@const dbTableOps = dbTableOpsFactory({ colDefs: colDefs[tableKey], tableKey })}
|
|
<DBTable {dbTableOps} 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>
|
|
{#if onImport}
|
|
<div class="flex gap-4">
|
|
<button
|
|
onclick={() => onImport('schema_only')}
|
|
class="hover:opacity-70 transition-opacity rounded-md border aspect-square w-52 gap-4 p-4 center-center flex-col"
|
|
>
|
|
<UploadIcon size={64} class="text-secondary" />
|
|
<span class="text-center font-normal text-sm text-secondary">
|
|
Import schema from database
|
|
</span>
|
|
</button>
|
|
{#if !!$userStore?.is_admin || !!$superadmin}
|
|
<button
|
|
onclick={() => onImport('schema_and_data')}
|
|
class="hover:opacity-70 transition-opacity rounded-md border aspect-square w-52 gap-4 p-4 center-center flex-col"
|
|
>
|
|
<UploadIcon size={64} class="text-secondary" />
|
|
<span class="text-center font-normal text-sm text-secondary">
|
|
Import schema and data from database
|
|
</span>
|
|
</button>
|
|
{/if}
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
{/if}
|
|
</Pane>
|
|
</Splitpanes>
|
|
|
|
<Portal>
|
|
<ConfirmationModal
|
|
{...askingForConfirmation ?? { confirmationText: '', title: '' }}
|
|
on:canceled={() => (askingForConfirmation = undefined)}
|
|
on:confirmed={askingForConfirmation?.onConfirm ?? (() => {})}
|
|
/>
|
|
</Portal>
|
|
|
|
<Drawer
|
|
size="600px"
|
|
open={dbTableEditorState.open}
|
|
on:close={() => (dbTableEditorState = { open: false })}
|
|
>
|
|
<DrawerContent
|
|
id="db-table-editor-drawer"
|
|
on:close={() => (dbTableEditorState = { open: false })}
|
|
title={dbTableEditorState.alterTableKey
|
|
? `Alter ${dbTableEditorState.alterTableKey}`
|
|
: 'Create a new table'}
|
|
>
|
|
{#key dbTableEditorState.alterTableKey}
|
|
{#if !dbTableEditorState.alterTableKey || dbTableEditorAlterTableData.current}
|
|
<DbTableEditor
|
|
{features}
|
|
{dbSchema}
|
|
currentSchema={selected.schemaKey}
|
|
initialValues={dbTableEditorAlterTableData.current}
|
|
onConfirm={async ({ values }) => {
|
|
if (dbTableEditorState.alterTableKey && dbTableEditorAlterTableData.current) {
|
|
let diff = diffTableEditorValues(dbTableEditorAlterTableData.current, values)
|
|
await dbSchemaOps.onAlter({ schema: selected.schemaKey, values: diff })
|
|
} else {
|
|
await dbSchemaOps.onCreate({ values, schema: selected.schemaKey })
|
|
}
|
|
refresh?.()
|
|
sendUserToast(
|
|
dbTableEditorState.alterTableKey
|
|
? dbTableEditorState.alterTableKey + ' updated!'
|
|
: values.name + ' created!'
|
|
)
|
|
dbTableEditorState = { open: false }
|
|
}}
|
|
{dbType}
|
|
computePreview={async ({ values }) => {
|
|
if (dbTableEditorState.alterTableKey && dbTableEditorAlterTableData.current) {
|
|
let diff = diffTableEditorValues(dbTableEditorAlterTableData.current, values)
|
|
let sql = await dbSchemaOps.previewAlterSql({
|
|
values: diff,
|
|
schema: selected.schemaKey
|
|
})
|
|
let alert = !dbSupportsTransactionalDdl(dbType)
|
|
? {
|
|
title: capitalize(dbType) + ' does not support transactional DDL',
|
|
body: 'Any of these statements failing may leave your database in an intermediate state.'
|
|
}
|
|
: undefined
|
|
return { sql, ...(alert ? { alert } : {}) }
|
|
} else {
|
|
let sql = await dbSchemaOps.previewCreateSql({ values, schema: selected.schemaKey })
|
|
return { sql }
|
|
}
|
|
}}
|
|
computeBtnProps={({ values }) => {
|
|
if (dbTableEditorState.alterTableKey && dbTableEditorAlterTableData.current) {
|
|
let diff = diffTableEditorValues(dbTableEditorAlterTableData.current, values)
|
|
if (!diff.operations.length) {
|
|
return { text: 'No changes detected', disabled: true }
|
|
}
|
|
return {
|
|
text: `Alter table (${pluralize(diff.operations.length, 'change')} detected)`
|
|
}
|
|
} else {
|
|
return { text: 'Create table' }
|
|
}
|
|
}}
|
|
/>
|
|
{:else if dbTableEditorAlterTableData.loading || !colDefs}
|
|
<Loader2 class="animate-spin" size={32} />
|
|
{:else}
|
|
<p class="text-sm text-tertiary">Failed to load table definition.</p>
|
|
<p>{dbTableEditorAlterTableData.error}</p>
|
|
{/if}
|
|
{/key}
|
|
</DrawerContent>
|
|
</Drawer>
|
|
|
|
<Drawer
|
|
size="400px"
|
|
open={newSchemaDialogOpen}
|
|
on:close={() => {
|
|
newSchemaDialogOpen = false
|
|
newSchemaName = ''
|
|
}}
|
|
>
|
|
<DrawerContent
|
|
on:close={() => {
|
|
newSchemaDialogOpen = false
|
|
newSchemaName = ''
|
|
}}
|
|
title="Create a new schema"
|
|
>
|
|
<div class="flex flex-col gap-4">
|
|
<div>
|
|
<label for="schema-name" class="block text-sm font-medium text-primary mb-1"
|
|
>Schema name</label
|
|
>
|
|
<ClearableInput
|
|
bind:value={newSchemaName}
|
|
placeholder="Enter schema name..."
|
|
autofocus
|
|
on:keydown={(e) => {
|
|
if (e.key === 'Enter' && sanitizedNewSchemaName && !schemaAlreadyExists) {
|
|
askingForConfirmation = {
|
|
confirmationText: `Create ${sanitizedNewSchemaName}`,
|
|
type: 'reload',
|
|
title: `This will run 'CREATE SCHEMA ${sanitizedNewSchemaName}' on your database. Are you sure?`,
|
|
open: true,
|
|
id: 'db-create-schema-confirmation-modal',
|
|
onConfirm: async () => {
|
|
askingForConfirmation && (askingForConfirmation.loading = true)
|
|
try {
|
|
await dbSchemaOps.onCreateSchema({ schema: sanitizedNewSchemaName })
|
|
refresh?.()
|
|
selected.schemaKey = sanitizedNewSchemaName
|
|
newSchemaDialogOpen = false
|
|
newSchemaName = ''
|
|
} finally {
|
|
askingForConfirmation = undefined
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}}
|
|
/>
|
|
{#if schemaAlreadyExists}
|
|
<p class="text-xs text-red-500 mt-1">
|
|
Schema "{sanitizedNewSchemaName}" already exists
|
|
</p>
|
|
{:else}
|
|
<p class="text-xs text-tertiary mt-1">
|
|
Only letters, numbers, and underscores are allowed.
|
|
</p>
|
|
{/if}
|
|
</div>
|
|
</div>
|
|
{#snippet actions()}
|
|
<Button
|
|
color="blue"
|
|
disabled={!sanitizedNewSchemaName || schemaAlreadyExists}
|
|
on:click={() => {
|
|
askingForConfirmation = {
|
|
confirmationText: `Create ${sanitizedNewSchemaName}`,
|
|
type: 'reload',
|
|
title: `This will run 'CREATE SCHEMA ${sanitizedNewSchemaName}' on your database. Are you sure?`,
|
|
open: true,
|
|
id: 'db-create-schema-confirmation-modal',
|
|
onConfirm: async () => {
|
|
askingForConfirmation && (askingForConfirmation.loading = true)
|
|
try {
|
|
await dbSchemaOps.onCreateSchema({ schema: sanitizedNewSchemaName })
|
|
refresh?.()
|
|
selected.schemaKey = sanitizedNewSchemaName
|
|
newSchemaDialogOpen = false
|
|
newSchemaName = ''
|
|
} finally {
|
|
askingForConfirmation = undefined
|
|
}
|
|
}
|
|
}
|
|
}}
|
|
>
|
|
Create schema
|
|
</Button>
|
|
{/snippet}
|
|
</DrawerContent>
|
|
</Drawer>
|