{translate('::App.Platform.ObjectExplorer')}
@@ -1355,7 +1396,9 @@ GO`,
{/* Center Panel - Editor and Results */}
-
+
{state.executionResult || state.tableColumns ? (
@@ -1405,7 +1448,13 @@ GO`,
-
+
@@ -1461,7 +1510,7 @@ GO`,
-
{translate('::App.SqlQueryManager.MoveFiles')}
+ {translate('::App.SqlQueryManager.MoveFiles')}
diff --git a/ui/src/views/developerKit/SqlResultsGrid.tsx b/ui/src/views/developerKit/SqlResultsGrid.tsx
index cfa4702..4abc625 100644
--- a/ui/src/views/developerKit/SqlResultsGrid.tsx
+++ b/ui/src/views/developerKit/SqlResultsGrid.tsx
@@ -1,45 +1,272 @@
-import { useMemo } from 'react'
+import { useEffect, useMemo, useState } from 'react'
import { DataGrid } from 'devextreme-react'
-import { Column, Paging, Scrolling, SearchPanel, Export, Selection } from 'devextreme-react/data-grid'
+import {
+ Column,
+ Paging,
+ Scrolling,
+ SearchPanel,
+ Export,
+ Selection,
+} from 'devextreme-react/data-grid'
+import { Button, Checkbox, DatePicker, Dialog } from '@/components/ui'
import type { SqlQueryExecutionResultDto } from '@/proxy/sql-query-manager/models'
import { useLocalization } from '@/utils/hooks/useLocalization'
import { FaTimesCircle } from 'react-icons/fa'
+import DateTimepicker from '@/components/ui/DatePicker/DateTimepicker'
+import dayjs from 'dayjs'
+import { sqlObjectManagerService } from '@/services/sql-query-manager.service'
+
+type ResultColumn = { name: string; dataType?: string; isNullable?: boolean }
interface SqlResultsGridProps {
result: SqlQueryExecutionResultDto
+ queryText?: string
+ dataSourceCode?: string | null
+ isPostgreSql?: boolean
+ onExecuteMutation?: (queryText: string) => Promise
}
-const SqlResultsGrid = ({ result }: SqlResultsGridProps) => {
- const { translate } = useLocalization()
+const getEditableTableName = (queryText?: string) => {
+ if (!queryText || /\b(join|union|intersect|except)\b/i.test(queryText)) return null
- const columns = useMemo(() => {
- // Get columns from metadata if available
+ const match = queryText.match(
+ /\bfrom\s+((?:\[[^\]]+\]|"[^"]+"|[\w$]+)(?:\s*\.\s*(?:\[[^\]]+\]|"[^"]+"|[\w$]+)){0,2})/i,
+ )
+ return match?.[1]?.replace(/\s+/g, '') || null
+}
+
+const getTableParts = (fullName: string, isPostgreSql: boolean) => {
+ const parts = fullName
+ .match(/\[[^\]]+\]|"[^"]+"|[\w$]+/g)
+ ?.map((part) => part.replace(/^\[|\]$|^"|"$/g, ''))
+
+ if (!parts?.length) return null
+ return {
+ schemaName: parts.length > 1 ? parts[parts.length - 2] : isPostgreSql ? 'public' : 'dbo',
+ tableName: parts[parts.length - 1],
+ }
+}
+
+const quoteIdentifier = (name: string, isPostgreSql: boolean) =>
+ isPostgreSql ? `"${name.replace(/"/g, '""')}"` : `[${name.replace(/]/g, ']]')}]`
+
+const isBooleanColumn = (column: ResultColumn) =>
+ /bit|bool/.test(column.dataType?.toLowerCase() || '')
+const isNumberColumn = (column: ResultColumn) =>
+ /int|decimal|numeric|float|double|money|real/.test(column.dataType?.toLowerCase() || '')
+const isDateColumn = (column: ResultColumn) =>
+ /date|time|timestamp/.test(column.dataType?.toLowerCase() || '') || /date|time/i.test(column.name)
+const isDateTimeColumn = (column: ResultColumn) =>
+ /time|datetime|datetimeoffset|timestamp/.test(column.dataType?.toLowerCase() || '') ||
+ /time$/i.test(column.name)
+
+const createNewId = () => {
+ if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
+ return crypto.randomUUID()
+ }
+
+ return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (character) => {
+ const random = Math.floor(Math.random() * 16)
+ const value = character === 'x' ? random : (random & 0x3) | 0x8
+ return value.toString(16)
+ })
+}
+
+const toSqlLiteral = (value: unknown, column: ResultColumn) => {
+ if (value === null || value === undefined || value === '') return 'NULL'
+
+ const dataType = column.dataType?.toLowerCase() || ''
+ if (dataType.includes('bit') || dataType.includes('bool')) {
+ return value === true || value === 'true' || value === 1 || value === '1' ? '1' : '0'
+ }
+ if (/int|decimal|numeric|float|double|money|real/.test(dataType)) {
+ const numberValue = Number(value)
+ if (Number.isFinite(numberValue)) return String(numberValue)
+ }
+
+ return `'${String(value).replace(/'/g, "''")}'`
+}
+
+const SqlResultsGrid = ({
+ result,
+ queryText,
+ dataSourceCode,
+ isPostgreSql = false,
+ onExecuteMutation,
+}: SqlResultsGridProps) => {
+ const { translate } = useLocalization()
+ const [selectedRows, setSelectedRows] = useState[]>([])
+ const [focusedRow, setFocusedRow] = useState | null>(null)
+ const [focusedRowKey, setFocusedRowKey] = useState(null)
+ const [editingRow, setEditingRow] = useState | null>(null)
+ const [isAdding, setIsAdding] = useState(false)
+ const [formValues, setFormValues] = useState>({})
+ const [isSaving, setIsSaving] = useState(false)
+ const [validationError, setValidationError] = useState('')
+ const [tableColumns, setTableColumns] = useState([])
+
+ const resultColumns = useMemo(() => {
if (result.metadata?.columns && Array.isArray(result.metadata.columns)) {
return result.metadata.columns
}
-
- // Otherwise infer from data
if (result.data && result.data.length > 0) {
const firstRow = result.data[0]
- return Object.keys(firstRow).map((key) => ({
- name: key,
- dataType: typeof firstRow[key],
+ return Object.keys(firstRow).map((name) => ({
+ name,
+ dataType: typeof firstRow[name],
isNullable: true,
}))
}
-
return []
}, [result])
- const dataSource = useMemo(() => {
- return result.data || []
- }, [result])
+ const dataSource = useMemo(() => result.data || [], [result])
+ const tableName = useMemo(() => getEditableTableName(queryText), [queryText])
+ const tableParts = useMemo(
+ () => (tableName ? getTableParts(tableName, isPostgreSql) : null),
+ [isPostgreSql, tableName],
+ )
+ const columns = resultColumns.length > 0 ? resultColumns : tableColumns
+ const primaryKey = useMemo(
+ () => columns.find((column) => column.name.toLowerCase() === 'id'),
+ [columns],
+ )
+ const canModify = Boolean(tableName && primaryKey && onExecuteMutation)
+
+ useEffect(() => {
+ setTableColumns([])
+ if (resultColumns.length > 0 || !dataSourceCode || !tableParts) return
+
+ let isActive = true
+ sqlObjectManagerService
+ .getTableColumns(dataSourceCode, tableParts.schemaName, tableParts.tableName)
+ .then((response) => {
+ if (!isActive) return
+ setTableColumns(
+ response.data.map((column) => ({
+ name: column.columnName,
+ dataType: column.dataType,
+ isNullable: column.isNullable,
+ })),
+ )
+ })
+ .catch(() => {
+ if (isActive) setTableColumns([])
+ })
+
+ return () => {
+ isActive = false
+ }
+ }, [dataSourceCode, resultColumns.length, tableParts])
+
+ useEffect(() => {
+ const firstRow = dataSource[0]
+ const key = primaryKey && firstRow ? firstRow[primaryKey.name] : null
+ setFocusedRow(firstRow || null)
+ setFocusedRowKey(typeof key === 'string' || typeof key === 'number' ? key : null)
+ }, [dataSource, primaryKey])
+
+ const openAddDialog = () => {
+ setEditingRow(null)
+ setIsAdding(true)
+ const initialValues: Record = {}
+ columns.filter(isBooleanColumn).forEach((column) => {
+ initialValues[column.name] = false
+ })
+ if (primaryKey) initialValues[primaryKey.name] = createNewId()
+ setFormValues(initialValues)
+ setValidationError('')
+ }
+
+ const openEditDialog = () => {
+ const row = focusedRow
+ if (!row) return
+ setEditingRow(row)
+ setIsAdding(false)
+ setFormValues(
+ Object.fromEntries(
+ columns.map((column) => [
+ column.name,
+ isBooleanColumn(column) ? Boolean(row[column.name]) : String(row[column.name] ?? ''),
+ ]),
+ ),
+ )
+ setValidationError('')
+ }
+
+ const closeDialog = () => {
+ if (isSaving) return
+ setEditingRow(null)
+ setIsAdding(false)
+ setFormValues({})
+ setValidationError('')
+ }
+
+ const executeMutation = async (sql: string) => {
+ if (!onExecuteMutation) return
+ setIsSaving(true)
+ try {
+ await onExecuteMutation(sql)
+ setSelectedRows([])
+ setFocusedRow(null)
+ setEditingRow(null)
+ setIsAdding(false)
+ setFormValues({})
+ } catch {
+ // The parent reports the execution error; keep the dialog open for correction.
+ } finally {
+ setIsSaving(false)
+ }
+ }
+
+ const saveRecord = async () => {
+ if (!tableName) return
+
+ const requiredColumns = columns.filter(
+ (column) => column.isNullable === false && (isAdding || column.name !== primaryKey?.name),
+ )
+ const missingRequiredColumn = requiredColumns.find((column) => {
+ const value = formValues[column.name]
+ return value === undefined || value === null || value === ''
+ })
+ if (missingRequiredColumn) {
+ setValidationError(`${missingRequiredColumn.name} is required.`)
+ return
+ }
+
+ if (isAdding) {
+ const populatedColumns = columns.filter(
+ (column) => formValues[column.name] !== '' && formValues[column.name] !== undefined,
+ )
+ if (populatedColumns.length === 0) return
+ const sql = `INSERT INTO ${tableName} (${populatedColumns.map((column) => quoteIdentifier(column.name, isPostgreSql)).join(', ')}) VALUES (${populatedColumns.map((column) => toSqlLiteral(formValues[column.name], column)).join(', ')});`
+ await executeMutation(sql)
+ return
+ }
+
+ if (!editingRow || !primaryKey) return
+ const editableColumns = columns.filter((column) => column.name !== primaryKey.name)
+ const sql = `UPDATE ${tableName} SET ${editableColumns.map((column) => `${quoteIdentifier(column.name, isPostgreSql)} = ${toSqlLiteral(formValues[column.name], column)}`).join(', ')} WHERE ${quoteIdentifier(primaryKey.name, isPostgreSql)} = ${toSqlLiteral(editingRow[primaryKey.name], primaryKey)};`
+ await executeMutation(sql)
+ }
+
+ const deleteSelectedRows = async () => {
+ if (!tableName || !primaryKey || selectedRows.length === 0) return
+ if (!window.confirm(`Delete ${selectedRows.length} selected record(s)?`)) return
+
+ const values = selectedRows
+ .map((row) => toSqlLiteral(row[primaryKey.name], primaryKey))
+ .join(', ')
+ await executeMutation(
+ `DELETE FROM ${tableName} WHERE ${quoteIdentifier(primaryKey.name, isPostgreSql)} IN (${values});`,
+ )
+ }
if (!result.success) {
return (
-
-
-
+
+
+
{translate('::App.Platform.Error')}
@@ -54,61 +281,116 @@ const SqlResultsGrid = ({ result }: SqlResultsGridProps) => {
}
return (
-
- {dataSource.length > 0 ? (
-
-
-
-
-
-
-
-
+
+ {dataSource.length > 0 || columns.length > 0 ? (
+ <>
+
+
+
setSelectedRows(event.selectedRowsData)}
+ onFocusedRowChanged={(event) => {
+ setFocusedRow(event.row?.data || null)
+ setFocusedRowKey(
+ typeof event.row?.key === 'string' || typeof event.row?.key === 'number'
+ ? event.row.key
+ : null,
+ )
+ }}
+ onToolbarPreparing={(event) => {
+ const items = event.toolbarOptions.items || []
+ items.forEach((item) => {
+ if (item.name === 'searchPanel' || item.name === 'exportButton') {
+ item.location = 'after'
+ }
+ })
+ if (canModify) {
+ event.toolbarOptions.items = [
+ {
+ location: 'before',
+ widget: 'dxButton',
+ options: {
+ icon: 'add',
+ text: translate('::ListForms.ListForm.AddNewRecord'),
+ onClick: openAddDialog,
+ },
+ },
+ {
+ location: 'before',
+ widget: 'dxButton',
+ options: {
+ icon: 'edit',
+ text: translate('::App.Platform.Edit'),
+ disabled: !focusedRow,
+ onClick: openEditDialog,
+ },
+ },
+ {
+ location: 'before',
+ widget: 'dxButton',
+ options: {
+ icon: 'trash',
+ text: `Delete Selected (${selectedRows.length})`,
+ disabled: selectedRows.length === 0,
+ onClick: deleteSelectedRows,
+ },
+ },
+ ...items,
+ ]
+ }
+ }}
+ >
+
+
+
+
+
- {columns.map((col, index) => (
-
- ))}
-
+ {columns.map((column, index) => (
+
+ ))}
+
+
-
+ >
) : (
-
+
-
{translate('::App.Platform.NoResults')}
+
{translate('::App.Platform.NoResults')}
{result.rowsAffected > 0
? translate('::App.Platform.RowCount', { count: result.rowsAffected })
@@ -117,6 +399,100 @@ const SqlResultsGrid = ({ result }: SqlResultsGridProps) => {
)}
+
+
)
}