diff --git a/frontend/package-lock.json b/frontend/package-lock.json index d58336161f..9c3d0635a6 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -11220,8 +11220,6 @@ }, "node_modules/zod": { "version": "3.24.2", - "resolved": "https://registry.npmjs.org/zod/-/zod-3.24.2.tgz", - "integrity": "sha512-lY7CDW43ECgW9u1TcT3IoXHflywfVqDYze4waEz812jR/bZ8FHDsl7pFQoSZTz5N+2NqRXs8GBwnAwo3ZNxqhQ==", "license": "MIT", "funding": { "url": "https://github.com/sponsors/colinhacks" diff --git a/frontend/src/lib/components/BoundedInputNumber b/frontend/src/lib/components/BoundedInputNumber deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/frontend/src/lib/components/DBManager.svelte b/frontend/src/lib/components/DBManager.svelte new file mode 100644 index 0000000000..cfca62c71b --- /dev/null +++ b/frontend/src/lib/components/DBManager.svelte @@ -0,0 +1,206 @@ + + + + +
+ {#if dbSupportsSchemas} + + {/if} + +
+
+ {#each filteredTableKeys as tableKey} + + {/each} +
+ +
+ + {#if tableKey} + {#await getColDefs(tableKey) then colDefs} + {#if colDefs && colDefs?.length} + {@const dbTableOps = dbTableOpsFactory({ colDefs, tableKey })} + + {/if} + {/await} + {/if} + +
+ + (askingForConfirmation = undefined)} + on:confirmed={askingForConfirmation?.onConfirm ?? (() => {})} +/> + +{#if dbTableEditorProps} + (dbTableEditorState = { open: false })} + > + (dbTableEditorState = { open: false })} + title="Create a new table" + > + { + await dbTableEditorProps.onConfirm(values) + dbTableEditorState = { open: false } + }} + /> + + +{/if} diff --git a/frontend/src/lib/components/DBManagerDrawerButton.svelte b/frontend/src/lib/components/DBManagerDrawerButton.svelte new file mode 100644 index 0000000000..1120f4211f --- /dev/null +++ b/frontend/src/lib/components/DBManagerDrawerButton.svelte @@ -0,0 +1,272 @@ + + + { + if (e.key === 'Escape') { + if (replResultData) { + replResultData = undefined + } + } + }} +/> + +{#if shouldDisplayError} + + Schema could not be loaded. Please check the permissions of the resource. + +{:else} + + + { + if (replResultData) { + replResultData = undefined + } else { + isDrawerOpen = false + } + }} + CloseIcon={replResultData ? ArrowLeft : undefined} + noPadding + > + {#if dbSchema && $workspaceStore} + + + + +
{ + // Only proceed if the click is directly on this div and not on the child elements + if (e.target === e.currentTarget) { + replResultData = undefined + } + }} + > + {#if replResultData} + {#key replResultData} + + {/key} + {/if} +
+ + dbTableOpsWithPreviewScripts({ + colDefs, + tableKey, + resourcePath, + resourceType, + workspace: $workspaceStore + })} + dbTableActionsFactory={[ + dbDeleteTableActionWithPreviewScript({ + resourcePath, + resourceType, + workspace: $workspaceStore + }) + ]} + {refresh} + dbTableEditorPropsFactory={({ selectedSchemaKey }) => ({ + resourceType, + previewSql: (values) => + makeCreateTableQuery(values, resourceType, selectedSchemaKey), + async onConfirm(values) { + await runPreviewJobAndPollResult({ + workspace: $workspaceStore, + requestBody: { + args: { database: '$res:' + resourcePath }, + content: makeCreateTableQuery(values, resourceType, selectedSchemaKey), + language: getLanguageByResourceType(resourceType) + } + }) + refresh() + } + })} + /> +
+ + { + replResultData = data + }} + placeholderTableName={sortArray( + Object.keys( + dbSchema?.schema[ + 'public' in dbSchema?.schema + ? 'public' + : 'dbo' in dbSchema?.schema + ? 'dbo' + : Object.keys(dbSchema?.schema)?.[0] + ] + ) + )?.[0]} + /> + +
+ {:else} + + + + + + {/if} + + + + - - - - - - {#if dbSchema.lang !== 'graphql' && (dbSchema.schema?.public || dbSchema.schema?.PUBLIC || dbSchema.schema?.dbo)} - - - - - {/if} - {#if dbSchema.lang === 'graphql'} - {#await import('$lib/components/GraphqlSchemaViewer.svelte')} - - {:then Module} - - {/await} - {:else} - - {/if} - - -{:else if shouldDisplayError} - - Schema could not be loaded. Please check the permissions of the resource. - + + + +{/if} +{#if dbSchema.lang === 'graphql'} + {#await import('$lib/components/GraphqlSchemaViewer.svelte')} + + {:then Module} + + {/await} +{:else} + {/if} diff --git a/frontend/src/lib/components/DBTable.svelte b/frontend/src/lib/components/DBTable.svelte new file mode 100644 index 0000000000..337f66b0fd --- /dev/null +++ b/frontend/src/lib/components/DBTable.svelte @@ -0,0 +1,206 @@ + + + + +
+
+ + {#if dbTableOps.onInsert} + { + if (!$workspaceStore) return + dbTableOps.onInsert?.({ values }).then((result) => { + refresh?.() + sendUserToast('Row inserted') + }) + }} + /> + {/if} +
+
+
+ +
{ + 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) + } + } + }} + >
+
+ +
+
+ + Download +
+ {#if rowCount} + {firstRow}{'->'}{lastRow + 1} of {rowCount} rows + {:else} + {firstRow}{'->'}{lastRow + 1} + {/if} +
+
+
diff --git a/frontend/src/lib/components/DBTableEditor.svelte b/frontend/src/lib/components/DBTableEditor.svelte new file mode 100644 index 0000000000..8381b60c45 --- /dev/null +++ b/frontend/src/lib/components/DBTableEditor.svelte @@ -0,0 +1,435 @@ + + + + + + +
+
+ + +
+ + + + + + Name + Type + Primary + + + + {#each values.columns as column, i} + + + + + + + + {#snippet trigger()} + + {/snippet} + {#snippet content()} + {#if datatypeHasLength(column.datatype)} + + {/if} + + {#if !column.primaryKey} + + {/if} + {/snippet} + + + + + + +
+
+ + + + + + Table + Columns + + + + {#each values.foreignKeys as foreignKey, foreignKeyIndex} + {@const fkErrors = errors?.foreignKeys?.[foreignKeyIndex]} + + + (column.sourceColumn = e.detail.value)} + items={values.columns.map((c) => c.name)} + clearable={false} + /> +
+ +
+ + + + + + + + ON UPDATE + + {/snippet} + + {/if} +
+
+ {/each} + +
+ + {/each} + + + + + + + + + + + + + (askingForConfirmation = undefined)} + on:confirmed={askingForConfirmation?.onConfirm ?? (() => {})} +> + {#if askingForConfirmation?.codeContent} +
+ + {askingForConfirmation.codeContent} + + +
+ {/if} +
diff --git a/frontend/src/lib/components/ResourcePicker.svelte b/frontend/src/lib/components/ResourcePicker.svelte index f82dd888dc..4e50c13911 100644 --- a/frontend/src/lib/components/ResourcePicker.svelte +++ b/frontend/src/lib/components/ResourcePicker.svelte @@ -8,10 +8,11 @@ import ResourceEditorDrawer from './ResourceEditorDrawer.svelte' import { Button } from './common' - import DBSchemaExplorer from './DBSchemaExplorer.svelte' + import DBManagerDrawerButton from './DBManagerDrawerButton.svelte' import DarkModeObserver from './DarkModeObserver.svelte' import { Pen, Plus, RotateCw } from 'lucide-svelte' import { sendUserToast } from '$lib/toast' + import { isDbType } from './apps/components/display/dbtable/utils' import { createDispatcherIfMounted } from '$lib/createDispatcherIfMounted' const dispatch = createEventDispatcher() @@ -221,7 +222,7 @@ iconOnly /> - {#if showSchemaExplorer} - + {#if showSchemaExplorer && isDbType(resourceType) && value} + {/if} diff --git a/frontend/src/lib/components/SimpleAgTable.svelte b/frontend/src/lib/components/SimpleAgTable.svelte new file mode 100644 index 0000000000..47da07fd0b --- /dev/null +++ b/frontend/src/lib/components/SimpleAgTable.svelte @@ -0,0 +1,94 @@ + + + + +
+
+ +
{ + 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) + } + } + }} + > +
+
+
diff --git a/frontend/src/lib/components/SqlRepl.svelte b/frontend/src/lib/components/SqlRepl.svelte new file mode 100644 index 0000000000..727334cf1d --- /dev/null +++ b/frontend/src/lib/components/SqlRepl.svelte @@ -0,0 +1,178 @@ + + + + + + + + + + + { + const data = e.detail as (typeof runHistory)[number] + editor?.setCode(data.code) + onData(data.result) + }} + /> + + diff --git a/frontend/src/lib/components/apps/components/display/InsertRowDrawerButton.svelte b/frontend/src/lib/components/apps/components/display/InsertRowDrawerButton.svelte new file mode 100644 index 0000000000..fb7d1aaf6e --- /dev/null +++ b/frontend/src/lib/components/apps/components/display/InsertRowDrawerButton.svelte @@ -0,0 +1,61 @@ + + + { + if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') { + onConfirm() + } + }} +/> + + + + + + + + + + + diff --git a/frontend/src/lib/components/apps/components/display/dbtable/AppDbExplorer.svelte b/frontend/src/lib/components/apps/components/display/dbtable/AppDbExplorer.svelte index 9148ef6524..32b0ea7993 100644 --- a/frontend/src/lib/components/apps/components/display/dbtable/AppDbExplorer.svelte +++ b/frontend/src/lib/components/apps/components/display/dbtable/AppDbExplorer.svelte @@ -21,11 +21,7 @@ import { getContext, tick } from 'svelte' import UpdateCell from './UpdateCell.svelte' import { workspaceStore, type DBSchemas } from '$lib/stores' - import Button from '$lib/components/common/button/Button.svelte' - import { Plus } from 'lucide-svelte' - import { Drawer, DrawerContent } from '$lib/components/common' - import InsertRow from './InsertRow.svelte' - import Portal from '$lib/components/Portal.svelte' + import { Drawer } from '$lib/components/common' import { sendUserToast } from '$lib/toast' import type { AppInput, StaticInput } from '$lib/components/apps/inputType' @@ -41,6 +37,7 @@ import { CancelablePromise } from '$lib/gen' import RefreshButton from '$lib/components/apps/components/helpers/RefreshButton.svelte' import RunnableWrapper from '../../helpers/RunnableWrapper.svelte' + import InsertRowDrawerButton from '../InsertRowDrawerButton.svelte' export let id: string export let configuration: RichConfigurations @@ -173,8 +170,6 @@ ) } - let args: Record = {} - let outputs = initOutput($worldStore, id, { selectedRowIndex: 0, selectedRow: {}, @@ -189,7 +184,7 @@ let lastResource: string | undefined = undefined - function updateOneOfConfiguration( + function updateOneOfConfiguration( oneOfConfiguration: OneOfConfiguration, resolvedConfig: { configuration: Record @@ -249,7 +244,7 @@ resolvedConfig.type.configuration[resolvedConfig?.type?.selected].resource.split(':')[1], $workspaceStore, dbSchemas, - (message: string) => {} + () => {} ) updateOneOfConfiguration( @@ -263,7 +258,7 @@ resolvedConfig?.type?.selected, resource.split(':')[1], $workspaceStore! - ) + ) : [], loading: false } @@ -479,8 +474,6 @@ }, 1500) } - let isInsertable: boolean = false - $: $worldStore && render && connectToComponents() function connectToComponents() { @@ -521,7 +514,7 @@ } } - async function insert() { + async function insert(args: object) { try { const selected = resolvedConfig.type.selected @@ -539,8 +532,6 @@ } catch (e) { sendUserToast(e.message, true) } - - args = {} } let runnableComponent: RunnableComponent @@ -651,17 +642,11 @@
{#if hideInsert !== true} - + insert(args)} + /> {/if}
@@ -696,7 +681,7 @@ - + diff --git a/frontend/src/lib/components/apps/components/display/dbtable/queries/count.ts b/frontend/src/lib/components/apps/components/display/dbtable/queries/count.ts index 2c1960a80a..482752b844 100644 --- a/frontend/src/lib/components/apps/components/display/dbtable/queries/count.ts +++ b/frontend/src/lib/components/apps/components/display/dbtable/queries/count.ts @@ -2,7 +2,7 @@ import type { AppInput, RunnableByName } from '$lib/components/apps/inputType' import { buildParameters, type DbType } from '../utils' import { getLanguageByResourceType, type ColumnDef, buildVisibleFieldList } from '../utils' -function makeCountQuery( +export function makeCountQuery( dbType: DbType, table: string, whereClause: string | undefined = undefined, diff --git a/frontend/src/lib/components/apps/components/display/dbtable/queries/createTable.ts b/frontend/src/lib/components/apps/components/display/dbtable/queries/createTable.ts new file mode 100644 index 0000000000..06a952312a --- /dev/null +++ b/frontend/src/lib/components/apps/components/display/dbtable/queries/createTable.ts @@ -0,0 +1,93 @@ +import { dbSupportsSchemas, type DbType } from '../utils' + +export type CreateTableValues = { + name: string + columns: CreateTableValuesColumn[] + foreignKeys: { + targetTable?: string + columns: { + sourceColumn?: string + targetColumn?: string + }[] + onDelete: 'CASCADE' | 'SET NULL' | 'NO ACTION' + onUpdate: 'CASCADE' | 'SET NULL' | 'NO ACTION' + }[] +} + +type CreateTableValuesColumn = { + name: string + datatype: string + primaryKey?: boolean + defaultValue?: string + not_null?: boolean + datatype_length?: number // e.g varchar(255) +} + +export function makeCreateTableQuery( + values: CreateTableValues, + resourceType: DbType, + schema?: string +) { + const pkCount = values.columns.reduce((p, c) => p + (c.primaryKey ? 1 : 0), 0) + + function transformColumn(c: CreateTableValuesColumn): string { + const datatype = c.datatype_length ? `${c.datatype}(${c.datatype_length})` : c.datatype + const defValue = c.defaultValue && formatDefaultValue(c.defaultValue, datatype, resourceType) + + let str = ` ${c.name} ${datatype}` + if (c.not_null) str += ' NOT NULL' + if (defValue) str += ` DEFAULT ${defValue}` + if (pkCount === 1 && c.primaryKey) str += ' PRIMARY KEY' + return str + } + + function transformFk(fk: CreateTableValues['foreignKeys'][number]): string { + const sourceColumns = fk.columns.map((c) => c.sourceColumn).filter(Boolean) + const targetColumns = fk.columns.map((c) => c.targetColumn).filter(Boolean) + const targetTable = + useSchema || !fk.targetTable?.includes('.') + ? fk.targetTable + : fk.targetTable?.split('.').pop() + + let l = ` FOREIGN KEY (${sourceColumns.join(', ')}) REFERENCES ${targetTable} (${targetColumns.join( + ', ' + )})` + if (fk.onDelete !== 'NO ACTION') l += ` ON DELETE ${fk.onDelete}` + if (fk.onUpdate !== 'NO ACTION') l += ` ON UPDATE ${fk.onUpdate}` + return l + } + + const useSchema = dbSupportsSchemas(resourceType) + + const lines = values.columns.map(transformColumn) + lines.push(...values.foreignKeys.map(transformFk)) + if (pkCount > 1) { + const pks = values.columns.filter((c) => c.primaryKey) + lines.push(` PRIMARY KEY (${pks.map((c) => c.name).join(', ')})`) + } + + return `CREATE TABLE ${useSchema && schema ? schema.trim() + '.' : ''}${values.name.trim()} ( +${lines.join(',\n')} +);` +} + +function formatDefaultValue(str: string, datatype: string, resourceType: DbType): string { + if (!str) return '' + if (str.startsWith('{') && str.endsWith('}')) { + return str.slice(1, str.length - 1) + } + if (resourceType === 'postgresql') { + return `CAST('${str}' AS ${datatype})` + } + return `'${str}'` +} + +export function datatypeDefaultLength(datatype: string): number { + datatype = datatype.toLowerCase() + if (datatype == 'bit') return 1 + if (['varchar', 'char', 'nvarchar', 'nchar', 'varbinary', 'binary'].includes(datatype)) { + return 255 + } else { + return 10 + } +} diff --git a/frontend/src/lib/components/apps/components/display/dbtable/queries/delete.ts b/frontend/src/lib/components/apps/components/display/dbtable/queries/delete.ts index b74be6a3c8..bf343cb974 100644 --- a/frontend/src/lib/components/apps/components/display/dbtable/queries/delete.ts +++ b/frontend/src/lib/components/apps/components/display/dbtable/queries/delete.ts @@ -1,7 +1,7 @@ import type { AppInput, RunnableByName } from '$lib/components/apps/inputType' import { getLanguageByResourceType, type ColumnDef, buildParameters, type DbType } from '../utils' -function deleteWithAllValues(table: string, columns: ColumnDef[], dbType: DbType) { +export function makeDeleteQuery(table: string, columns: ColumnDef[], dbType: DbType) { let query = buildParameters( dbType === 'snowflake' ? columns.flatMap((c) => [c, c]) : columns, dbType @@ -71,7 +71,7 @@ export function getDeleteInput( name: 'AppDbExplorer', type: 'runnableByName', inlineScript: { - content: deleteWithAllValues(table, columns, dbType), + content: makeDeleteQuery(table, columns, dbType), language: getLanguageByResourceType(dbType), schema: { $schema: 'https://json-schema.org/draft/2020-12/schema', diff --git a/frontend/src/lib/components/apps/components/display/dbtable/queries/deleteTable.ts b/frontend/src/lib/components/apps/components/display/dbtable/queries/deleteTable.ts new file mode 100644 index 0000000000..397d9886e0 --- /dev/null +++ b/frontend/src/lib/components/apps/components/display/dbtable/queries/deleteTable.ts @@ -0,0 +1,6 @@ +import type { DbType } from '../utils' + +export function makeDeleteTableQuery(tableKey: string, resourceType: DbType): string { + // same for all sql dbs + return `DROP TABLE ${tableKey};` +} diff --git a/frontend/src/lib/components/apps/components/display/dbtable/queries/select.ts b/frontend/src/lib/components/apps/components/display/dbtable/queries/select.ts index 75fab13a13..f4453360ec 100644 --- a/frontend/src/lib/components/apps/components/display/dbtable/queries/select.ts +++ b/frontend/src/lib/components/apps/components/display/dbtable/queries/select.ts @@ -79,7 +79,7 @@ function makeSnowflakeSelectQuery( return query } -function makeSelectQuery( +export function makeSelectQuery( table: string, columnDefs: ColumnDef[], whereClause: string | undefined, @@ -155,6 +155,9 @@ CASE WHEN :order_by = '${column.field}' AND :is_desc IS true THEN \`${column.fie case 'ms_sql_server': // MSSQL uses CONCAT for string concatenation and supports OFFSET FETCH for pagination // Note: MSSQL does not have a built-in ILIKE function, so we use LIKE with a case-insensitive collation if needed + // + // Note 2: CONCAT in mssql requires 2 to 254 arguments. But we can't change this query without breaking + // existing policies const orderBy = columnDefs .map((column) => { return ` diff --git a/frontend/src/lib/components/apps/components/display/dbtable/queries/update.ts b/frontend/src/lib/components/apps/components/display/dbtable/queries/update.ts index c11c5c304f..467922b9d7 100644 --- a/frontend/src/lib/components/apps/components/display/dbtable/queries/update.ts +++ b/frontend/src/lib/components/apps/components/display/dbtable/queries/update.ts @@ -1,18 +1,15 @@ import type { AppInput, RunnableByName } from '$lib/components/apps/inputType' import { getLanguageByResourceType, type ColumnDef, buildParameters, type DbType } from '../utils' -function updateWithAllValues( +export function makeUpdateQuery( table: string, - column: ColumnDef, - columns: ColumnDef[], + column: { datatype: string; field: string }, + columns: { datatype: string; field: string }[], dbType: DbType ) { let query = buildParameters( [ - { - field: 'value_to_update', - datatype: column.datatype - }, + { field: 'value_to_update', datatype: column.datatype }, ...(dbType === 'snowflake' ? columns.flatMap((c) => [c, c]) : columns) ], dbType @@ -85,7 +82,7 @@ export function getUpdateInput( name: 'AppDbExplorer', type: 'runnableByName', inlineScript: { - content: updateWithAllValues(table, column, columns, dbType), + content: makeUpdateQuery(table, column, columns, dbType), language: getLanguageByResourceType(dbType), schema: { $schema: 'https://json-schema.org/draft/2020-12/schema', diff --git a/frontend/src/lib/components/apps/components/display/dbtable/utils.ts b/frontend/src/lib/components/apps/components/display/dbtable/utils.ts index 226561f667..0c3fce722f 100644 --- a/frontend/src/lib/components/apps/components/display/dbtable/utils.ts +++ b/frontend/src/lib/components/apps/components/display/dbtable/utils.ts @@ -8,6 +8,7 @@ import { } from 'graphql' import { tryEvery } from '$lib/utils' import { stringifySchema } from '$lib/components/copilot/lib' +import { runPreviewJobAndPollResult } from '$lib/components/jobs/utils' export enum ColumnIdentity { ByDefault = 'By Default', @@ -61,15 +62,99 @@ export async function loadTableMetaData( if (!resource || !table || !workspace) { return undefined } + const job = await JobService.runScriptPreview({ + workspace: workspace, + requestBody: { + language: getLanguageByResourceType(resourceType), + content: await makeLoadTableMetaDataQuery(resource, workspace, table, resourceType), + args: { + database: resource + } + } + }) - let code: string = '' + const maxRetries = 8 + let attempts = 0 + while (attempts < maxRetries) { + try { + await new Promise((resolve) => setTimeout(resolve, 1000 * (attempts || 0.6))) + const testResult = (await JobService.getCompletedJob({ + workspace: workspace, + id: job + })) as any + + if (testResult.success) { + attempts = maxRetries + + if (resourceType === 'ms_sql_server') { + return testResult.result[0].map(lowercaseKeys) + } else { + return testResult.result.map(lowercaseKeys) + } + } else { + attempts++ + } + } catch (error) { + attempts++ + } + } + + console.error('Failed to load table metadata after maximum retries.') + return undefined +} + +export async function loadAllTablesMetaData( + resource: string, + workspace: string | undefined, + resourceType: string +): Promise | undefined> { + if (!resource || !workspace) { + return undefined + } + + try { + let result = (await runPreviewJobAndPollResult({ + workspace: workspace, + requestBody: { + language: getLanguageByResourceType(resourceType), + content: await makeLoadTableMetaDataQuery(resource, workspace, undefined, resourceType), + args: { + database: resource + } + } + })) as ({ table_name: string; schema_name?: string } & object)[] + if (resourceType === 'ms_sql_server') { + result = (result as any)[0] + } + const map: Record = {} + + for (const _col of result) { + const col = lowercaseKeys(_col) + const tableKey = col.schema_name ? `${col.schema_name}.${col.table_name}` : col.table_name + if (!(tableKey in map)) { + map[tableKey] = [] + } + map[tableKey].push(col) + } + return map + } catch (e) { + throw new Error('Error loading all tables metadata: ' + e) + } +} + +async function makeLoadTableMetaDataQuery( + resource: string, + workspace: string, + table: string | undefined, + resourceType: string +): Promise { if (resourceType === 'mysql') { const resourceObj = (await ResourceService.getResourceValue({ workspace, path: resource.split(':')[1] })) as any - code = ` + return ` SELECT COLUMN_NAME as field, COLUMN_TYPE as DataType, @@ -77,19 +162,31 @@ export async function loadTableMetaData( CASE WHEN COLUMN_KEY = 'PRI' THEN 'YES' ELSE 'NO' END as IsPrimaryKey, CASE WHEN EXTRA like '%auto_increment%' THEN 'YES' ELSE 'NO' END as IsIdentity, CASE WHEN IS_NULLABLE = 'YES' THEN 'YES' ELSE 'NO' END as IsNullable, - CASE WHEN DATA_TYPE = 'enum' THEN true ELSE false END as IsEnum + CASE WHEN DATA_TYPE = 'enum' THEN true ELSE false END as IsEnum${ + table + ? '' + : `, + TABLE_NAME as table_name` + } FROM - INFORMATION_SCHEMA.COLUMNS - WHERE + INFORMATION_SCHEMA.COLUMNS${ + table + ? ` + WHERE TABLE_NAME = '${table.split('.').reverse()[0]}' AND TABLE_SCHEMA = '${ - table.split('.').reverse()[1] ?? resourceObj?.database ?? '' - }' - ORDER BY + table.split('.').reverse()[1] ?? resourceObj?.database ?? '' + }'` + : ` + WHERE + TABLE_SCHEMA NOT IN ('mysql', 'performance_schema', 'information_schema', 'sys')` + } + ORDER BY + TABLE_NAME, ORDINAL_POSITION; ` } else if (resourceType === 'postgresql') { - code = ` - SELECT + return ` + SELECT a.attname as field, pg_catalog.format_type(a.atttypid, a.atttypmod) as DataType, (SELECT substring(pg_catalog.pg_get_expr(d.adbin, d.adrelid, true) for 128) @@ -110,17 +207,32 @@ export async function loadTableMetaData( END as IsNullable, (SELECT true FROM pg_catalog.pg_enum e - WHERE e.enumtypid = a.atttypid FETCH FIRST ROW ONLY) as IsEnum - FROM pg_catalog.pg_attribute a + WHERE e.enumtypid = a.atttypid FETCH FIRST ROW ONLY) as IsEnum${ + table + ? '' + : `, + ns.nspname AS schema_name, + c.relname AS table_name` + } + FROM pg_catalog.pg_attribute a${ + table + ? ` WHERE a.attrelid = (SELECT c.oid FROM pg_catalog.pg_class c JOIN pg_catalog.pg_namespace ns ON c.relnamespace = ns.oid WHERE relname = '${ table.split('.').reverse()[0] }' AND ns.nspname = '${table.split('.').reverse()[1] ?? 'public'}') AND a.attnum > 0 AND NOT a.attisdropped - ORDER BY a.attnum; + ` + : ` + JOIN pg_catalog.pg_class c ON a.attrelid = c.oid + JOIN pg_catalog.pg_namespace ns ON c.relnamespace = ns.oid + WHERE c.relkind = 'r' AND a.attnum > 0 AND NOT a.attisdropped + AND ns.nspname != 'pg_catalog' AND ns.nspname != 'information_schema'` + } + ORDER BY ${table ? 'a.attnum' : 'ns.nspname, c.relname, a.attnum'}; ` } else if (resourceType === 'ms_sql_server') { - code = ` + return ` SELECT COLUMN_NAME as field, DATA_TYPE as DataType, @@ -128,32 +240,52 @@ export async function loadTableMetaData( CASE WHEN COLUMNPROPERTY(OBJECT_ID(TABLE_NAME), COLUMN_NAME, 'IsIdentity') = 1 THEN 'By Default' ELSE 'No' END as IsIdentity, CASE WHEN COLUMNPROPERTY(OBJECT_ID(TABLE_NAME), COLUMN_NAME, 'IsIdentity') = 1 THEN 1 ELSE 0 END as IsPrimaryKey, -- This line still needs correction for primary key identification CASE WHEN IS_NULLABLE = 'YES' THEN 'YES' ELSE 'NO' END as IsNullable, - CASE WHEN DATA_TYPE = 'enum' THEN 1 ELSE 0 END as IsEnum + CASE WHEN DATA_TYPE = 'enum' THEN 1 ELSE 0 END as IsEnum${ + table + ? '' + : `, + TABLE_NAME as table_name` + } FROM - INFORMATION_SCHEMA.COLUMNS + INFORMATION_SCHEMA.COLUMNS${ + table + ? ` WHERE - TABLE_NAME = '${table}' + TABLE_NAME = '${table}'` + : '' + } ORDER BY ORDINAL_POSITION; - ` } else if (resourceType === 'snowflake' || resourceType === 'snowflake_oauth') { - code = ` + return ` select COLUMN_NAME as field, DATA_TYPE as DataType, COLUMN_DEFAULT as DefaultValue, CASE WHEN COLUMN_DEFAULT like 'AUTOINCREMENT%' THEN 'By Default' ELSE 'No' END as IsIdentity, CASE WHEN COLUMN_DEFAULT like 'AUTOINCREMENT%' THEN 1 ELSE 0 END as IsPrimaryKey, CASE WHEN IS_NULLABLE = 'YES' THEN 'YES' ELSE 'NO' END as IsNullable, - CASE WHEN DATA_TYPE = 'enum' THEN 1 ELSE 0 END as IsEnum - from information_schema.columns + CASE WHEN DATA_TYPE = 'enum' THEN 1 ELSE 0 END as IsEnum${ + table + ? '' + : `, + table_name as table_name, + table_schema as schema_name` + } + from information_schema.columns${ + table + ? ` where table_name = '${table.split('.').reverse()[0]}' and table_schema = '${ - table.split('.').reverse()[1] ?? 'PUBLIC' - }' + table.split('.').reverse()[1] ?? 'PUBLIC' + }'` + : "\nwhere table_schema <> 'INFORMATION_SCHEMA'\n" + } order by ORDINAL_POSITION; ` } else if (resourceType === 'bigquery') { - code = `SELECT + // TODO: find a solution for this (query uses hardcoded dataset name) + if (!table) throw new Error('Table name is required for BigQuery') + return `SELECT c.COLUMN_NAME as field, DATA_TYPE as DataType, CASE WHEN COLUMN_DEFAULT = 'NULL' THEN '' ELSE COLUMN_DEFAULT END as DefaultValue, @@ -164,58 +296,14 @@ ORDER BY FROM ${table.split('.')[0]}.INFORMATION_SCHEMA.COLUMNS c LEFT JOIN - test_dataset.INFORMATION_SCHEMA.KEY_COLUMN_USAGE p + ${table.split('.')[0]}.INFORMATION_SCHEMA.KEY_COLUMN_USAGE p on c.table_name = p.table_name AND c.column_name = p.COLUMN_NAME WHERE - c.TABLE_NAME = "${table.split('.')[1]}" + c.TABLE_NAME = '${table.split('.')[1]}' order by c.ORDINAL_POSITION;` } else { throw new Error('Unsupported database type:' + resourceType) } - - const maxRetries = 3 - let attempts = 0 - - while (attempts < maxRetries) { - try { - const job = await JobService.runScriptPreview({ - workspace: workspace, - requestBody: { - language: getLanguageByResourceType(resourceType), - content: code, - args: { - database: resource - } - } - }) - - await new Promise((resolve) => setTimeout(resolve, 3000)) - - const testResult = (await JobService.getCompletedJob({ - workspace: workspace, - id: job - })) as any - - if (testResult.success) { - attempts = maxRetries - - if (resourceType === 'ms_sql_server') { - return lowercaseKeys(testResult.result[0]) - } else { - return lowercaseKeys(testResult.result) - } - } else { - attempts++ - } - } catch (error) { - attempts++ - } - // Exponential back-off - await new Promise((resolve) => setTimeout(resolve, 2000 * attempts)) - } - - console.error('Failed to load table metadata after maximum retries.') - return undefined } export function resourceTypeToLang(rt: string) { @@ -226,14 +314,12 @@ export function resourceTypeToLang(rt: string) { } } -function lowercaseKeys(arr: Array>): Array { - return arr.map((obj) => { - const newObj = {} - Object.keys(obj).forEach((key) => { - newObj[key.toLowerCase()] = obj[key] - }) - return newObj +function lowercaseKeys(obj: Record): any { + const newObj = {} + Object.keys(obj).forEach((key) => { + newObj[key.toLowerCase()] = obj[key] }) + return newObj } const scripts: Record< @@ -569,7 +655,9 @@ export function formatGraphqlSchema(schema: IntrospectionQuery): string { return printSchema(buildClientSchema(schema)) } -export type DbType = 'mysql' | 'ms_sql_server' | 'postgresql' | 'snowflake' | 'bigquery' +export type DbType = (typeof dbTypes)[number] +export const dbTypes = ['mysql', 'ms_sql_server', 'postgresql', 'snowflake', 'bigquery'] as const +export const isDbType = (str?: string): str is DbType => !!str && dbTypes.includes(str as DbType) export function buildVisibleFieldList(columnDefs: ColumnDef[], dbType: DbType) { // Filter out hidden columns to avoid counting the wrong number of rows @@ -714,3 +802,13 @@ export async function getTablesByResource( return [] } } + +export function dbSupportsSchemas(dbType: DbType): boolean { + return dbType === 'postgresql' || dbType === 'snowflake' || dbType === 'bigquery' +} + +export function datatypeHasLength(datatype: string): boolean { + datatype = datatype.toLowerCase() + const lengthDataTypes = ['varchar', 'char', 'nvarchar', 'nchar', 'varbinary', 'binary', 'bit'] + return lengthDataTypes.some((type) => datatype === type) +} diff --git a/frontend/src/lib/components/apps/components/display/table/utils.ts b/frontend/src/lib/components/apps/components/display/table/utils.ts index 038a2c5480..f3a2c93bb3 100644 --- a/frontend/src/lib/components/apps/components/display/table/utils.ts +++ b/frontend/src/lib/components/apps/components/display/table/utils.ts @@ -2,7 +2,12 @@ * Base class for embedding a svelte component within an AGGrid call. * See: https://stackoverflow.com/a/72608215 */ -import type { ICellRendererComp, ICellRendererParams } from 'ag-grid-community' +import type { ColDef, ColGroupDef, ICellRendererComp, ICellRendererParams } from 'ag-grid-community' +import { ColumnIdentity, type ColumnDef } from '../dbtable/utils' +import type { TableAction } from '$lib/components/apps/editor/component' +import { mount, unmount } from 'svelte' +import { Button } from '$lib/components/common' +import { Trash2 } from 'lucide-svelte' /** * Class for defining a cell renderer. @@ -110,3 +115,139 @@ export function defaultCellRenderer(cellRendererType: string) { return undefined } } + +export function transformColumnDefs({ + columnDefs, + actions, + customActionsHeader, + wrapActions, + tableActionsFactory, + onDelete, + onInvalidColumnDefs +}: { + columnDefs: ColumnDef[] + actions?: TableAction[] + customActionsHeader?: string + wrapActions?: boolean + tableActionsFactory?: ReturnType + onDelete?: (values: object) => void + onInvalidColumnDefs?: (errors: string[]) => void +}): (ColDef | ColGroupDef)[] { + if (!columnDefs) { + return [] + } + + const { isValid, errors } = validateColumnDefs(columnDefs) + + if (!isValid) { + onInvalidColumnDefs?.(errors) + return [] + } + + let r: any[] = columnDefs?.filter((x) => x && !x.ignored) ?? [] + + if (onDelete) { + r.push({ + field: 'delete', + headerName: 'Delete', + cellRenderer: cellRendererFactory((c, p) => { + const btnComponent = mount(Button, { + target: c.eGui, + props: { + btnClasses: 'w-12', + wrapperClasses: 'flex justify-end items-center h-full', + color: 'light', + size: 'sm', + variant: 'contained', + iconOnly: true, + startIcon: { icon: Trash2 }, + nonCaptureEvent: true + } + }) + return { + destroy: () => { + unmount(btnComponent) + }, + refresh(params) { + // + } + } + }), + cellRendererParams: { + onClick: (e) => { + onDelete?.(e) + } + }, + lockPosition: 'right', + editable: false, + flex: 0, + width: 100 + }) + } + + if (actions?.length) { + r.push({ + headerName: customActionsHeader ?? 'Actions', + cellRenderer: tableActionsFactory, + autoHeight: true, + cellStyle: { textAlign: 'center' }, + cellClass: 'grid-cell-centered', + lockPosition: 'right', + + ...(!wrapActions ? { minWidth: 130 * actions?.length } : {}) + }) + } + + return r.map((fields) => { + let cr = defaultCellRenderer(fields.cellRendererType) + return { + ...fields, + ...(cr ? { cellRenderer: cr } : {}) + } + }) +} + +export function validateColumnDefs(columnDefs: ColumnDef[]): { + isValid: boolean + errors: string[] +} { + let isValid = true + const errors: string[] = [] + + if (!Array.isArray(columnDefs)) { + return { isValid: false, errors: ['Column definitions must be an array.'] } + } + + // Validate each column definition + columnDefs.forEach((colDef, index) => { + let noField = !colDef.field || typeof colDef.field !== 'string' || colDef.field.trim() === '' + + if ( + (colDef.isidentity === ColumnIdentity.ByDefault || + colDef.isidentity === ColumnIdentity.Always) && + colDef.hideInsert == undefined + ) { + colDef.hideInsert = true + } + + // Check if 'field' property exists and is a non-empty string + if (noField && !(colDef.children && Array.isArray(colDef.children))) { + isValid = false + errors.push( + `Column at index ${index} is missing a valid 'field' property nor having any children.` + ) + } + + if (colDef.children && Array.isArray(colDef.children)) { + const { isValid: isChildrenValid, errors: childrenErrors } = validateColumnDefs( + colDef.children + ) + if (!isChildrenValid) { + isValid = false + errors.push(...childrenErrors.map((err) => `Error in children at index ${index}: ${err}`)) + } + } + }) + + return { isValid, errors } +} diff --git a/frontend/src/lib/components/common/CloseButton.svelte b/frontend/src/lib/components/common/CloseButton.svelte index 7bed98a57d..fea765e3ef 100644 --- a/frontend/src/lib/components/common/CloseButton.svelte +++ b/frontend/src/lib/components/common/CloseButton.svelte @@ -6,12 +6,13 @@ export let noBg = false export let small: boolean = false + export let Icon: any | undefined = undefined const dispatch = createEventDispatcher()