From ca6311d8cd0ca48bf9a469176bb50c9701f3e0c5 Mon Sep 17 00:00:00 2001 From: Faton Ramadani Date: Tue, 5 Mar 2024 08:52:20 +0100 Subject: [PATCH] feat(frontend): Added support for mysql, mssql and snowflake in the Database Studio (#3250) * feat(frontend): Added support for mysql in the Database Studio * feat(frontend): generic db * feat(frontend): generic column def wip * fix(frontend): adapt queries * fix(frontend): adapt queries * fix(frontend): wip * fix(frontend): wip * feat(frontend): remove useless runnable calls * feat(frontend): MySQL + PSQL done, mssql wip * feat(frontend): clean up * feat(frontend): clean up * feat(frontend): mssql: select + count done * feat(frontend): mssql: update, insert and delete + fix policies * feat(frontend): typo * feat(frontend): fix build * feat(frontend): improve perf + UI * feat(frontend): add snowflake * feat(frontend): fix count * feat(frontend): debounce input * feat(frontend): fix deploy button * feat(frontend): remove ghost rows * feat(frontend): Fix search * feat(frontend): Fix search * feat(fontend): remove bigquery * feat(fontend): Fix performance issues + sorting * feat(fontend): Fix lastRow * feat(fontend): Fix build + search * feat(fontend): fix mysql metadata * feat(fontend): simplify code * feat(fontend): remove cache + fix the number of calls * feat(fontend): clean up * feat(fontend): clean up * feat(fontend): remove dead code * feat(fontend): fix infinite scroll * feat(fontend): fix count * feat(fontend): remove unnecessary clearRows * feat(fontend): add missing delete policy * feat(fontend): fix search * feat(fontend): fix search * feat(fontend): roll back infiniteInitialRowCount * feat(fontend): roll back infiniteInitialRowCount * feat(fontend): add bigquery (#3326) * feat(fontend): add bigquery --------- Co-authored-by: HugoCasa * feat(fontend): remove code duplication * feat(fontend): add mapping for postgres --------- Co-authored-by: HugoCasa --- frontend/src/lib/components/Dev.svelte | 6 +- .../display/dbtable/AppDbExplorer.svelte | 367 ++++++---- .../display/dbtable/DbExplorerCount.svelte | 15 +- .../display/dbtable/DeleteRow.svelte | 10 +- .../display/dbtable/InsertRow.svelte | 128 ++-- .../display/dbtable/InsertRowRunnable.svelte | 8 +- .../display/dbtable/UpdateCell.svelte | 15 +- .../display/dbtable/queries/count.ts | 185 +++++ .../display/dbtable/queries/delete.ts | 81 +++ .../display/dbtable/queries/insert.ts | 98 +++ .../display/dbtable/queries/select.ts | 272 ++++++++ .../display/dbtable/queries/update.ts | 98 +++ .../apps/components/display/dbtable/utils.ts | 660 +++++++----------- .../table/AppAggridExplorerTable.svelte | 30 +- .../apps/components/display/table/utils.ts | 70 ++ .../apps/editor/AppEditorHeader.svelte | 40 +- .../apps/editor/component/components.ts | 66 +- .../inputEditor/StaticInputEditor.svelte | 4 +- frontend/src/lib/components/apps/inputType.ts | 11 + frontend/src/lib/components/apps/types.ts | 22 +- .../components/common/button/Button.svelte | 2 +- frontend/src/lib/infer.ts | 2 +- frontend/src/routes/flows/dev/+page.svelte | 7 +- ....timestamp-1704794658407-1f2b8aca1d21a.mjs | 67 -- 24 files changed, 1550 insertions(+), 714 deletions(-) create mode 100644 frontend/src/lib/components/apps/components/display/dbtable/queries/count.ts create mode 100644 frontend/src/lib/components/apps/components/display/dbtable/queries/delete.ts create mode 100644 frontend/src/lib/components/apps/components/display/dbtable/queries/insert.ts create mode 100644 frontend/src/lib/components/apps/components/display/dbtable/queries/select.ts create mode 100644 frontend/src/lib/components/apps/components/display/dbtable/queries/update.ts create mode 100644 frontend/src/lib/components/apps/components/display/table/utils.ts delete mode 100644 frontend/vite.config.js.timestamp-1704794658407-1f2b8aca1d21a.mjs diff --git a/frontend/src/lib/components/Dev.svelte b/frontend/src/lib/components/Dev.svelte index c02fef0756..bad2532823 100644 --- a/frontend/src/lib/components/Dev.svelte +++ b/frontend/src/lib/components/Dev.svelte @@ -41,6 +41,7 @@ import { workspacedOpenai } from './copilot/lib' import type { FlowCopilotContext, FlowCopilotModule } from './copilot/flow' import { pickScript } from './flows/flowStateUtils' + import type { Schedule } from './flows/scheduleUtils' $: token = $page.url.searchParams.get('wm_token') ?? undefined $: workspace = $page.url.searchParams.get('workspace') ?? undefined @@ -346,11 +347,12 @@ } const flowStateStore = writable({} as FlowState) - const scheduleStore = writable({ + const scheduleStore = writable({ args: {}, cron: '', timezone: Intl.DateTimeFormat().resolvedOptions().timeZone, - enabled: false + enabled: false, + summary: undefined }) const previewArgsStore = writable>({}) const scriptEditorDrawer = writable(undefined) 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 01a1dbda3c..d55f9ba490 100644 --- a/frontend/src/lib/components/apps/components/display/dbtable/AppDbExplorer.svelte +++ b/frontend/src/lib/components/apps/components/display/dbtable/AppDbExplorer.svelte @@ -3,22 +3,23 @@ AppEditorContext, AppViewerContext, ComponentCustomCSS, + OneOfConfiguration, RichConfigurations } from '../../../types' import { components } from '$lib/components/apps/editor/component' import ResolveConfig from '../../helpers/ResolveConfig.svelte' import { findGridItem, initConfig, initOutput } from '$lib/components/apps/editor/appUtils' import { - createPostgresInput, getDbSchemas, loadTableMetaData, - type ColumnMetadata, type TableMetadata, - getPrimaryKeys + getPrimaryKeys, + type ColumnDef, + type DbType } from './utils' import { getContext, tick } from 'svelte' import UpdateCell from './UpdateCell.svelte' - import { workspaceStore, type DBSchemas } from '$lib/stores' + import { workspaceStore, type DBSchemas, type DBSchema } from '$lib/stores' import Button from '$lib/components/common/button/Button.svelte' import { Plus } from 'lucide-svelte' import { Drawer, DrawerContent } from '$lib/components/common' @@ -34,6 +35,8 @@ import InsertRowRunnable from './InsertRowRunnable.svelte' import DeleteRow from './DeleteRow.svelte' import InitializeComponent from '../../helpers/InitializeComponent.svelte' + import { getSelectInput } from './queries/select' + import DebouncedInput from '../../helpers/DebouncedInput.svelte' export let id: string export let configuration: RichConfigurations @@ -46,6 +49,32 @@ configuration ) + $: computeInput( + resolvedConfig.columnDefs, + resolvedConfig.whereClause, + resolvedConfig.type.configuration[resolvedConfig.type.selected].resource + ) + + let timeoutInput: NodeJS.Timeout | undefined = undefined + + function computeInput(columnDefs: any, whereClause: string | undefined, resource: any) { + if (timeoutInput) { + clearTimeout(timeoutInput) + } + timeoutInput = setTimeout(() => { + timeoutInput = undefined + console.log('compute input') + + input = getSelectInput( + resource, + resolvedConfig.type.configuration[resolvedConfig.type.selected].table, + columnDefs, + whereClause, + resolvedConfig.type.selected as DbType + ) + }, 1000) + } + const { app, worldStore, mode, selectedComponent } = getContext('AppViewerContext') const editorContext = getContext('AppEditorContext') @@ -54,39 +83,15 @@ let quicksearch = '' let aggrid: AppAggridExplorerTable - $: computeInput( - resolvedConfig.columnDefs, - resolvedConfig.whereClause, - resolvedConfig.type.configuration.postgresql.resource - ) - - let timeoutInput: NodeJS.Timeout | undefined = undefined - function computeInput(columnDefs: any, whereClause: string | undefined, resource: any) { - if (timeoutInput) { - clearTimeout(timeoutInput) - } - timeoutInput = setTimeout(() => { - timeoutInput = undefined - console.log('compute input') - aggrid?.clearRows() - input = createPostgresInput( - resource, - resolvedConfig.type.configuration.postgresql.table, - columnDefs, - whereClause - ) - }, 1000) - } - $: editorContext != undefined && $mode == 'dnd' && resolvedConfig.type && listTableIfAvailable() $: editorContext != undefined && $mode == 'dnd' && - resolvedConfig.type.configuration?.postgresql?.table && + resolvedConfig.type.configuration?.[resolvedConfig?.type?.selected]?.table && listColumnsIfAvailable() let firstQuicksearch = true - $: if (quicksearch) { + $: if (quicksearch !== undefined) { if (firstQuicksearch) { firstQuicksearch = false } else { @@ -106,7 +111,7 @@ function onUpdate( e: CustomEvent<{ row: number - columnDef: ColumnMetadata + columnDef: ColumnDef column: string value: any data: any @@ -116,13 +121,14 @@ const { columnDef, value, data, oldValue } = e.detail updateCell?.triggerUpdate( - resolvedConfig.type.configuration.postgresql.resource, - resolvedConfig.type.configuration.postgresql.table ?? 'unknown', + resolvedConfig.type.configuration[resolvedConfig.type.selected].resource, + resolvedConfig.type.configuration[resolvedConfig.type.selected].table ?? 'unknown', columnDef, resolvedConfig.columnDefs, value, data, - oldValue + oldValue, + resolvedConfig.type.selected as DbType ) } @@ -140,8 +146,30 @@ }) let lastResource: string | undefined = undefined + + function updateOneOfConfiguration( + oneOfConfiguration: OneOfConfiguration, + resolvedConfig: { + configuration: Record + selected: U + }, + patch: Partial> + ) { + const selectedConfig = oneOfConfiguration.configuration[resolvedConfig.selected] + if (!selectedConfig) { + console.warn(`Selected configuration '${resolvedConfig.selected}' does not exist.`) + return + } + Object.keys(patch).forEach((key) => { + oneOfConfiguration.configuration[resolvedConfig.selected][key] = { + ...oneOfConfiguration.configuration[resolvedConfig.selected][key], + ...patch[key] + } + }) + } + async function listTableIfAvailable() { - let resource = resolvedConfig.type.configuration?.postgresql?.resource + let resource = resolvedConfig.type.configuration?.[resolvedConfig.type.selected]?.resource if (lastResource === resource) return lastResource = resource const gridItem = findGridItem($app, id) @@ -150,105 +178,143 @@ return } - if ( - 'configuration' in gridItem.data?.configuration?.type && - 'selectOptions' in gridItem.data?.configuration?.type?.configuration?.postgresql?.table - ) { - gridItem.data.configuration.type.configuration.postgresql.table.selectOptions = [] - } + updateOneOfConfiguration( + gridItem.data.configuration.type as OneOfConfiguration, + resolvedConfig.type, + { + table: { + selectOptions: [], + loading: true + } + } + ) - if (!resolvedConfig.type?.configuration?.postgresql?.resource) { + if (!resolvedConfig.type?.configuration?.[resolvedConfig.type.selected]?.resource) { $app = { ...$app } return } - if ( - 'configuration' in gridItem.data?.configuration?.type && - gridItem.data.configuration.type.configuration.postgresql.table - ) { - gridItem.data.configuration.type.configuration.postgresql.table.loading = true - } - try { const dbSchemas: DBSchemas = {} await getDbSchemas( - 'postgresql', - resolvedConfig.type.configuration.postgresql.resource.split(':')[1], + resolvedConfig?.type?.selected, + resolvedConfig.type.configuration[resolvedConfig?.type?.selected].resource.split(':')[1], $workspaceStore, dbSchemas, (message: string) => {} ) - if ('configuration' in gridItem.data.configuration.type) { - gridItem.data.configuration.type.configuration.postgresql.table['selectOptions'] = dbSchemas - ? // @ts-ignore - Object.keys(Object.values(dbSchemas)?.[0]?.schema?.public ?? {}) - : [] - } + updateOneOfConfiguration( + gridItem.data.configuration.type as OneOfConfiguration, + resolvedConfig.type, + { + table: { + selectOptions: dbSchemas ? getTablesByResource(dbSchemas) : [], + loading: false + } + } + ) $app = { ...$app } } catch (e) {} - if ( - 'configuration' in gridItem.data?.configuration?.type && - gridItem.data.configuration.type.configuration.postgresql.table - ) { - gridItem.data.configuration.type.configuration.postgresql.table.loading = false + } + + function getTablesByResource(schema: Partial>) { + const s = Object.values(schema)?.[0] + switch (resolvedConfig.type.selected) { + case 'postgresql': + if (s?.lang === 'postgresql') { + return Object.keys(s.schema?.public ?? s.schema ?? {}) + } + case 'mysql': + return Object.keys(Object.values(s?.schema ?? {})?.[0]) + case 'ms_sql_server': + return Object.keys(Object.values(s?.schema ?? {})?.[0]) + case 'snowflake': { + return Object.keys(Object.values(s?.schema ?? {})?.[0]) + } + + case 'bigquery': { + const paths: string[] = [] + for (const key in s?.schema) { + if (s?.schema.hasOwnProperty(key)) { + const subObj = s?.schema[key] + for (const subKey in subObj) { + if (subObj.hasOwnProperty(subKey)) { + paths.push(`${key}.${subKey}`) + } + } + } + } + + return paths + } + + default: + return [] } } let datasource: IDatasource = { rowCount: 0, getRows: async function (params) { - let uuid = await runnableComponent?.runComponent( - undefined, - undefined, - undefined, - { - offset: params.startRow, - limit: params.endRow - params.startRow, - quicksearch, - orderBy: params.sortModel?.[0]?.colId ?? resolvedConfig.columnDefs?.[0]?.field, - is_desc: params.sortModel?.[0]?.sort === 'desc' - }, - { - done: (x) => { - let lastRow = -1 - if (datasource.rowCount && datasource.rowCount <= params.endRow) { - lastRow = datasource.rowCount + const currentParams = { + offset: params.startRow, + limit: params.endRow - params.startRow, + quicksearch, + order_by: params.sortModel?.[0]?.colId ?? resolvedConfig.columnDefs?.[0]?.field, + is_desc: params.sortModel?.[0]?.sort === 'desc' + } + + if (!render) { + return + } + + runnableComponent?.runComponent(undefined, undefined, undefined, currentParams, { + done: (items) => { + let lastRow = -1 + + if (datasource?.rowCount && datasource.rowCount <= params.endRow) { + lastRow = datasource.rowCount + } + + if (items && Array.isArray(items)) { + // MsSql response have an outer array, we need to flatten it + if (resolvedConfig.type.selected === 'ms_sql_server') { + items = items?.[0] } - if (x && Array.isArray(x)) { - params.successCallback( - x.map((x) => { - let primaryKeys = getPrimaryKeys(resolvedConfig.columnDefs) - let o = {} - primaryKeys.forEach((pk) => { - o[pk] = x[pk] - }) - x['__index'] = JSON.stringify(o) - return x - }), - lastRow - ) - } else { - params.failCallback() + + let processedData = items.map((item) => { + let primaryKeys = getPrimaryKeys(resolvedConfig.columnDefs) + let o = {} + primaryKeys.forEach((pk) => { + o[pk] = item[pk] + }) + item['__index'] = JSON.stringify(o) + return item + }) + + if (items.length < params.endRow - params.startRow) { + lastRow = params.startRow + items.length } - }, - cancel: () => { - console.log('cancel datasource request') - params.failCallback() - }, - error: () => { - console.log('error datasource request') + + params.successCallback(processedData, lastRow) + } else { params.failCallback() } + }, + cancel: () => { + params.failCallback() + }, + error: () => { + params.failCallback() } - ) - console.log('asking for ' + params.startRow + ' to ' + params.endRow, uuid) + }) } } @@ -256,15 +322,19 @@ let timeout: NodeJS.Timeout | undefined = undefined async function listColumnsIfAvailable() { - let table = resolvedConfig.type.configuration?.postgresql?.table - if (lastTable === table) return - lastTable = table + const selected = resolvedConfig.type.selected + let table = resolvedConfig.type.configuration?.[resolvedConfig.type.selected]?.table + if (lastTable === table) return + + lastTable = table let tableMetadata = await loadTableMetaData( - resolvedConfig.type.configuration.postgresql.resource, + resolvedConfig.type.configuration[selected].resource, $workspaceStore, - resolvedConfig.type.configuration.postgresql.table + resolvedConfig.type.configuration[selected].table, + selected ) + if (!tableMetadata) return const gridItem = findGridItem($app, id) @@ -281,6 +351,11 @@ const oldMap = Object.fromEntries(old.filter((x) => x != undefined).map((x) => [x.field, x])) const newMap = Object.fromEntries(tableMetadata?.map((x) => [x.field, x]) ?? []) + // if they are the same, do nothing + if (JSON.stringify(oldMap) === JSON.stringify(newMap)) { + return + } + let ncols: any[] = [] Object.entries(oldMap).forEach(([key, value]) => { if (newMap[key]) { @@ -296,6 +371,15 @@ } }) + // Mysql capitalizes the column names, so we make sure to lowercase them + ncols = ncols.map((x) => { + let o = {} + Object.keys(x).forEach((k) => { + o[k.toLowerCase()] = x[k] + }) + return o + }) + state = undefined //@ts-ignore @@ -321,14 +405,29 @@ $: $worldStore && connectToComponents() function connectToComponents() { - if ($worldStore) { + if ($worldStore && datasource !== undefined) { const outputs = $worldStore.outputsById[`${id}_count`] + if (outputs) { outputs.result.subscribe( { id: 'dbexplorer-count-' + id, next: (value) => { - datasource.rowCount = value?.[0]?.count + // MsSql response have an outer array, we need to flatten it + if ( + Array.isArray(value) && + value.length === 1 && + resolvedConfig.type.selected === 'ms_sql_server' + ) { + // @ts-ignore + datasource.rowCount = value?.[0]?.[0]?.count + } else if (resolvedConfig.type.selected === 'snowflake') { + // @ts-ignore + datasource.rowCount = value?.[0]?.COUNT + } else { + // @ts-ignore + datasource.rowCount = value?.[0]?.count + } } }, datasource.rowCount @@ -339,12 +438,14 @@ async function insert() { try { + const selected = resolvedConfig.type.selected await insertRowRunnable?.insertRow( - resolvedConfig.type.configuration.postgresql.resource, + resolvedConfig.type.configuration[selected].resource, $workspaceStore, - resolvedConfig.type.configuration.postgresql.table, + resolvedConfig.type.configuration[selected].table, resolvedConfig.columnDefs, - args + args, + selected ) insertDrawer?.closeDrawer() @@ -368,11 +469,13 @@ let getPrimaryKeysresolvedConfig = resolvedConfig.columnDefs?.filter((x) => primaryColumns.includes(x.field) ) + const selected = resolvedConfig.type.selected deleteRow?.triggerDelete( - resolvedConfig.type.configuration.postgresql.resource, - resolvedConfig.type.configuration.postgresql.table ?? 'unknown', + resolvedConfig.type.configuration[selected].resource, + resolvedConfig.type.configuration[selected].table ?? 'unknown', getPrimaryKeysresolvedConfig, - data + data, + selected ) } @@ -408,13 +511,18 @@ /> {/if} - +{#if render} + +{/if} @@ -430,9 +538,7 @@ >
-
- {#if resolvedConfig.type.configuration?.postgresql?.resource && resolvedConfig.type.configuration?.postgresql?.table} + {#if resolvedConfig.type.configuration?.[resolvedConfig?.type?.selected]?.resource && resolvedConfig.type.configuration?.[resolvedConfig?.type?.selected]?.table} - {#key renderCount} + {#key renderCount && render} Insert - + diff --git a/frontend/src/lib/components/apps/components/display/dbtable/DbExplorerCount.svelte b/frontend/src/lib/components/apps/components/display/dbtable/DbExplorerCount.svelte index 81d4347db2..2547cb3820 100644 --- a/frontend/src/lib/components/apps/components/display/dbtable/DbExplorerCount.svelte +++ b/frontend/src/lib/components/apps/components/display/dbtable/DbExplorerCount.svelte @@ -5,13 +5,17 @@ import type RunnableComponent from '../../helpers/RunnableComponent.svelte' import RunnableWrapper from '../../helpers/RunnableWrapper.svelte' import { initOutput } from '../../../editor/appUtils' - import { getCountPostgresql } from './utils' + import { type ColumnDef, type DbType } from './utils' + import { getCountInput } from './queries/count' export let id: string export let table: string export let resource: string export let renderCount: number export let quicksearch: string + export let resourceType: string + export let columnDefs: ColumnDef[] + export let whereClause: string | undefined const { worldStore } = getContext('AppViewerContext') @@ -23,14 +27,13 @@ let runnableComponent: RunnableComponent let loading = false - let input: AppInput | undefined = undefined - - $: table && renderCount != undefined && quicksearch != undefined && computeCount() - let lastTableCount = '' let renderCountLast = -1 let quicksearchLast: string | undefined = undefined + + $: table && renderCount != undefined && quicksearch != undefined && computeCount() + async function computeCount() { if ( lastTableCount === table && @@ -47,7 +50,7 @@ } async function getCount(resource: string, table: string, quicksearch: string) { - input = getCountPostgresql(resource, table) + input = getCountInput(resource, table, resourceType as DbType, columnDefs, whereClause) await tick() diff --git a/frontend/src/lib/components/apps/components/display/dbtable/DeleteRow.svelte b/frontend/src/lib/components/apps/components/display/dbtable/DeleteRow.svelte index 9edaf4e906..66110f22c2 100644 --- a/frontend/src/lib/components/apps/components/display/dbtable/DeleteRow.svelte +++ b/frontend/src/lib/components/apps/components/display/dbtable/DeleteRow.svelte @@ -5,8 +5,9 @@ import type RunnableComponent from '../../helpers/RunnableComponent.svelte' import RunnableWrapper from '../../helpers/RunnableWrapper.svelte' import { initOutput } from '../../../editor/appUtils' - import { type ColumnMetadata, createDeletePostgresInput } from './utils' + import { type ColumnDef, type DbType } from './utils' import { sendUserToast } from '$lib/toast' + import { getDeleteInput } from './queries/delete' export let id: string @@ -28,12 +29,13 @@ export async function triggerDelete( resource: string, table: string, - columns: ColumnMetadata[], - data: Record + columns: ColumnDef[], + data: Record, + dbType: DbType ) { // const datatype = tableMetaData?.find((column) => column.isprimarykey)?.datatype - input = createDeletePostgresInput(resource, table, columns) + input = getDeleteInput(resource, table, columns, dbType) await tick() diff --git a/frontend/src/lib/components/apps/components/display/dbtable/InsertRow.svelte b/frontend/src/lib/components/apps/components/display/dbtable/InsertRow.svelte index 2e9e7c9cfe..a3a21c3aa8 100644 --- a/frontend/src/lib/components/apps/components/display/dbtable/InsertRow.svelte +++ b/frontend/src/lib/components/apps/components/display/dbtable/InsertRow.svelte @@ -1,19 +1,33 @@ - +{#if schema} + +{/if} diff --git a/frontend/src/lib/components/apps/components/display/dbtable/InsertRowRunnable.svelte b/frontend/src/lib/components/apps/components/display/dbtable/InsertRowRunnable.svelte index e0fb9043d4..953206f121 100644 --- a/frontend/src/lib/components/apps/components/display/dbtable/InsertRowRunnable.svelte +++ b/frontend/src/lib/components/apps/components/display/dbtable/InsertRowRunnable.svelte @@ -5,8 +5,9 @@ import type RunnableComponent from '../../helpers/RunnableComponent.svelte' import RunnableWrapper from '../../helpers/RunnableWrapper.svelte' import { initOutput } from '../../../editor/appUtils' - import { type ColumnDef, createPostgresInsert } from './utils' + import { type ColumnDef, type DbType } from './utils' import { sendUserToast } from '$lib/toast' + import { getInsertInput } from './queries/insert' export let id: string @@ -29,13 +30,14 @@ workspace: string | undefined, table: string | undefined, columns: ColumnDef[], - values: Record + values: Record, + resourceType: string ): Promise { if (!resource || !table || !workspace) { return false } - input = createPostgresInsert(table, columns, resource) + input = getInsertInput(table, columns, resource, resourceType as DbType) await tick() diff --git a/frontend/src/lib/components/apps/components/display/dbtable/UpdateCell.svelte b/frontend/src/lib/components/apps/components/display/dbtable/UpdateCell.svelte index c13939a7e9..2052611876 100644 --- a/frontend/src/lib/components/apps/components/display/dbtable/UpdateCell.svelte +++ b/frontend/src/lib/components/apps/components/display/dbtable/UpdateCell.svelte @@ -5,8 +5,9 @@ import type RunnableComponent from '../../helpers/RunnableComponent.svelte' import RunnableWrapper from '../../helpers/RunnableWrapper.svelte' import { initOutput } from '../../../editor/appUtils' - import { createUpdatePostgresInput, type ColumnMetadata, getPrimaryKeys } from './utils' + import { getPrimaryKeys, type ColumnDef, type DbType } from './utils' import { sendUserToast } from '$lib/toast' + import { getUpdateInput } from './queries/update' export let id: string @@ -20,24 +21,24 @@ let runnableComponent: RunnableComponent let loading = false - let input: AppInput | undefined = undefined export async function triggerUpdate( resource: string, table: string, - column: ColumnMetadata, - allColumns: ColumnMetadata[], + column: ColumnDef, + allColumns: ColumnDef[], valueToUpdate: string, data: Record, - oldValue: string | undefined = undefined + oldValue: string | undefined = undefined, + dbType: DbType ) { // const datatype = tableMetaData?.find((column) => column.isprimarykey)?.datatype let primaryColumns = getPrimaryKeys(allColumns) let columns = allColumns?.filter((x) => primaryColumns.includes(x.field)) - input = createUpdatePostgresInput(resource, table, column, columns) + input = getUpdateInput(resource, table, column, columns, dbType) await tick() @@ -51,7 +52,7 @@ undefined, undefined, undefined, - { valueToUpdate, ...ndata }, + { value_to_update: valueToUpdate, ...ndata }, { done: (x) => { sendUserToast('Value updated', false) 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 new file mode 100644 index 0000000000..6dd4f8469e --- /dev/null +++ b/frontend/src/lib/components/apps/components/display/dbtable/queries/count.ts @@ -0,0 +1,185 @@ +import type { AppInput, RunnableByName } from '$lib/components/apps/inputType' +import { Preview } from '$lib/gen' +import { buildParameters, type DbType } from '../utils' +import { getLanguageByResourceType, type ColumnDef, buildVisibleFieldList } from '../utils' + +function makeCountQuery( + dbType: DbType, + table: string, + whereClause: string | undefined = undefined, + columnDefs: ColumnDef[] +): string { + const wherePrefix = ' WHERE ' + const andCondition = ' AND ' + let quicksearchCondition = '' + let query = buildParameters( + [{ field: 'quicksearch', datatype: dbType === 'bigquery' ? 'string' : 'text' }], + dbType + ) + + query += '\n' + + if (whereClause) { + quicksearchCondition = ` ${whereClause} AND ` + } + + const filteredColumns = buildVisibleFieldList(columnDefs, dbType) + + switch (dbType) { + case 'mysql': + if (filteredColumns.length > 0) { + quicksearchCondition += ` (:quicksearch = '' OR CONCAT_WS(' ', ${filteredColumns.join( + ', ' + )}) LIKE CONCAT('%', :quicksearch, '%'))` + } else { + quicksearchCondition += ` (:quicksearch = '' OR 1 = 1)` + } + query += `SELECT COUNT(*) as count FROM \`${table}\`` + break + case 'postgresql': + if (filteredColumns.length > 0) { + quicksearchCondition += `($1 = '' OR CONCAT(${filteredColumns.join( + ', ' + )}) ILIKE '%' || $1 || '%')` + } else { + quicksearchCondition += `($1 = '' OR 1 = 1)` + } + query += `SELECT COUNT(*) as count FROM "${table}"` + break + case 'ms_sql_server': + if (filteredColumns.length > 0) { + quicksearchCondition += `(@p1 = '' OR CONCAT(${filteredColumns.join( + ', +' + )}) LIKE '%' + @p1 + '%')` + } else { + quicksearchCondition += `(@p1 = '' OR 1 = 1)` + } + query += `SELECT COUNT(*) as count FROM [${table}]` + break + case 'snowflake': { + query = '' + + if (filteredColumns.length > 0) { + query += buildParameters( + [ + { field: 'quicksearch', datatype: 'text' }, + { field: 'quicksearch', datatype: 'text' } + ], + dbType + ) + + query += '\n' + + quicksearchCondition += `(? = '' OR CONCAT(${filteredColumns.join( + ', ' + )}) ILIKE '%' || ? || '%')` + } else { + query += buildParameters([{ field: 'quicksearch', datatype: 'text' }], dbType) + + query += '\n' + + quicksearchCondition += `(? = '' OR 1 = 1)` + } + + query += `SELECT COUNT(*) as count FROM ${table}` + break + } + case 'bigquery': { + if (filteredColumns.length > 0) { + const searchClause = filteredColumns + .map((col) => { + const def = columnDefs.find((c) => c.field === col.slice(1, -1)) + if ( + def?.datatype === 'JSON' || + def?.datatype.startsWith('STRUCT') || + def?.datatype.startsWith('ARRAY') + ) { + return `TO_JSON_STRING(${col})` + } + return `${col}` + }) + .join(',') + quicksearchCondition += `(@quicksearch = '' OR REGEXP_CONTAINS(CONCAT(${searchClause}), '(?i)' || @quicksearch))` + } else { + quicksearchCondition += `(@quicksearch = '' OR 1 = 1)` + } + query += `SELECT COUNT(*) as count FROM \`${table}\`` + break + } + + default: + throw new Error('Unsupported database type:' + dbType) + } + + if (whereClause) { + query += `${wherePrefix}${quicksearchCondition}` + } else { + query += dbType === 'ms_sql_server' && !whereClause ? wherePrefix : andCondition + query += quicksearchCondition + } + + if ( + !whereClause && + (dbType === Preview.language.MYSQL || + dbType === 'postgresql' || + dbType === 'snowflake' || + dbType === 'bigquery') + ) { + query = query.replace(`${andCondition}`, wherePrefix) + } + + return query +} + +export function getCountInput( + resource: string, + table: string, + resourceType: DbType, + columnDefs: ColumnDef[], + whereClause: string | undefined +): AppInput | undefined { + if (!resource || !table || !columnDefs) { + // Return undefined if resource or table is not defined + + return undefined + } + + const query = makeCountQuery(resourceType, table, whereClause, columnDefs) + + const updateRunnable: RunnableByName = { + name: 'AppDbExplorer', + type: 'runnableByName', + inlineScript: { + content: query, + language: getLanguageByResourceType(resourceType), + schema: { + $schema: 'https://json-schema.org/draft/2020-12/schema', + properties: { + database: { + description: 'Database name', + type: 'object', + format: `resource-${resourceType}` + } + }, + required: ['database'], + type: 'object' + } + } + } + + const updateQuery: AppInput = { + runnable: updateRunnable, + fields: { + database: { + type: 'static', + value: resource, + fieldType: 'object', + format: `resource-${resourceType}` + } + }, + type: 'runnable', + fieldType: 'object' + } + + return updateQuery +} 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 new file mode 100644 index 0000000000..92311acd5b --- /dev/null +++ b/frontend/src/lib/components/apps/components/display/dbtable/queries/delete.ts @@ -0,0 +1,81 @@ +import type { AppInput, RunnableByName } from '$lib/components/apps/inputType' +import { getLanguageByResourceType, type ColumnDef, buildParameters, type DbType } from '../utils' + +function updateWithAllValues(table: string, columns: ColumnDef[], dbType: DbType) { + let query = buildParameters(columns, dbType) + + switch (dbType) { + case 'postgresql': { + const conditions = columns + .map((c, i) => `${c.field} = $${i + 1}::text::${c.datatype} `) + .join(' AND ') + + query += `\nDELETE FROM ${table} WHERE ${conditions} RETURNING 1;` + return query + } + case 'mysql': { + const conditions = columns.map((c) => `${c.field} = :${c.field}`).join(' AND ') + query += `\nDELETE FROM ${table} WHERE ${conditions}` + return query + } + case 'ms_sql_server': { + const conditions = columns.map((c, i) => `${c.field} = @p${i + 1} `).join(' AND ') + query += `\nDELETE FROM ${table} WHERE ${conditions}` + return query + } + case 'snowflake': { + const conditions = columns.map((c, i) => `${c.field} = ? `).join(' AND ') + query += `\nDELETE FROM ${table} WHERE ${conditions}` + return query + } + case 'bigquery': { + const conditions = columns.map((c, i) => `${c.field} = @${c.field}`).join(' AND ') + query += `\nDELETE FROM ${table} WHERE ${conditions}` + return query + } + default: + throw new Error('Unsupported database type') + } +} + +export function getDeleteInput( + resource: string, + table: string, + columns: ColumnDef[], + dbType: DbType +): AppInput | undefined { + if (!resource || !table) { + return undefined + } + + const updateRunnable: RunnableByName = { + name: 'AppDbExplorer', + type: 'runnableByName', + inlineScript: { + content: updateWithAllValues(table, columns, dbType), + language: getLanguageByResourceType(dbType), + schema: { + $schema: 'https://json-schema.org/draft/2020-12/schema', + properties: {}, + required: ['database'], + type: 'object' + } + } + } + + const updateQuery: AppInput = { + runnable: updateRunnable, + fields: { + database: { + type: 'static', + value: resource, + fieldType: 'object', + format: `resource-${dbType}` + } + }, + type: 'runnable', + fieldType: 'object' + } + + return updateQuery +} diff --git a/frontend/src/lib/components/apps/components/display/dbtable/queries/insert.ts b/frontend/src/lib/components/apps/components/display/dbtable/queries/insert.ts new file mode 100644 index 0000000000..f7ddecb7a2 --- /dev/null +++ b/frontend/src/lib/components/apps/components/display/dbtable/queries/insert.ts @@ -0,0 +1,98 @@ +import type { AppInput } from '$lib/components/apps/inputType' +import { buildParameters, type DbType } from '../utils' +import { getLanguageByResourceType, type ColumnDef } from '../utils' + +function formatInsertValues(columns: ColumnDef[], dbType: DbType, startIndex: number = 1): string { + switch (dbType) { + case 'mysql': + return columns.map((c) => `:${c.field}`).join(', ') + case 'postgresql': + return columns.map((c, i) => `$${startIndex + i}::${c.datatype}`).join(', ') + case 'ms_sql_server': + return columns.map((c, i) => `@p${startIndex + i}`).join(', ') + case 'snowflake': + return columns.map(() => `?`).join(', ') + case 'bigquery': + return columns.map((c) => `@${c.field}`).join(', ') + default: + throw new Error('Unsupported database type') + } +} + +function formatColumnNames(columns: ColumnDef[]): string { + return columns.map((c) => c.field).join(', ') +} + +function formatDefaultValues(columns: ColumnDef[]): string { + const defaultValues = columns + .map((c) => { + if (c.defaultValueNull) { + return 'NULL' + } else { + return typeof c.defaultUserValue === 'string' + ? `'${c.defaultUserValue}'` + : c.defaultUserValue + } + }) + .join(', ') + + return defaultValues +} + +export function makeInsertQuery(table: string, columns: ColumnDef[], dbType: DbType) { + if (!table) throw new Error('Table name is required') + + const columnsInsert = columns.filter((x) => !x.hideInsert) + const columnsDefault = columns.filter( + (x) => x.hideInsert && (x.overrideDefaultValue || x.defaultvalue === null) + ) + + const allInsertColumns = columnsInsert.concat(columnsDefault) + let query = buildParameters(columnsInsert, dbType) + + query += '\n' + + const shouldInsertComma = columnsDefault.length > 0 && columnsInsert.length > 0 + const columnNames = formatColumnNames(allInsertColumns) + const insertValues = formatInsertValues(columnsInsert, dbType) + const defaultValues = formatDefaultValues(columnsDefault) + const commaOrEmpty = shouldInsertComma ? ', ' : '' + + query += `INSERT INTO ${table} (${columnNames}) VALUES (${insertValues}${commaOrEmpty}${defaultValues})` + + return query +} + +export function getInsertInput( + table: string, + columns: ColumnDef[], + resource: string, + dbType: DbType +): AppInput { + return { + runnable: { + name: 'AppDbExplorer', + type: 'runnableByName', + inlineScript: { + content: makeInsertQuery(table, columns, dbType), + language: getLanguageByResourceType(dbType), + schema: { + $schema: 'https://json-schema.org/draft/2020-12/schema', + properties: {}, + required: ['database'], + type: 'object' + } + } + }, + fields: { + database: { + type: 'static', + value: resource, + fieldType: 'object', + format: `resource-${dbType}` + } + }, + type: 'runnable', + fieldType: 'object' + } +} 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 new file mode 100644 index 0000000000..6619bd5bf4 --- /dev/null +++ b/frontend/src/lib/components/apps/components/display/dbtable/queries/select.ts @@ -0,0 +1,272 @@ +import type { AppInput, RunnableByName } from '$lib/components/apps/inputType' +import { buildParameters, type DbType } from '../utils' +import { getLanguageByResourceType, type ColumnDef, buildVisibleFieldList } from '../utils' + +function makeSnowflakeSelectQuery( + table: string, + columnDefs: ColumnDef[], + whereClause: string | undefined, + options?: { limit?: number; offset?: number } +) { + const limit = coerceToNumber(options?.limit) || 100 + const offset = coerceToNumber(options?.offset) || 0 + + const headers: Array<{ + field: string + datatype: string + }> = [ + { + field: 'quicksearch', + datatype: 'text' + } + ] + + let query = '' + + query += '\n' + + const filteredColumns = buildVisibleFieldList(columnDefs, 'snowflake') + const selectClause = filteredColumns.join(', ') + + query += `SELECT ${selectClause} FROM "${table}"` + + const quicksearchCondition = [ + 'LENGTH(?) = 0', + ...filteredColumns.map((column) => { + headers.push({ + field: 'quicksearch', + datatype: 'text' + }) + + return `CONCAT(${column}) ILIKE CONCAT('%', ?, '%')` + }) + ].join(' OR ') + + if (whereClause) { + query += ` WHERE ${whereClause} AND (${quicksearchCondition})` + } else { + query += ` WHERE ${quicksearchCondition}` + } + + const orderBy = columnDefs.map((column) => { + headers.push( + { + field: 'order_by', + datatype: 'text' + }, + { + field: 'is_desc', + datatype: 'boolean' + }, + { + field: 'order_by', + datatype: 'text' + }, + { + field: 'is_desc', + datatype: 'boolean' + } + ) + + return `CASE WHEN ? = '${column.field}' AND ? = FALSE THEN "${column.field}" END ASC, + CASE WHEN ? = '${column.field}' AND ? = TRUE THEN "${column.field}" END DESC` + }) + + query += ` ORDER BY ${orderBy.join(',\n')}` + query += ` LIMIT ${limit} OFFSET ${offset}` + query = buildParameters(headers, 'snowflake') + '\n' + query + + return query +} + +function makeSelectQuery( + table: string, + columnDefs: ColumnDef[], + whereClause: string | undefined, + dbType: DbType, + options?: { limit?: number; offset?: number } +) { + if (!table) throw new Error('Table name is required') + let quicksearchCondition = '' + + let query = buildParameters( + [ + { field: 'limit', datatype: dbType === 'bigquery' ? 'integer' : 'int' }, + { field: 'offset', datatype: dbType === 'bigquery' ? 'integer' : 'int' }, + { field: 'quicksearch', datatype: dbType === 'bigquery' ? 'string' : 'text' }, + { field: 'order_by', datatype: 'bigquery' ? 'string' : 'text' }, + { field: 'is_desc', datatype: 'bigquery' ? 'bool' : 'boolean' } + ], + dbType + ) + + query += '\n' + + const filteredColumns = buildVisibleFieldList(columnDefs, dbType) + const selectClause = filteredColumns.join(', ') + + console.log(table) + + switch (dbType) { + case 'mysql': { + const orderBy = columnDefs + .map((column) => { + return ` +CASE WHEN :order_by = '${column.field}' AND :is_desc IS false THEN \`${column.field}\` END, +CASE WHEN :order_by = '${column.field}' AND :is_desc IS true THEN \`${column.field}\` END DESC` + }) + .join(',\n') + + quicksearchCondition = ` (:quicksearch = '' OR CONCAT_WS(' ', ${filteredColumns.join( + ', ' + )}) LIKE CONCAT('%', :quicksearch, '%'))` + + query += `SELECT ${selectClause} FROM \`${table}\`` + query += ` WHERE ${whereClause ? `${whereClause} AND` : ''} ${quicksearchCondition}` + query += ` ORDER BY ${orderBy}` + query += ` LIMIT :limit OFFSET :offset` + + break + } + + case 'postgresql': { + const orderBy = ` + ${columnDefs + .map( + (column) => + ` + (CASE WHEN $4 = '${column.field}' AND $5 IS false THEN "${column.field}"::text END), + (CASE WHEN $4 = '${column.field}' AND $5 IS true THEN "${column.field}"::text END) DESC` + ) + .join(',\n')}` + + quicksearchCondition = ` ($3 = '' OR "${table}"::text ILIKE '%' || $3 || '%')` + query += `SELECT ${filteredColumns + .map((column) => `${column}::text`) + .join(', ')} FROM "${table}"` + query += ` WHERE ${whereClause ? `${whereClause} AND` : ''} ${quicksearchCondition}` + query += ` ORDER BY ${orderBy}` + query += ` LIMIT $1::INT OFFSET $2::INT` + + break + } + + 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 + const orderBy = columnDefs + .map((column) => { + return ` +(CASE WHEN @p4 = '${column.field}' AND @p5 = 0 THEN ${column.field} END) ASC, +(CASE WHEN @p4 = '${column.field}' AND @p5 = 1 THEN ${column.field} END) DESC` + }) + .join(',\n') + + quicksearchCondition = ` (@p3 = '' OR CONCAT(${selectClause}) LIKE '%' + @p3 + '%')` + + query += `SELECT ${selectClause} FROM ${table}` + query += ` WHERE ${whereClause ? `${whereClause} AND` : ''} ${quicksearchCondition}` + query += ` ORDER BY ${orderBy}` + query += ` OFFSET @p2 ROWS FETCH NEXT @p1 ROWS ONLY` + break + case 'snowflake': { + return makeSnowflakeSelectQuery(table, columnDefs, whereClause, options) + } + case 'bigquery': { + const orderBy = columnDefs + .map((column) => { + if ( + column.datatype === 'JSON' || + column.datatype.startsWith('STRUCT') || + column.datatype.startsWith('ARRAY') + ) { + return ` +(CASE WHEN @order_by = '${column.field}' AND @is_desc = false THEN TO_JSON_STRING(${column.field}) END) ASC, +(CASE WHEN @order_by = '${column.field}' AND @is_desc = true THEN TO_JSON_STRING(${column.field}) END) DESC` + } + return ` +(CASE WHEN @order_by = '${column.field}' AND @is_desc = false THEN ${column.field} END) ASC, +(CASE WHEN @order_by = '${column.field}' AND @is_desc = true THEN ${column.field} END) DESC` + }) + .join(',\n') + + const searchClause = filteredColumns + .map((col) => { + const def = columnDefs.find((c) => c.field === col.slice(1, -1)) + if ( + def?.datatype === 'JSON' || + def?.datatype.startsWith('STRUCT') || + def?.datatype.startsWith('ARRAY') + ) { + return `TO_JSON_STRING(${col})` + } + return `${col}` + }) + .join(',') + quicksearchCondition = ` (@quicksearch = '' OR REGEXP_CONTAINS(CONCAT(${searchClause}), '(?i)' || @quicksearch))` + + query += `SELECT ${selectClause} FROM ${table}` + query += ` WHERE ${whereClause ? `${whereClause} AND` : ''} ${quicksearchCondition}` + query += ` ORDER BY ${orderBy}` + query += ` LIMIT @limit OFFSET @offset` + break + } + + default: + throw new Error('Unsupported database type') + } + + return query +} + +function coerceToNumber(value: any): number { + if (typeof value === 'number') { + return value + } + if (typeof value === 'string') { + return parseInt(value, 10) + } + return 0 +} + +export function getSelectInput( + resource: string, + table: string | undefined, + columnDefs: ColumnDef[], + whereClause: string | undefined, + dbType: DbType, + options?: { limit?: number; offset?: number } +): AppInput | undefined { + if (!resource || !table || !columnDefs) { + return undefined + } + + if (columnDefs.length === 0) { + return undefined + } + + const getRunnable: RunnableByName = { + name: 'AppDbExplorer', + type: 'runnableByName', + inlineScript: { + content: makeSelectQuery(table, columnDefs, whereClause, dbType, options), + language: getLanguageByResourceType(dbType) + } + } + + const getQuery: AppInput = { + runnable: getRunnable, + fields: { + database: { + type: 'static', + value: resource, + fieldType: 'object', + format: `resource-${dbType}` + } + }, + type: 'runnable', + fieldType: 'object' + } + + return getQuery +} 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 new file mode 100644 index 0000000000..76e7253bd7 --- /dev/null +++ b/frontend/src/lib/components/apps/components/display/dbtable/queries/update.ts @@ -0,0 +1,98 @@ +import type { AppInput, RunnableByName } from '$lib/components/apps/inputType' +import { getLanguageByResourceType, type ColumnDef, buildParameters, type DbType } from '../utils' + +function updateWithAllValues( + table: string, + column: ColumnDef, + columns: ColumnDef[], + dbType: DbType +) { + let query = buildParameters( + [ + { + field: 'value_to_update', + datatype: column.datatype + }, + ...columns + ], + dbType + ) + + query += `\n` + + switch (dbType) { + case 'postgresql': { + const conditions = columns + .map((c, i) => `${c.field} = $${i + 2}::text::${c.datatype} `) + .join(' AND ') + + query += `\nUPDATE ${table} SET ${column.field} = $1::text::${column.datatype} WHERE ${conditions} RETURNING 1` + return query + } + case 'mysql': { + const conditions = columns.map((c) => `${c.field} = :${c.field}`).join(' AND ') + query += `\nUPDATE ${table} SET ${column.field} = :value_to_update WHERE ${conditions}` + return query + } + case 'ms_sql_server': { + const conditions = columns.map((c, i) => `${c.field} = @p${i + 2} `).join(' AND ') + query += `\nUPDATE ${table} SET ${column.field} = @p1 WHERE ${conditions}` + return query + } + case 'snowflake': { + const conditions = columns.map((c, i) => `${c.field} = ? `).join(' AND ') + query += `\nUPDATE ${table} SET ${column.field} = ? WHERE ${conditions}` + return query + } + case 'bigquery': { + const conditions = columns.map((c, i) => `${c.field} = @${c.field}`).join(' AND ') + query += `\nUPDATE ${table} SET ${column.field} = @value_to_update WHERE ${conditions}` + return query + } + default: + throw new Error('Unsupported database type') + } +} + +export function getUpdateInput( + resource: string, + table: string, + column: ColumnDef, + columns: ColumnDef[], + dbType: DbType +): AppInput | undefined { + if (!resource || !table) { + return undefined + } + + const updateRunnable: RunnableByName = { + name: 'AppDbExplorer', + type: 'runnableByName', + inlineScript: { + content: updateWithAllValues(table, column, columns, dbType), + language: getLanguageByResourceType(dbType), + schema: { + $schema: 'https://json-schema.org/draft/2020-12/schema', + properties: {}, + required: ['database'], + type: 'object' + } + } + } + + const updateQuery: AppInput = { + runnable: updateRunnable, + fields: { + database: { + type: 'static', + value: resource, + fieldType: 'object', + format: `resource-${dbType}` + } + }, + type: 'runnable', + fieldType: 'object' + } + + return updateQuery +} 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 55348eabac..284bb6d492 100644 --- a/frontend/src/lib/components/apps/components/display/dbtable/utils.ts +++ b/frontend/src/lib/components/apps/components/display/dbtable/utils.ts @@ -1,313 +1,8 @@ -import type { AppInput, RunnableByName } from '../../../inputType' import { JobService, Preview } from '$lib/gen' import type { DBSchema, DBSchemas, GraphqlSchema, SQLSchema } from '$lib/stores' import { buildClientSchema, getIntrospectionQuery, printSchema } from 'graphql' import { tryEvery } from '$lib/utils' -export function makeQuery( - table: string, - tableMetadata: TableMetadata, - whereClause: string | undefined -) { - if (!table) throw new Error('Table name is required') - - const filteredColumns = tableMetadata - .filter((x) => x != undefined) - .map((column) => `"${column?.field}"::text`) - - let selectClause = filteredColumns.join(', ') - - let orderBy = ` - ${tableMetadata - .map( - (column) => - ` -(CASE WHEN $4 = '${column.field}' AND $5 IS false THEN "${column.field}"::text END), -(CASE WHEN $4 = '${column.field}' AND $5 IS true THEN "${column.field}"::text END) DESC` - ) - .join(',\n')}` - - let query = ` --- $1 limit --- $2 offset --- $3 quicksearch --- $4 orderBy --- $5 is_desc - -SELECT ${selectClause} FROM "${table}" WHERE ` - if (whereClause) { - query += ` ${whereClause} AND ` - } - query += ` ($3 = '' OR "${table}"::text ILIKE '%' || $3 || '%')` - - query += ` ORDER BY ${orderBy}` - query += ` LIMIT $1::INT OFFSET $2::INT` - - return query -} - -export function createPostgresInsert( - table: string, - columns: ColumnDef[], - resource: string -): AppInput { - return { - runnable: { - name: 'AppDbExplorer', - type: 'runnableByName', - inlineScript: { - content: makeInsertQuery(table, columns), - language: Preview.language.POSTGRESQL, - schema: { - $schema: 'https://json-schema.org/draft/2020-12/schema', - properties: {}, - required: ['database'], - type: 'object' - } - } - }, - fields: { - database: { - type: 'static', - value: resource, - fieldType: 'object', - format: 'resource-postgresql' - } - }, - type: 'runnable', - fieldType: 'object' - } -} - -export function makeInsertQuery(table: string, columns: ColumnDef[]) { - if (!table) throw new Error('Table name is required') - - const columnsInsert = columns.filter((x) => !x.hideInsert) - const columnsDefault = columns.filter( - (x) => x.hideInsert && (x.overrideDefaultValue || x.defaultvalue === null) - ) - - const allInsertColumns = columnsInsert.concat(columnsDefault) - // Constructing the query - const query = ` -${columnsInsert.map((column, i) => `-- $${i + 1} ${column.field}`).join('\n')} - -INSERT INTO ${table} (${allInsertColumns.map((c) => c.field).join(', ')}) -VALUES (${columnsInsert.map((c, i) => `$${i + 1}::${c.datatype}`).join(', ')}${ - columnsDefault.length > 0 ? ',' : '' - } ${columnsDefault - .map((c) => (c.defaultValueNull ? 'NULL' : `${c.defaultUserValue}::${c.datatype}`)) - .join(', ')})` - - return query -} - -export function getPrimaryKeys(tableMetadata?: TableMetadata): string[] { - let r = tableMetadata?.filter((x) => x.isprimarykey)?.map((x) => x.field) ?? [] - if (r?.length === 0) { - r = tableMetadata?.map((x) => x.field) ?? [] - } - return r ?? [] -} - -export function createPostgresInput( - resource: string, - table: string | undefined, - columns: TableMetadata, - whereClause: string | undefined -): AppInput | undefined { - if (!resource || !table || !columns) { - // Return undefined if resource or table is not defined - return undefined - } - - if (columns.length === 0) { - return undefined - } - - const getRunnable: RunnableByName = { - name: 'AppDbExplorer', - type: 'runnableByName', - inlineScript: { - content: makeQuery(table, columns, whereClause), - language: Preview.language.POSTGRESQL - } - } - - const getQuery: AppInput = { - runnable: getRunnable, - fields: { - database: { - type: 'static', - value: resource, - fieldType: 'object', - format: 'resource-postgresql' - } - }, - type: 'runnable', - fieldType: 'object' - } - - return getQuery -} - -export function createUpdatePostgresInput( - resource: string, - table: string, - column: ColumnMetadata, - columns: ColumnMetadata[] -): AppInput | undefined { - if (!resource || !table) { - return undefined - } - - const query = updateWithAllValues() - - function updateWithAllValues() { - let query = ` --- $1 valueToUpdate -${columns.map((c, i) => `-- $${i + 2} ${c.field}`).join('\n')} - -UPDATE ${table} SET ${column.field} = $1::text::${column.datatype} WHERE -${columns.map((c, i) => `${c.field} = $${i + 2}::text::${c.datatype} `).join(' AND ')} -RETURNING 1` - - return query - } - - const updateRunnable: RunnableByName = { - name: 'AppDbExplorer', - type: 'runnableByName', - inlineScript: { - content: query, - language: Preview.language.POSTGRESQL, - schema: { - $schema: 'https://json-schema.org/draft/2020-12/schema', - properties: {}, - required: ['database'], - type: 'object' - } - } - } - - const updateQuery: AppInput = { - runnable: updateRunnable, - fields: { - database: { - type: 'static', - value: resource, - fieldType: 'object', - format: 'resource-postgresql' - } - }, - type: 'runnable', - fieldType: 'object' - } - - return updateQuery -} - -export function createDeletePostgresInput( - resource: string, - table: string, - columns: ColumnMetadata[] -): AppInput | undefined { - if (!resource || !table) { - return undefined - } - - const query = updateWithAllValues() - - function updateWithAllValues() { - let query = ` -${columns.map((c, i) => `-- $${i + 1} ${c.field}`).join('\n')} - -DELETE FROM ${table} WHERE ${columns - .map((c, i) => `${c.field} = $${i + 1}::text::${c.datatype}`) - .join(' AND ')} RETURNING 1;` - - return query - } - - const updateRunnable: RunnableByName = { - name: 'AppDbExplorer', - type: 'runnableByName', - inlineScript: { - content: query, - language: Preview.language.POSTGRESQL, - schema: { - $schema: 'https://json-schema.org/draft/2020-12/schema', - properties: {}, - required: ['database'], - type: 'object' - } - } - } - - const updateQuery: AppInput = { - runnable: updateRunnable, - fields: { - database: { - type: 'static', - value: resource, - fieldType: 'object', - format: 'resource-postgresql' - } - }, - type: 'runnable', - fieldType: 'object' - } - - return updateQuery -} - -export function getCountPostgresql(resource: string, table: string): AppInput | undefined { - if (!resource || !table) { - return undefined - } - - const query = ` --- $1 quicksearch -SELECT COUNT(*) FROM ${table} WHERE ($1 = '' OR ${table}::text ILIKE '%' || $1 || '%')` - - const updateRunnable: RunnableByName = { - name: 'AppDbExplorer', - type: 'runnableByName', - inlineScript: { - content: query, - language: Preview.language.POSTGRESQL, - schema: { - $schema: 'https://json-schema.org/draft/2020-12/schema', - properties: { - database: { - description: 'Database name', - type: 'object', - format: 'resource-postgresql' - } - }, - required: ['database'], - type: 'object' - } - } - } - - const updateQuery: AppInput = { - runnable: updateRunnable, - fields: { - database: { - type: 'static', - value: resource, - fieldType: 'object', - format: 'resource-postgresql' - } - }, - type: 'runnable', - fieldType: 'object' - } - - return updateQuery -} - export enum ColumnIdentity { ByDefault = 'By Default', Always = 'Always', @@ -353,41 +48,113 @@ export type ColumnDef = { export async function loadTableMetaData( resource: string, workspace: string | undefined, - table: string | undefined + table: string | undefined, + resourceType: string ): Promise { if (!resource || !table || !workspace) { return undefined } - const code = ` - 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) - FROM pg_catalog.pg_attrdef d - WHERE d.adrelid = a.attrelid AND d.adnum = a.attnum AND a.atthasdef) as DefaultValue, - (SELECT CASE WHEN i.indisprimary THEN true ELSE 'NO' END - FROM pg_catalog.pg_class tbl, pg_catalog.pg_class idx, pg_catalog.pg_index i, pg_catalog.pg_attribute att - WHERE tbl.oid = a.attrelid AND idx.oid = i.indexrelid AND att.attrelid = tbl.oid - AND i.indrelid = tbl.oid AND att.attnum = any(i.indkey) AND att.attname = a.attname LIMIT 1) as IsPrimaryKey, - CASE a.attidentity - WHEN 'd' THEN 'By Default' - WHEN 'a' THEN 'Always' - ELSE 'No' - END as IsIdentity, - CASE a.attnotnull - WHEN false THEN 'YES' - ELSE 'NO' - 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 a.attrelid = (SELECT oid FROM pg_catalog.pg_class WHERE relname = '${table}') - AND a.attnum > 0 AND NOT a.attisdropped -ORDER BY a.attnum; + let code: string = '' -` + if (resourceType === 'mysql') { + code = ` + SELECT + COLUMN_NAME as field, + COLUMN_TYPE as DataType, + COLUMN_DEFAULT as DefaultValue, + 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 + FROM + INFORMATION_SCHEMA.COLUMNS + WHERE + TABLE_NAME = '${table}' + ORDER BY + ORDINAL_POSITION; + ` + } else if (resourceType === 'postgresql') { + code = ` + 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) + FROM pg_catalog.pg_attrdef d + WHERE d.adrelid = a.attrelid AND d.adnum = a.attnum AND a.atthasdef) as DefaultValue, + (SELECT CASE WHEN i.indisprimary THEN true ELSE 'NO' END + FROM pg_catalog.pg_class tbl, pg_catalog.pg_class idx, pg_catalog.pg_index i, pg_catalog.pg_attribute att + WHERE tbl.oid = a.attrelid AND idx.oid = i.indexrelid AND att.attrelid = tbl.oid + AND i.indrelid = tbl.oid AND att.attnum = any(i.indkey) AND att.attname = a.attname LIMIT 1) as IsPrimaryKey, + CASE a.attidentity + WHEN 'd' THEN 'By Default' + WHEN 'a' THEN 'Always' + ELSE 'No' + END as IsIdentity, + CASE a.attnotnull + WHEN false THEN 'YES' + ELSE 'NO' + 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 a.attrelid = (SELECT oid FROM pg_catalog.pg_class WHERE relname = '${table}') + AND a.attnum > 0 AND NOT a.attisdropped + ORDER BY a.attnum; + + ` + } else if (resourceType === 'ms_sql_server') { + code = ` + SELECT + COLUMN_NAME as field, + DATA_TYPE as DataType, + COLUMN_DEFAULT as DefaultValue, + 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 +FROM + INFORMATION_SCHEMA.COLUMNS +WHERE + TABLE_NAME = '${table}' +ORDER BY + ORDINAL_POSITION; + + ` + } else if (resourceType === 'snowflake') { + code = ` + 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 + where table_name = '${table}' + order by ORDINAL_POSITION; + ` + } else if (resourceType === 'bigquery') { + code = `SELECT + c.COLUMN_NAME as field, + DATA_TYPE as DataType, + CASE WHEN COLUMN_DEFAULT = 'NULL' THEN '' ELSE COLUMN_DEFAULT END as DefaultValue, + CASE WHEN constraint_name is not null THEN true ELSE false END as IsPrimaryKey, + 'No' as IsIdentity, + IS_NULLABLE as IsNullable, + false as IsEnum +FROM + test_dataset.INFORMATION_SCHEMA.COLUMNS c + LEFT JOIN + test_dataset.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]}" +order by c.ORDINAL_POSITION;` + } else { + throw new Error('Unsupported database type:' + resourceType) + } const maxRetries = 3 let attempts = 0 @@ -397,7 +164,7 @@ ORDER BY a.attnum; const job = await JobService.runScriptPreview({ workspace: workspace, requestBody: { - language: Preview.language.POSTGRESQL, + language: getLanguageByResourceType(resourceType), content: code, args: { database: resource @@ -415,7 +182,11 @@ ORDER BY a.attnum; if (testResult.success) { attempts = maxRetries - return testResult.result + if (resourceType === 'ms_sql_server') { + return testResult.result[0] + } else { + return testResult.result + } } else { attempts++ } @@ -457,6 +228,7 @@ const scripts: Record< acc[table_schema].push(a) return acc }, {}) + const data = {} for (const key in schemas) { data[key] = schemas[key].reduce((acc, a) => { @@ -478,13 +250,14 @@ const scripts: Record< return acc }, {}) } + return data }, lang: 'postgresql', argName: 'database' }, mysql: { - code: "select TABLE_SCHEMA, TABLE_NAME, DATA_TYPE, COLUMN_NAME, COLUMN_DEFAULT from information_schema.columns where table_schema != 'information_schema'", + code: "SELECT DATABASE() AS default_db_name, TABLE_SCHEMA, TABLE_NAME, DATA_TYPE, COLUMN_NAME, COLUMN_DEFAULT FROM information_schema.columns WHERE table_schema = DATABASE() OR table_schema NOT IN ('information_schema', 'mysql', 'performance_schema', 'sys', '_vt');", processingFn: (rows) => { const schemas = rows.reduce((acc, a) => { const table_schema = a.TABLE_SCHEMA @@ -493,6 +266,7 @@ const scripts: Record< acc[table_schema].push(a) return acc }, {}) + const data = {} for (const key in schemas) { data[key] = schemas[key].reduce((acc, a) => { @@ -525,7 +299,7 @@ const scripts: Record< argName: 'api' }, bigquery: { - code: `import { BigQuery } from '@google-cloud/bigquery'; + code: `import { BigQuery } from '@google-cloud/bigquery@7.5.0'; export async function main(args: bigquery) { const bq = new BigQuery({ credentials: args @@ -657,6 +431,7 @@ export async function getDbSchemas( const { processingFn } = scripts[resourceType] const schema = processingFn !== undefined ? processingFn(testResult.result) : testResult.result + dbSchemas[resourcePath] = { lang: resourceTypeToLang(resourceType) as SQLSchema['lang'], schema, @@ -715,73 +490,160 @@ export function formatGraphqlSchema(dbSchema: GraphqlSchema): string { return printSchema(buildClientSchema(dbSchema.schema)) } -/** - * 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' +export function getFieldType(type: string, databaseType: DbType) { + switch (databaseType) { + case 'postgresql': { + const baseType = type.split('(')[0] + const validTextTypes = ['character varying', 'text'] + const validNumberTypes = ['integer', 'bigint', 'numeric', 'double precision'] + const validDateTypes = ['date', 'timestamp without time zone', 'timestamp with time zone'] -/** - * Class for defining a cell renderer. - * If you don't need to define a separate class you could use cellRendererFactory - * to create a component with the column definitions. - */ -export abstract class AbstractCellRenderer implements ICellRendererComp { - eGui: any - protected value: any - protected params: any - constructor(parentElement = 'span') { - // create empty span (or other element) to place svelte component in - this.eGui = document.createElement(parentElement) - } + return validTextTypes.includes(baseType) + ? 'text' + : validNumberTypes.includes(baseType) + ? 'number' + : baseType === 'boolean' + ? 'checkbox' + : validDateTypes.includes(baseType) + ? 'date' + : 'text' + } + case 'mysql': { + const baseType = type.split('(')[0].toLowerCase() // Ensure case-insensitive comparison + const validTextTypes = ['varchar', 'text', 'char', 'mediumtext', 'longtext', 'tinytext'] + const validNumberTypes = ['int', 'bigint', 'decimal', 'numeric', 'float', 'double'] + const validDateTypes = ['date', 'datetime', 'timestamp', 'time', 'year'] - init(params: ICellRendererParams & { onClick?: (data: any) => void }) { - this.value = params.value - this.createComponent(params) - this.eGui.addEventListener('click', () => params.onClick?.(params.data)) - this.params = params - } + return validTextTypes.includes(baseType) + ? 'text' + : validNumberTypes.includes(baseType) + ? 'number' + : baseType === 'boolean' + ? 'checkbox' + : validDateTypes.includes(baseType) + ? 'date' + : 'text' + } + case 'ms_sql_server': { + const baseType = type.split('(')[0].toLowerCase() // Ensure case-insensitive comparison + const validTextTypes = ['varchar', 'text', 'char', 'nchar', 'nvarchar', 'ntext'] + const validNumberTypes = [ + 'int', + 'bigint', + 'decimal', + 'numeric', + 'float', + 'real', + 'smallint', + 'tinyint' + ] + const validDateTypes = [ + 'date', + 'datetime', + 'datetime2', + 'smalldatetime', + 'datetimeoffset', + 'time' + ] - getGui() { - return this.eGui - } + return validTextTypes.includes(baseType) + ? 'text' + : validNumberTypes.includes(baseType) + ? 'number' + : baseType === 'bit' + ? 'checkbox' + : validDateTypes.includes(baseType) + ? 'date' + : 'text' + } - refresh(params: ICellRendererParams) { - this.value = params.value - this.eGui.innerHTML = '' + case 'snowflake': { + const baseType = type.split('(')[0].toLowerCase() // Ensure case-insensitive comparison + const validTextTypes = ['varchar', 'text', 'char'] + const validNumberTypes = ['int', 'number', 'decimal', 'float', 'double'] + const validDateTypes = ['date', 'timestamp', 'time'] - return true - } + return validTextTypes.includes(baseType) + ? 'text' + : validNumberTypes.includes(baseType) + ? 'number' + : baseType === 'boolean' + ? 'checkbox' + : validDateTypes.includes(baseType) + ? 'date' + : 'text' + } - /** - * Define and create the svelte component to use in the cell - * @example - * // This is all you need to do within this method: create the component with new, specify the target - * // is the class, and pass in props via the params. - * new CampusIcon({ - * target: this.eGui, - * props: { - * color: params.data?.color, - * name: params.data?.name - * } - * @param params params for rendering the call, including the value for the cell - */ - abstract createComponent(params: ICellRendererParams): void -} - -/** - * Creates a cell renderer using the given callback for how to initialise a svelte component. - * See AbstractCellRenderer.createComponent - * @param svelteComponent function for how to create the svelte component - * @returns - */ -export function cellRendererFactory( - svelteComponent: (cell: AbstractCellRenderer, params: ICellRendererParams) => void -) { - class Renderer extends AbstractCellRenderer { - createComponent(params: ICellRendererParams): void { - svelteComponent(this, params) + default: { + return 'text' } } - return Renderer +} + +export type DbType = 'mysql' | 'ms_sql_server' | 'postgresql' | 'snowflake' | 'bigquery' + +export function buildVisibleFieldList(columnDefs: ColumnDef[], dbType: DbType) { + // Filter out hidden columns to avoid counting the wrong number of rows + return columnDefs + .filter((columnDef: ColumnDef) => columnDef && columnDef.hide !== true) + .map((column) => { + switch (dbType) { + case 'postgresql': + return `"${column?.field}"` // PostgreSQL uses double quotes for identifiers + case 'ms_sql_server': + return `[${column?.field}]` // MSSQL uses square brackets for identifiers + case 'mysql': + return `\`${column?.field}\`` // MySQL uses backticks + case 'snowflake': + return `"${column?.field}"` // Snowflake uses double quotes for identifiers + case 'bigquery': + return `\`${column?.field}\`` // BigQuery uses backticks + default: + throw new Error('Unsupported database type') + } + }) +} + +export function getLanguageByResourceType(name: string) { + const language = { + postgresql: Preview.language.POSTGRESQL, + mysql: Preview.language.MYSQL, + ms_sql_server: Preview.language.MSSQL, + snowflake: Preview.language.SNOWFLAKE, + bigquery: Preview.language.BIGQUERY + } + return language[name] +} + +export function buildParameters( + columns: Array<{ + field: string + datatype: string + }>, + databaseType: string +) { + return columns + .map((column, i) => { + switch (databaseType) { + case 'postgresql': + return `-- $${i + 1} ${column.field}` + case 'mysql': + return `-- :${column.field} (${column.datatype.split('(')[0]})` + case 'ms_sql_server': + return `-- @p${i + 1} ${column.field} (${column.datatype.split('(')[0]})` + case 'snowflake': + return `-- ? ${column.field} (${column.datatype.split('(')[0]})` + case 'bigquery': + return `-- @${column.field} (${column.datatype.split('(')[0]})` + } + }) + .join('\n') +} + +export function getPrimaryKeys(tableMetadata?: TableMetadata): string[] { + let r = tableMetadata?.filter((x) => x.isprimarykey)?.map((x) => x.field) ?? [] + if (r?.length === 0) { + r = tableMetadata?.map((x) => x.field) ?? [] + } + return r ?? [] } diff --git a/frontend/src/lib/components/apps/components/display/table/AppAggridExplorerTable.svelte b/frontend/src/lib/components/apps/components/display/table/AppAggridExplorerTable.svelte index 33b5a3db04..5095796a62 100644 --- a/frontend/src/lib/components/apps/components/display/table/AppAggridExplorerTable.svelte +++ b/frontend/src/lib/components/apps/components/display/table/AppAggridExplorerTable.svelte @@ -16,12 +16,11 @@ import type { Output } from '$lib/components/apps/rx' import type { InitConfig } from '$lib/components/apps/editor/appUtils' import { Button } from '$lib/components/common' - import { cellRendererFactory } from '../dbtable/utils' + import { cellRendererFactory } from './utils' import { Trash2 } from 'lucide-svelte' // import 'ag-grid-community/dist/styles/ag-theme-alpine-dark.css' export let id: string - export let customCss: ComponentCustomCSS<'aggridcomponent'> | undefined = undefined export let containerHeight: number | undefined = undefined export let resolvedConfig: InitConfig< @@ -135,11 +134,13 @@ new Button({ target: c.eGui, props: { - btnClasses: 'mt-1', - color: 'red', - variant: 'border', + btnClasses: 'w-12', + wrapperClasses: 'flex justify-end items-center h-full', + color: 'light', + size: 'sm', + variant: 'contained', iconOnly: true, - endIcon: { icon: Trash2 }, + startIcon: { icon: Trash2 }, nonCaptureEvent: true } }) @@ -174,7 +175,7 @@ editable: resolvedConfig?.allEditable, onCellValueChanged }, - infiniteInitialRowCount: 1000, + infiniteInitialRowCount: 100, cacheBlockSize: 100, cacheOverflowSize: 2, maxBlocksInCache: 20, @@ -222,9 +223,8 @@ let oldDatasource = datasource $: if (datasource && datasource != oldDatasource) { - console.log('datasource changed') - oldDatasource = datasource + api?.updateGridOptions({ datasource }) } @@ -283,11 +283,7 @@ {#if Array.isArray(resolvedConfig.columnDefs) && resolvedConfig.columnDefs.every(isObject)}
+
{firstRow}{'->'}{lastRow + 1} of {datasource?.rowCount} rows
-
{firstRow}{'->'}{lastRow + 1} of {datasource?.rowCount} rows
{:else if resolvedConfig.columnDefs != undefined} The columnDefs should be an array of objects, received: diff --git a/frontend/src/lib/components/apps/components/display/table/utils.ts b/frontend/src/lib/components/apps/components/display/table/utils.ts new file mode 100644 index 0000000000..8998355c48 --- /dev/null +++ b/frontend/src/lib/components/apps/components/display/table/utils.ts @@ -0,0 +1,70 @@ +/** + * 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' + +/** + * Class for defining a cell renderer. + * If you don't need to define a separate class you could use cellRendererFactory + * to create a component with the column definitions. + */ +export abstract class AbstractCellRenderer implements ICellRendererComp { + eGui: any + protected value: any + protected params: any + constructor(parentElement = 'span') { + // create empty span (or other element) to place svelte component in + this.eGui = document.createElement(parentElement) + } + + init(params: ICellRendererParams & { onClick?: (data: any) => void }) { + this.value = params.value + this.createComponent(params) + this.eGui.addEventListener('click', () => params.onClick?.(params.data)) + this.params = params + } + + getGui() { + return this.eGui + } + + refresh(params: ICellRendererParams) { + this.value = params.value + this.eGui.innerHTML = '' + + return true + } + + /** + * Define and create the svelte component to use in the cell + * @example + * // This is all you need to do within this method: create the component with new, specify the target + * // is the class, and pass in props via the params. + * new CampusIcon({ + * target: this.eGui, + * props: { + * color: params.data?.color, + * name: params.data?.name + * } + * @param params params for rendering the call, including the value for the cell + */ + abstract createComponent(params: ICellRendererParams): void +} + +/** + * Creates a cell renderer using the given callback for how to initialise a svelte component. + * See AbstractCellRenderer.createComponent + * @param svelteComponent function for how to create the svelte component + * @returns + */ +export function cellRendererFactory( + svelteComponent: (cell: AbstractCellRenderer, params: ICellRendererParams) => void +) { + class Renderer extends AbstractCellRenderer { + createComponent(params: ICellRendererParams): void { + svelteComponent(this, params) + } + } + return Renderer +} diff --git a/frontend/src/lib/components/apps/editor/AppEditorHeader.svelte b/frontend/src/lib/components/apps/editor/AppEditorHeader.svelte index a562437e42..282e4a1b2f 100644 --- a/frontend/src/lib/components/apps/editor/AppEditorHeader.svelte +++ b/frontend/src/lib/components/apps/editor/AppEditorHeader.svelte @@ -75,15 +75,13 @@ import { cloneDeep } from 'lodash' import AppReportsDrawer from './AppReportsDrawer.svelte' import HighlightCode from '$lib/components/HighlightCode.svelte' - import { - type ColumnDef, - getCountPostgresql, - createPostgresInput, - createPostgresInsert, - createUpdatePostgresInput, - getPrimaryKeys - } from '../components/display/dbtable/utils' + import { type ColumnDef, getPrimaryKeys } from '../components/display/dbtable/utils' import DebugPanel from './contextPanel/DebugPanel.svelte' + import { getCountInput } from '../components/display/dbtable/queries/count' + import { getSelectInput } from '../components/display/dbtable/queries/select' + import { getInsertInput } from '../components/display/dbtable/queries/insert' + import { getUpdateInput } from '../components/display/dbtable/queries/update' + import { getDeleteInput } from '../components/display/dbtable/queries/delete' async function hash(message) { try { @@ -192,8 +190,12 @@ if (c.type === 'dbexplorercomponent') { let nr: { id: string; input: AppInput }[] = [] let config = c.configuration as any - let pg = config?.type?.configuration?.postgresql - if (pg) { + + const dbType = config?.type?.selected + + let pg = config?.type?.configuration?.[dbType] + + if (pg && dbType) { const { table, resource } = pg const tableValue = table.value const resourceValue = resource.value @@ -203,17 +205,22 @@ | undefined if (tableValue && resourceValue && columnDefs) { r.push({ - input: createPostgresInput(resourceValue, tableValue, columnDefs, whereClause), + input: getSelectInput(resourceValue, tableValue, columnDefs, whereClause, dbType), id: x.id }) r.push({ - input: getCountPostgresql(resourceValue, tableValue), + input: getCountInput(resourceValue, tableValue, dbType, columnDefs, whereClause), id: x.id + '_count' }) r.push({ - input: createPostgresInsert(tableValue, columnDefs, resourceValue), + input: getInsertInput(tableValue, columnDefs, resourceValue, dbType), id: x.id + '_insert' }) + r.push({ + input: getDeleteInput(resourceValue, tableValue, columnDefs, dbType), + id: x.id + '_delete' + }) + let primaryColumns = getPrimaryKeys(columnDefs) let columns = columnDefs?.filter((x) => primaryColumns.includes(x.field)) @@ -221,7 +228,7 @@ .filter((col) => col.editable || config.allEditable.value) .forEach((column) => { r.push({ - input: createUpdatePostgresInput(resourceValue, tableValue, column, columns), + input: getUpdateInput(resourceValue, tableValue, column, columns, dbType), id: x.id + '_update' }) }) @@ -247,6 +254,8 @@ ) )) as ([string, Record] | undefined)[] + console.log('allTriggers', allTriggers) + policy.triggerables = Object.fromEntries( allTriggers.filter(Boolean) as [string, Record][] ) @@ -259,7 +268,8 @@ ): Promise<[string, Record] | undefined> { const staticInputs = collectStaticFields(fields) if (runnable?.type == 'runnableByName') { - console.log(runnable.inlineScript?.content) + console.log('processRunnable:content', runnable.inlineScript?.content) + let hex = await hash(runnable.inlineScript?.content) console.log('hex', hex, id) return [`${id}:rawscript/${hex}`, staticInputs] diff --git a/frontend/src/lib/components/apps/editor/component/components.ts b/frontend/src/lib/components/apps/editor/component/components.ts index c1255fb453..d9d767a4ca 100644 --- a/frontend/src/lib/components/apps/editor/component/components.ts +++ b/frontend/src/lib/components/apps/editor/component/components.ts @@ -3358,13 +3358,77 @@ See date-fns format for more information. By default, it is 'dd.MM.yyyy HH:mm' selected: 'postgresql', labels: { postgresql: 'PostgreSQL', - msql: 'MySQL' + mysql: 'MySQL', + ms_sql_server: 'MS SQL Server', + snowflake: 'Snowflake', + bigquery: 'BigQuery' }, configuration: { postgresql: { resource: { type: 'static', fieldType: 'resource', + subFieldType: 'postgres', + value: '' + } as StaticAppInput, + table: { + fieldType: 'select', + subfieldType: 'db-table', + type: 'static', + selectOptions: [], + value: undefined + } + }, + mysql: { + resource: { + type: 'static', + fieldType: 'resource', + subFieldType: 'mysql', + value: '' + } as StaticAppInput, + table: { + fieldType: 'select', + subfieldType: 'db-table', + type: 'static', + selectOptions: [], + value: undefined + } + }, + ms_sql_server: { + resource: { + type: 'static', + fieldType: 'resource', + subFieldType: 'ms_sql_server', + value: '' + } as StaticAppInput, + table: { + fieldType: 'select', + subfieldType: 'db-table', + type: 'static', + selectOptions: [], + value: undefined + } + }, + snowflake: { + resource: { + type: 'static', + fieldType: 'resource', + subFieldType: 'snowflake', + value: '' + } as StaticAppInput, + table: { + fieldType: 'select', + subfieldType: 'db-table', + type: 'static', + selectOptions: [], + value: undefined + } + }, + bigquery: { + resource: { + type: 'static', + fieldType: 'resource', + subFieldType: 'bigquery', value: '' } as StaticAppInput, table: { diff --git a/frontend/src/lib/components/apps/editor/settingsPanel/inputEditor/StaticInputEditor.svelte b/frontend/src/lib/components/apps/editor/settingsPanel/inputEditor/StaticInputEditor.svelte index 9557af3a4e..8da91d8cae 100644 --- a/frontend/src/lib/components/apps/editor/settingsPanel/inputEditor/StaticInputEditor.svelte +++ b/frontend/src/lib/components/apps/editor/settingsPanel/inputEditor/StaticInputEditor.svelte @@ -67,7 +67,7 @@ {:else if fieldType === 'tab-select'} - {:else if fieldType === 'resource' && subFieldType !== 's3'} + {:else if fieldType === 'resource' && subFieldType && ['mysql', 'postgres', 'ms_sql_server', 'snowflake', 'bigquery'].includes(subFieldType)} { @@ -81,7 +81,7 @@ } }} showSchemaExplorer - resourceType="postgresql" + resourceType={subFieldType === 'postgres' ? 'postgresql' : subFieldType} /> {:else if fieldType === 'resource' && subFieldType === 's3'} | AppInputSpec<'resource', string> | AppInputSpec<'resource', string, 's3'> + | AppInputSpec<'resource', string, 'postgres'> + | AppInputSpec<'resource', string, 'mysql'> + | AppInputSpec<'resource', string, 'ms_sql_server'> + | AppInputSpec<'resource', string, 'snowflake'> + | AppInputSpec<'resource', string, 'bigquery'> | AppInputSpec<'array', object[], 'number-tuple'> export type RowAppInput = Extract diff --git a/frontend/src/lib/components/apps/types.ts b/frontend/src/lib/components/apps/types.ts index 220c45c9c7..ff04419403 100644 --- a/frontend/src/lib/components/apps/types.ts +++ b/frontend/src/lib/components/apps/types.ts @@ -63,15 +63,19 @@ export type Configuration = | TemplateV2AppInput export type StaticConfiguration = GeneralAppInput & StaticAppInput -export type RichConfigurationT = - | (T & { type: AppInput['type'] }) - | { - type: 'oneOf' - selected: string - tooltip?: string - labels?: Record - configuration: Record> - } +export type OneOfRichConfiguration = { + type: 'oneOf' + selected: string + tooltip?: string + labels?: Record + configuration: Record> +} + +export type OneOfConfiguration = OneOfRichConfiguration< + GeneralAppInput & (StaticAppInput | EvalAppInput | EvalV2AppInput) +> + +export type RichConfigurationT = (T & { type: AppInput['type'] }) | OneOfRichConfiguration export type RichConfiguration = RichConfigurationT export type RichConfigurations = Record diff --git a/frontend/src/lib/components/common/button/Button.svelte b/frontend/src/lib/components/common/button/Button.svelte index def049a22a..bb83fb035f 100644 --- a/frontend/src/lib/components/common/button/Button.svelte +++ b/frontend/src/lib/components/common/button/Button.svelte @@ -133,7 +133,7 @@ ButtonType.FontSizeClasses[size], ButtonType.SpacingClasses[spacingSize][variant], 'focus-visible:ring-2 font-semibold', - dropdownItems ? 'rounded-l-md h-full' : 'rounded-md', + dropdownItems && dropdownItems.length > 0 ? 'rounded-l-md h-full' : 'rounded-md', 'justify-center items-center text-center whitespace-nowrap inline-flex gap-2', btnClasses, 'active:opacity-80 transition-all', diff --git a/frontend/src/lib/infer.ts b/frontend/src/lib/infer.ts index 96193f1c39..9c592dd7ab 100644 --- a/frontend/src/lib/infer.ts +++ b/frontend/src/lib/infer.ts @@ -151,7 +151,7 @@ export async function inferArgs( await tick() } -function argSigToJsonSchemaType( +export function argSigToJsonSchemaType( t: | string | { resource: string | null } diff --git a/frontend/src/routes/flows/dev/+page.svelte b/frontend/src/routes/flows/dev/+page.svelte index 9a22c45b28..48b443bb54 100644 --- a/frontend/src/routes/flows/dev/+page.svelte +++ b/frontend/src/routes/flows/dev/+page.svelte @@ -16,6 +16,7 @@ import { userStore, workspaceStore } from '$lib/stores' import { getUserExt } from '$lib/user' import DarkModeToggle from '$lib/components/sidebar/DarkModeToggle.svelte' + import type { Schedule } from '$lib/components/flows/scheduleUtils' let token = $page.url.searchParams.get('wm_token') ?? undefined let workspace = $page.url.searchParams.get('workspace') ?? undefined @@ -62,12 +63,14 @@ let initialCode = JSON.stringify($flowStore, null, 4) const flowStateStore = writable({} as FlowState) - const scheduleStore = writable({ + const scheduleStore = writable({ args: {}, cron: '', timezone: Intl.DateTimeFormat().resolvedOptions().timeZone, - enabled: false + enabled: false, + summary: undefined }) + const previewArgsStore = writable>({}) const scriptEditorDrawer = writable(undefined) const moving = writable<{ module: FlowModule; modules: FlowModule[] } | undefined>(undefined) diff --git a/frontend/vite.config.js.timestamp-1704794658407-1f2b8aca1d21a.mjs b/frontend/vite.config.js.timestamp-1704794658407-1f2b8aca1d21a.mjs deleted file mode 100644 index f6891960cd..0000000000 --- a/frontend/vite.config.js.timestamp-1704794658407-1f2b8aca1d21a.mjs +++ /dev/null @@ -1,67 +0,0 @@ -// vite.config.js -import { sveltekit } from "file:///git/windmill/frontend/node_modules/@sveltejs/kit/src/exports/vite/index.js"; -import { readFileSync } from "fs"; -import { fileURLToPath } from "url"; -import monacoEditorPlugin from "file:///git/windmill/frontend/node_modules/vite-plugin-monaco-editor/dist/index.js"; -import circleDependency from "file:///git/windmill/frontend/node_modules/vite-plugin-circular-dependency/dist/index.mjs"; -var __vite_injected_original_import_meta_url = "file:///git/windmill/frontend/vite.config.js"; -var file = fileURLToPath(new URL("package.json", __vite_injected_original_import_meta_url)); -var json = readFileSync(file, "utf8"); -var version = JSON.parse(json); -var config = { - server: { - port: 3e3, - proxy: { - "^/api/.*": { - target: process.env.REMOTE ?? "https://app.windmill.dev/", - changeOrigin: true, - cookieDomainRewrite: "localhost" - }, - "^/ws/.*": { - target: process.env.REMOTE_LSP ?? "https://app.windmill.dev", - changeOrigin: true, - ws: true - }, - "^/ws_mp/.*": { - target: process.env.REMOTE_MP ?? "https://app.windmill.dev", - changeOrigin: true, - ws: true - } - } - }, - preview: { - port: 3e3 - }, - plugins: [ - sveltekit(), - monacoEditorPlugin.default({ - publicPath: "workers", - languageWorkers: [], - customWorkers: [ - { - label: "graphql", - entry: "monaco-graphql/esm/graphql.worker" - } - ] - }), - circleDependency({ circleImportThrowErr: false }) - ], - define: { - __pkg__: version - }, - optimizeDeps: { - include: ["highlight.js", "highlight.js/lib/core"] - }, - resolve: { - alias: { - path: "path-browserify" - }, - dedupe: ["monaco-editor", "vscode"] - }, - assetsInclude: ["**/*.wasm"] -}; -var vite_config_default = config; -export { - vite_config_default as default -}; -//# sourceMappingURL=data:application/json;base64,ewogICJ2ZXJzaW9uIjogMywKICAic291cmNlcyI6IFsidml0ZS5jb25maWcuanMiXSwKICAic291cmNlc0NvbnRlbnQiOiBbImNvbnN0IF9fdml0ZV9pbmplY3RlZF9vcmlnaW5hbF9kaXJuYW1lID0gXCIvZ2l0L3dpbmRtaWxsL2Zyb250ZW5kXCI7Y29uc3QgX192aXRlX2luamVjdGVkX29yaWdpbmFsX2ZpbGVuYW1lID0gXCIvZ2l0L3dpbmRtaWxsL2Zyb250ZW5kL3ZpdGUuY29uZmlnLmpzXCI7Y29uc3QgX192aXRlX2luamVjdGVkX29yaWdpbmFsX2ltcG9ydF9tZXRhX3VybCA9IFwiZmlsZTovLy9naXQvd2luZG1pbGwvZnJvbnRlbmQvdml0ZS5jb25maWcuanNcIjtpbXBvcnQgeyBzdmVsdGVraXQgfSBmcm9tICdAc3ZlbHRlanMva2l0L3ZpdGUnXG5pbXBvcnQgeyByZWFkRmlsZVN5bmMgfSBmcm9tICdmcydcbmltcG9ydCB7IGZpbGVVUkxUb1BhdGggfSBmcm9tICd1cmwnXG5pbXBvcnQgbW9uYWNvRWRpdG9yUGx1Z2luIGZyb20gJ3ZpdGUtcGx1Z2luLW1vbmFjby1lZGl0b3InXG5pbXBvcnQgY2lyY2xlRGVwZW5kZW5jeSBmcm9tICd2aXRlLXBsdWdpbi1jaXJjdWxhci1kZXBlbmRlbmN5J1xuXG5jb25zdCBmaWxlID0gZmlsZVVSTFRvUGF0aChuZXcgVVJMKCdwYWNrYWdlLmpzb24nLCBpbXBvcnQubWV0YS51cmwpKVxuY29uc3QganNvbiA9IHJlYWRGaWxlU3luYyhmaWxlLCAndXRmOCcpXG5jb25zdCB2ZXJzaW9uID0gSlNPTi5wYXJzZShqc29uKVxuXG4vKiogQHR5cGUge2ltcG9ydCgndml0ZScpLlVzZXJDb25maWd9ICovXG5jb25zdCBjb25maWcgPSB7XG5cdHNlcnZlcjoge1xuXHRcdHBvcnQ6IDMwMDAsXG5cdFx0cHJveHk6IHtcblx0XHRcdCdeL2FwaS8uKic6IHtcblx0XHRcdFx0dGFyZ2V0OiBwcm9jZXNzLmVudi5SRU1PVEUgPz8gJ2h0dHBzOi8vYXBwLndpbmRtaWxsLmRldi8nLFxuXHRcdFx0XHRjaGFuZ2VPcmlnaW46IHRydWUsXG5cdFx0XHRcdGNvb2tpZURvbWFpblJld3JpdGU6ICdsb2NhbGhvc3QnXG5cdFx0XHR9LFxuXHRcdFx0J14vd3MvLionOiB7XG5cdFx0XHRcdHRhcmdldDogcHJvY2Vzcy5lbnYuUkVNT1RFX0xTUCA/PyAnaHR0cHM6Ly9hcHAud2luZG1pbGwuZGV2Jyxcblx0XHRcdFx0Y2hhbmdlT3JpZ2luOiB0cnVlLFxuXHRcdFx0XHR3czogdHJ1ZVxuXHRcdFx0fSxcblx0XHRcdCdeL3dzX21wLy4qJzoge1xuXHRcdFx0XHR0YXJnZXQ6IHByb2Nlc3MuZW52LlJFTU9URV9NUCA/PyAnaHR0cHM6Ly9hcHAud2luZG1pbGwuZGV2Jyxcblx0XHRcdFx0Y2hhbmdlT3JpZ2luOiB0cnVlLFxuXHRcdFx0XHR3czogdHJ1ZVxuXHRcdFx0fVxuXHRcdH1cblx0fSxcblx0cHJldmlldzoge1xuXHRcdHBvcnQ6IDMwMDBcblx0fSxcblx0cGx1Z2luczogW1xuXHRcdHN2ZWx0ZWtpdCgpLFxuXHRcdG1vbmFjb0VkaXRvclBsdWdpbi5kZWZhdWx0KHtcblx0XHRcdHB1YmxpY1BhdGg6ICd3b3JrZXJzJyxcblx0XHRcdGxhbmd1YWdlV29ya2VyczogW10sXG5cdFx0XHRjdXN0b21Xb3JrZXJzOiBbXG5cdFx0XHRcdHtcblx0XHRcdFx0XHRsYWJlbDogJ2dyYXBocWwnLFxuXHRcdFx0XHRcdGVudHJ5OiAnbW9uYWNvLWdyYXBocWwvZXNtL2dyYXBocWwud29ya2VyJ1xuXHRcdFx0XHR9XG5cdFx0XHRdXG5cdFx0fSksXG5cdFx0Y2lyY2xlRGVwZW5kZW5jeSh7IGNpcmNsZUltcG9ydFRocm93RXJyOiBmYWxzZSB9KVxuXHRdLFxuXHRkZWZpbmU6IHtcblx0XHRfX3BrZ19fOiB2ZXJzaW9uXG5cdH0sXG5cdG9wdGltaXplRGVwczoge1xuXHRcdGluY2x1ZGU6IFsnaGlnaGxpZ2h0LmpzJywgJ2hpZ2hsaWdodC5qcy9saWIvY29yZSddXG5cdH0sXG5cdHJlc29sdmU6IHtcblx0XHRhbGlhczoge1xuXHRcdFx0cGF0aDogJ3BhdGgtYnJvd3NlcmlmeSdcblx0XHR9LFxuXHRcdGRlZHVwZTogWydtb25hY28tZWRpdG9yJywgJ3ZzY29kZSddXG5cdH0sXG5cdGFzc2V0c0luY2x1ZGU6IFsnKiovKi53YXNtJ11cbn1cblxuZXhwb3J0IGRlZmF1bHQgY29uZmlnXG4iXSwKICAibWFwcGluZ3MiOiAiO0FBQW9QLFNBQVMsaUJBQWlCO0FBQzlRLFNBQVMsb0JBQW9CO0FBQzdCLFNBQVMscUJBQXFCO0FBQzlCLE9BQU8sd0JBQXdCO0FBQy9CLE9BQU8sc0JBQXNCO0FBSnVILElBQU0sMkNBQTJDO0FBTXJNLElBQU0sT0FBTyxjQUFjLElBQUksSUFBSSxnQkFBZ0Isd0NBQWUsQ0FBQztBQUNuRSxJQUFNLE9BQU8sYUFBYSxNQUFNLE1BQU07QUFDdEMsSUFBTSxVQUFVLEtBQUssTUFBTSxJQUFJO0FBRy9CLElBQU0sU0FBUztBQUFBLEVBQ2QsUUFBUTtBQUFBLElBQ1AsTUFBTTtBQUFBLElBQ04sT0FBTztBQUFBLE1BQ04sWUFBWTtBQUFBLFFBQ1gsUUFBUSxRQUFRLElBQUksVUFBVTtBQUFBLFFBQzlCLGNBQWM7QUFBQSxRQUNkLHFCQUFxQjtBQUFBLE1BQ3RCO0FBQUEsTUFDQSxXQUFXO0FBQUEsUUFDVixRQUFRLFFBQVEsSUFBSSxjQUFjO0FBQUEsUUFDbEMsY0FBYztBQUFBLFFBQ2QsSUFBSTtBQUFBLE1BQ0w7QUFBQSxNQUNBLGNBQWM7QUFBQSxRQUNiLFFBQVEsUUFBUSxJQUFJLGFBQWE7QUFBQSxRQUNqQyxjQUFjO0FBQUEsUUFDZCxJQUFJO0FBQUEsTUFDTDtBQUFBLElBQ0Q7QUFBQSxFQUNEO0FBQUEsRUFDQSxTQUFTO0FBQUEsSUFDUixNQUFNO0FBQUEsRUFDUDtBQUFBLEVBQ0EsU0FBUztBQUFBLElBQ1IsVUFBVTtBQUFBLElBQ1YsbUJBQW1CLFFBQVE7QUFBQSxNQUMxQixZQUFZO0FBQUEsTUFDWixpQkFBaUIsQ0FBQztBQUFBLE1BQ2xCLGVBQWU7QUFBQSxRQUNkO0FBQUEsVUFDQyxPQUFPO0FBQUEsVUFDUCxPQUFPO0FBQUEsUUFDUjtBQUFBLE1BQ0Q7QUFBQSxJQUNELENBQUM7QUFBQSxJQUNELGlCQUFpQixFQUFFLHNCQUFzQixNQUFNLENBQUM7QUFBQSxFQUNqRDtBQUFBLEVBQ0EsUUFBUTtBQUFBLElBQ1AsU0FBUztBQUFBLEVBQ1Y7QUFBQSxFQUNBLGNBQWM7QUFBQSxJQUNiLFNBQVMsQ0FBQyxnQkFBZ0IsdUJBQXVCO0FBQUEsRUFDbEQ7QUFBQSxFQUNBLFNBQVM7QUFBQSxJQUNSLE9BQU87QUFBQSxNQUNOLE1BQU07QUFBQSxJQUNQO0FBQUEsSUFDQSxRQUFRLENBQUMsaUJBQWlCLFFBQVE7QUFBQSxFQUNuQztBQUFBLEVBQ0EsZUFBZSxDQUFDLFdBQVc7QUFDNUI7QUFFQSxJQUFPLHNCQUFROyIsCiAgIm5hbWVzIjogW10KfQo=