feat(frontend): DB Studio improvements (#3389)

* fix(frontend): wip

* fix(frontend): validate column def

* fix(frontend): add column definition loading sate

* fix(frontend): only reload columns if static

* fix(frontend): improve validation

* fix(frontend): improve reactivity

* fix(frontend): fix colum defs sync

* fix(frontend): fix colum defs sync + insert

* fix(frontend): fix insert

* fix(frontend): fix

* fix(frontend): fix insert

* fix(frontend): fix insert

* fix(frontend): fix hideSearch + hideInsert

* fix(frontend): fix early return

* fix(frontend): fix delete + policy

* fix(frontend): fix delete

* fix(frontend): restrict resource + table to static only
This commit is contained in:
Faton Ramadani
2024-03-12 17:42:32 +01:00
committed by GitHub
parent cda5e056f5
commit 212c9d76e5
10 changed files with 235 additions and 73 deletions
@@ -57,6 +57,10 @@
return
}
if (lastTable && !table) {
lastTable = undefined
}
const gridItem = findGridItem($app, id)
if (!gridItem) {
@@ -64,7 +68,7 @@
}
// @ts-ignore
gridItem.data.configuration.columnDefs = { value: [], type: 'static' }
gridItem.data.configuration.columnDefs = { value: [], type: 'static', loading: false }
$app = {
...$app
@@ -319,6 +323,32 @@
let lastTable: string | undefined = undefined
let timeout: NodeJS.Timeout | undefined = undefined
function isSubset(subset: Record<string, any>, superset: Record<string, any>) {
return Object.keys(subset).every((key) => {
return superset[key] === subset[key]
})
}
function shouldReturnEarly(subset: Record<string, any>, superset: Record<string, any>): boolean {
const subsetKeys = Object.keys(subset)
const supersetKeys = Object.keys(superset)
if (supersetKeys.length === 0) return false
if (subsetKeys.length !== supersetKeys.length) {
return false
}
if (
JSON.stringify(supersetKeys.sort()) === JSON.stringify(subsetKeys.sort()) &&
!subsetKeys.every((key) => isSubset(subset[key], superset[key]))
) {
return false
}
return true
}
async function listColumnsIfAvailable() {
const selected = resolvedConfig.type.selected
let table = resolvedConfig.type.configuration?.[resolvedConfig.type.selected]?.table
@@ -327,6 +357,18 @@
lastTable = table
const gridItem = findGridItem($app, id)
if (!gridItem) return
let columnDefs = gridItem.data.configuration.columnDefs as StaticInput<TableMetadata>
if (columnDefs.type !== 'static') return
//@ts-ignore
gridItem.data.configuration.columnDefs.loading = true
gridItem.data = gridItem.data
$app = $app
let tableMetadata = await loadTableMetaData(
resolvedConfig.type.configuration[selected].resource,
$workspaceStore,
@@ -336,23 +378,21 @@
if (!tableMetadata) return
const gridItem = findGridItem($app, id)
if (!gridItem) return
let columnDefs = gridItem.data.configuration.columnDefs as StaticInput<TableMetadata>
let old: TableMetadata = (columnDefs?.value as TableMetadata) ?? []
if (!Array.isArray(old)) {
console.log('old is not an array RESET')
old = []
}
// console.log('OLD', old)
// console.log(tableMetadata)
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)) {
if (shouldReturnEarly(newMap, oldMap)) {
//@ts-ignore
gridItem.data.configuration.columnDefs.loading = false
gridItem.data = gridItem.data
$app = $app
return
}
@@ -375,7 +415,21 @@
ncols = ncols.map((x) => {
let o = {}
Object.keys(x).forEach((k) => {
o[k.toLowerCase()] = x[k]
if (
[
'field',
'datatype',
'defaultvalue',
'isprimarykey',
'isidentity',
'isnullable',
'isenum'
].includes(k.toLocaleLowerCase())
) {
o[k.toLowerCase()] = x[k]
} else {
o[k] = x[k]
}
})
return o
})
@@ -383,7 +437,7 @@
state = undefined
//@ts-ignore
gridItem.data.configuration.columnDefs = { value: ncols, type: 'static' }
gridItem.data.configuration.columnDefs = { value: ncols, type: 'static', loading: false }
gridItem.data = gridItem.data
$app = $app
@@ -415,7 +469,8 @@
id: 'dbexplorer-count-' + id,
next: (value) => {
if (value?.error) {
sendUserToast(value.error, true)
const message = value?.error?.message ?? value?.error
sendUserToast(message, true)
return
}
@@ -445,6 +500,7 @@
async function insert() {
try {
const selected = resolvedConfig.type.selected
await insertRowRunnable?.insertRow(
resolvedConfig.type.configuration[selected].resource,
$workspaceStore,
@@ -471,21 +527,22 @@
function onDelete(e) {
const data = { ...e.detail }
delete data['__index']
let primaryColumns = getPrimaryKeys(resolvedConfig.columnDefs)
let getPrimaryKeysresolvedConfig = resolvedConfig.columnDefs?.filter((x) =>
primaryColumns.includes(x.field)
)
const selected = resolvedConfig.type.selected
deleteRow?.triggerDelete(
resolvedConfig.type.configuration[selected].resource,
resolvedConfig.type.configuration[selected].table ?? 'unknown',
getPrimaryKeysresolvedConfig,
resolvedConfig.columnDefs,
data,
selected
)
}
let refreshCount = 0
$: hideSearch = resolvedConfig.hideSearch as boolean
$: hideInsert = resolvedConfig.hideInsert as boolean
</script>
{#each Object.keys(components['dbexplorercomponent'].initialData.configuration) as key (key)}
@@ -543,25 +600,31 @@
{outputs}
>
<div class="h-full" bind:clientHeight={componentContainerHeight}>
<div class="flex p-2 justify-between gap-4" bind:clientHeight={buttonContainerHeight}>
<DebouncedInput
class="w-full max-w-[300px]"
type="text"
bind:value={quicksearch}
placeholder="Quicksearch"
/>
<Button
startIcon={{ icon: Plus }}
color="dark"
size="xs2"
on:click={() => {
args = {}
insertDrawer?.openDrawer()
}}
>
Insert
</Button>
</div>
{#if !(hideSearch === true && hideInsert === true)}
<div class="flex p-2 justify-between gap-4" bind:clientHeight={buttonContainerHeight}>
{#if hideSearch !== true}
<DebouncedInput
class="w-full max-w-[300px]"
type="text"
bind:value={quicksearch}
placeholder="Quicksearch"
/>
{/if}
{#if hideInsert !== true}
<Button
startIcon={{ icon: Plus }}
color="dark"
size="xs"
on:click={() => {
args = {}
insertDrawer?.openDrawer()
}}
>
Insert
</Button>
{/if}
</div>
{/if}
{#if resolvedConfig.type.configuration?.[resolvedConfig?.type?.selected]?.resource && resolvedConfig.type.configuration?.[resolvedConfig?.type?.selected]?.table}
<!-- {JSON.stringify(lastInput)} -->
<!-- <span class="text-xs">{JSON.stringify(configuration.columnDefs)}</span> -->
@@ -576,7 +639,7 @@
{customCss}
{outputs}
allowDelete={resolvedConfig.allowDelete ?? false}
containerHeight={componentContainerHeight - buttonContainerHeight}
containerHeight={componentContainerHeight - (buttonContainerHeight ?? 0)}
on:update={onUpdate}
on:delete={onDelete}
/>
@@ -5,7 +5,7 @@
import type RunnableComponent from '../../helpers/RunnableComponent.svelte'
import RunnableWrapper from '../../helpers/RunnableWrapper.svelte'
import { initOutput } from '../../../editor/appUtils'
import { type ColumnDef, type DbType } from './utils'
import { getPrimaryKeys, type ColumnDef, type DbType } from './utils'
import { sendUserToast } from '$lib/toast'
import { getDeleteInput } from './queries/delete'
@@ -29,11 +29,12 @@
export async function triggerDelete(
resource: string,
table: string,
columns: ColumnDef[],
allColumns: ColumnDef[],
data: Record<string, any>,
dbType: DbType
) {
// const datatype = tableMetaData?.find((column) => column.isprimarykey)?.datatype
let primaryColumns = getPrimaryKeys(allColumns)
let columns = allColumns?.filter((x) => primaryColumns.includes(x.field))
input = getDeleteInput(resource, table, columns, dbType)
@@ -100,7 +100,6 @@
const args = await parseSQLArgs(insertCode, dbType)
fields.forEach((field) => {
console.log(field)
const schemaProperty: SchemaProperty = {
type: 'string'
}
@@ -111,7 +110,7 @@
}
if (field.defaultValue) {
if (schemaProperty.type === 'number') {
if (schemaProperty.type === 'number' || schemaProperty.type === 'integer') {
schemaProperty.default = field.defaultValue ? Number(field.defaultValue) : undefined
} else if (schemaProperty.type === 'boolean') {
schemaProperty.default = field.defaultValue?.toLocaleLowerCase() === 'true'
@@ -1,7 +1,7 @@
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) {
function deleteWithAllValues(table: string, columns: ColumnDef[], dbType: DbType) {
let query = buildParameters(columns, dbType)
switch (dbType) {
@@ -48,11 +48,11 @@ export function getDeleteInput(
return undefined
}
const updateRunnable: RunnableByName = {
const deleteRunnable: RunnableByName = {
name: 'AppDbExplorer',
type: 'runnableByName',
inlineScript: {
content: updateWithAllValues(table, columns, dbType),
content: deleteWithAllValues(table, columns, dbType),
language: getLanguageByResourceType(dbType),
schema: {
$schema: 'https://json-schema.org/draft/2020-12/schema',
@@ -63,8 +63,8 @@ export function getDeleteInput(
}
}
const updateQuery: AppInput = {
runnable: updateRunnable,
const deleteQuery: AppInput = {
runnable: deleteRunnable,
fields: {
database: {
type: 'static',
@@ -77,5 +77,5 @@ export function getDeleteInput(
fieldType: 'object'
}
return updateQuery
return deleteQuery
}
@@ -1,13 +1,27 @@
import type { AppInput } from '$lib/components/apps/inputType'
import { buildParameters, type DbType } from '../utils'
import { buildParameters, ColumnIdentity, type DbType } from '../utils'
import { getLanguageByResourceType, type ColumnDef } from '../utils'
function mapDataTypeToDbType(dataType: string): string {
if (dataType === 'integer') {
return 'int'
}
if (dataType === 'boolean') {
return 'bool'
}
return dataType
}
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(', ')
return columns
.map((c, i) => `$${startIndex + i}::${mapDataTypeToDbType(c.datatype)}`)
.join(', ')
case 'ms_sql_server':
return columns.map((c, i) => `@p${startIndex + i}`).join(', ')
case 'snowflake':
@@ -23,36 +37,72 @@ function formatColumnNames(columns: ColumnDef[]): string {
return columns.map((c) => c.field).join(', ')
}
function getUserDefaultValue(column: ColumnDef) {
if (column.defaultValueNull) {
return 'NULL'
} else if (column.defaultUserValue) {
return typeof column.defaultUserValue === 'string'
? `'${column.defaultUserValue}'`
: column.defaultUserValue
}
}
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
const userDefaultValue = getUserDefaultValue(c)
if (c.overrideDefaultValue === true) {
return userDefaultValue
}
return userDefaultValue ?? c.defaultvalue
})
.join(', ')
return defaultValues
}
function shouldOmitColumnInInsert(column: ColumnDef) {
if (!column.hideInsert || column.isidentity === ColumnIdentity.Always) {
return true
}
const userDefaultValue =
(column.defaultUserValue !== undefined && column.defaultUserValue !== '') ||
column.defaultValueNull === true
const dbDefaultValue = Boolean(column.defaultvalue)
if (column.isnullable === 'NO') {
if (!userDefaultValue && !dbDefaultValue && column.isidentity === ColumnIdentity.No) {
throw new Error(`Column ${column.field} is not nullable and has no default value`)
}
if (!userDefaultValue && !dbDefaultValue) {
// Should be omitted if it's an identity column and we have no default value
return column.isidentity !== ColumnIdentity.No
}
// Should be omitted if the user had not provided a default value and the database has a default value
return !userDefaultValue && dbDefaultValue
} else if (column.isnullable === 'YES') {
return !userDefaultValue
}
return false
}
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 columnsDefault = columns.filter((c) => !shouldOmitColumnInInsert(c))
const allInsertColumns = columnsInsert.concat(columnsDefault)
let query = buildParameters(columnsInsert, dbType)
query += '\n'
const shouldInsertComma = columnsDefault.length > 0 && columnsInsert.length > 0
const shouldInsertComma = columnsDefault.length > 0
const columnNames = formatColumnNames(allInsertColumns)
const insertValues = formatInsertValues(columnsInsert, dbType)
const defaultValues = formatDefaultValues(columnsDefault)
@@ -1,6 +1,6 @@
<script lang="ts">
import { GridApi, createGrid, type IDatasource } from 'ag-grid-community'
import { isObject } from '$lib/utils'
import { isObject, sendUserToast } from '$lib/utils'
import { createEventDispatcher, getContext } from 'svelte'
import type { AppViewerContext, ComponentCustomCSS } from '../../../types'
@@ -18,6 +18,7 @@
import { Button } from '$lib/components/common'
import { cellRendererFactory } from './utils'
import { Trash2 } from 'lucide-svelte'
import type { ColumnDef } from '../dbtable/utils'
// import 'ag-grid-community/dist/styles/ag-theme-alpine-dark.css'
export let id: string
@@ -125,6 +126,13 @@
$: eGui && mountGrid()
function transformColumnDefs(columnDefs: any[]) {
const { isValid, errors } = validateColumnDefs(columnDefs)
if (!isValid) {
sendUserToast(`Invalid columnDefs: ${errors.join('\n')}`, true)
return []
}
let r = columnDefs?.filter((x) => x && !x.ignored) ?? []
if (allowDelete) {
r.push({
@@ -161,6 +169,23 @@
let firstRow = 0
let lastRow = 0
function validateColumnDefs(columnDefs: ColumnDef[]): { isValid: boolean; errors: string[] } {
let isValid = true
const errors: string[] = []
// Validate each column definition
columnDefs.forEach((colDef, index) => {
// Check if 'field' property exists and is a non-empty string
if (!colDef.field || typeof colDef.field !== 'string' || colDef.field.trim() === '') {
isValid = false
errors.push(`Column at index ${index} is missing a valid 'field' property.`)
}
})
return { isValid, errors }
}
function mountGrid() {
if (eGui) {
createGrid(
@@ -216,14 +216,15 @@
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))
r.push({
input: getDeleteInput(resourceValue, tableValue, columns, dbType),
id: x.id + '_delete'
})
columnDefs
.filter((col) => col.editable || config.allEditable.value)
.forEach((column) => {
@@ -2521,7 +2521,6 @@ See date-fns format for more information. By default, it is 'dd.MM.yyyy HH:mm'
timingFunction: {
fieldType: 'select',
type: 'static',
selectOptions: selectOptions.animationTimingFunctionOptions,
value: 'linear',
tooltip:
@@ -3369,14 +3368,16 @@ See date-fns format for more information. By default, it is 'dd.MM.yyyy HH:mm'
type: 'static',
fieldType: 'resource',
subFieldType: 'postgres',
value: ''
value: '',
allowTypeChange: false
} as StaticAppInput,
table: {
fieldType: 'select',
subFieldType: 'db-table',
type: 'static',
selectOptions: [],
value: undefined
value: undefined,
allowTypeChange: false
}
},
mysql: {
@@ -3445,7 +3446,8 @@ See date-fns format for more information. By default, it is 'dd.MM.yyyy HH:mm'
type: 'static',
fieldType: 'array',
subFieldType: 'db-explorer',
value: []
value: [],
loading: false
} as StaticAppInput,
whereClause: {
type: 'static',
@@ -3498,6 +3500,18 @@ See date-fns format for more information. By default, it is 'dd.MM.yyyy HH:mm'
fieldType: 'object',
value: {},
tooltip: 'any configuration that can be passed to ag-grid top level'
},
hideInsert: {
type: 'static',
fieldType: 'boolean',
value: false,
tooltip: 'Hide the insert button'
},
hideSearch: {
type: 'static',
fieldType: 'boolean',
value: false,
tooltip: 'Hide the search bar'
}
},
componentInput: undefined
@@ -1,6 +1,6 @@
<script lang="ts">
import { Button } from '$lib/components/common'
import { GripVertical, Plus, X } from 'lucide-svelte'
import { GripVertical, Loader2, Plus, X } from 'lucide-svelte'
import { createEventDispatcher, onMount } from 'svelte'
import type { InputType, StaticInput, StaticOptions } from '../../inputType'
import SubTypeEditor from './SubTypeEditor.svelte'
@@ -10,7 +10,7 @@
import Toggle from '$lib/components/Toggle.svelte'
import QuickAddColumn from './QuickAddColumn.svelte'
export let componentInput: StaticInput<any[]>
export let componentInput: StaticInput<any[]> & { loading?: boolean }
export let subFieldType: InputType | undefined = undefined
export let selectOptions: StaticOptions['selectOptions'] | undefined = undefined
export let id: string | undefined
@@ -320,6 +320,14 @@
{/each}
</section>
{/if}
{#if subFieldType === 'db-explorer'}
{#if componentInput.loading}
<div class="flex flex-row gap-2 w-full items-center text-xs">
<Loader2 class="animate-spin" size={14} />
Loading columns defintions...
</div>
{/if}
{/if}
{#if subFieldType !== 'db-explorer'}
<Button size="xs" color="light" startIcon={{ icon: Plus }} on:click={() => addElementByType()}>
Add
@@ -102,6 +102,7 @@
fileUpload={config?.['fileUpload']}
loading={config?.['loading']}
documentationLink={config?.['documentationLink']}
allowTypeChange={config?.['allowTypeChange']}
{showOnDemandOnlyToggle}
/>
{/if}