Merge pull request #1404 from dbgate/feature/fetch-all-button

Add fetch all button
This commit is contained in:
Jan Prochazka
2026-04-01 09:44:04 +02:00
committed by GitHub
10 changed files with 1414 additions and 662 deletions

View File

@@ -1,5 +1,8 @@
const { filterName } = require('dbgate-tools');
const { filterName, getLogger, extractErrorLogData } = require('dbgate-tools');
const logger = getLogger('jsldata');
const { jsldir, archivedir } = require('../utility/directories');
const fs = require('fs');
const path = require('path');
const lineReader = require('line-reader');
const _ = require('lodash');
const { __ } = require('lodash/fp');
@@ -149,6 +152,10 @@ module.exports = {
getRows_meta: true,
async getRows({ jslid, offset, limit, filters, sort, formatterFunction }) {
const fileName = getJslFileName(jslid);
if (!fs.existsSync(fileName)) {
return [];
}
const datastore = await this.ensureDatastore(jslid, formatterFunction);
return datastore.getRows(offset, limit, _.isEmpty(filters) ? null : filters, _.isEmpty(sort) ? null : sort);
},
@@ -159,6 +166,72 @@ module.exports = {
return fs.existsSync(fileName);
},
streamRows_meta: {
method: 'get',
raw: true,
},
streamRows(req, res) {
const { jslid } = req.query;
if (!jslid) {
res.status(400).json({ apiErrorMessage: 'Missing jslid' });
return;
}
// Reject file:// jslids — they resolve to arbitrary server-side paths
if (jslid.startsWith('file://')) {
res.status(403).json({ apiErrorMessage: 'Forbidden jslid scheme' });
return;
}
const fileName = getJslFileName(jslid);
if (!fs.existsSync(fileName)) {
res.status(404).json({ apiErrorMessage: 'File not found' });
return;
}
// Dereference symlinks and normalize case (Windows) before the allow-list check.
// realpathSync is safe here because existsSync confirmed the file is present.
// path.resolve() alone cannot dereference symlinks, so a symlink inside an allowed
// root could otherwise point to an arbitrary external path.
const normalize = p => (process.platform === 'win32' ? p.toLowerCase() : p);
const resolveRoot = r => { try { return fs.realpathSync(r); } catch { return path.resolve(r); } };
let realFile;
try {
realFile = fs.realpathSync(fileName);
} catch {
res.status(403).json({ apiErrorMessage: 'Forbidden path' });
return;
}
const allowedRoots = [jsldir(), archivedir()].map(r => normalize(resolveRoot(r)) + path.sep);
const isAllowed = allowedRoots.some(root => normalize(realFile).startsWith(root));
if (!isAllowed) {
logger.warn({ jslid, realFile }, 'DBGM-00000 streamRows rejected path outside allowed roots');
res.status(403).json({ apiErrorMessage: 'Forbidden path' });
return;
}
res.setHeader('Content-Type', 'application/x-ndjson');
res.setHeader('Cache-Control', 'no-cache');
const stream = fs.createReadStream(realFile, 'utf-8');
req.on('close', () => {
stream.destroy();
});
stream.on('error', err => {
logger.error(extractErrorLogData(err), 'DBGM-00000 Error streaming JSONL file');
if (!res.headersSent) {
res.status(500).json({ apiErrorMessage: 'Stream error' });
} else {
res.end();
}
});
stream.pipe(res);
},
getStats_meta: true,
getStats({ jslid }) {
const file = `${getJslFileName(jslid)}.stats`;

View File

@@ -26,6 +26,18 @@
onClick: () => getCurrentDataGrid().deepRefresh(),
});
registerCommand({
id: 'dataGrid.fetchAll',
category: __t('command.datagrid', { defaultMessage: 'Data grid' }),
name: __t('command.datagrid.fetchAll', { defaultMessage: 'Fetch all rows' }),
toolbarName: __t('command.datagrid.fetchAll.toolbar', { defaultMessage: 'Fetch all' }),
icon: 'icon download',
toolbar: true,
isRelatedToTab: true,
testEnabled: () => getCurrentDataGrid()?.canFetchAll(),
onClick: () => getCurrentDataGrid().fetchAll(),
});
registerCommand({
id: 'dataGrid.revertRowChanges',
category: __t('command.datagrid', { defaultMessage: 'Data grid' }),
@@ -432,6 +444,7 @@
import CollapseButton from './CollapseButton.svelte';
import GenerateSqlFromDataModal from '../modals/GenerateSqlFromDataModal.svelte';
import { showModal } from '../modals/modalTools';
import FetchAllConfirmModal from '../modals/FetchAllConfirmModal.svelte';
import StatusBarTabItem from '../widgets/StatusBarTabItem.svelte';
import { findCommand } from '../commands/runCommand';
import { openJsonDocument } from '../tabs/JsonTab.svelte';
@@ -454,6 +467,7 @@
import macros from '../macro/macros';
export let onLoadNextData = undefined;
export let onFetchAllRows = undefined;
export let grider = undefined;
export let display: GridDisplay = undefined;
export let conid = undefined;
@@ -473,6 +487,9 @@
export let errorMessage = undefined;
export let pureName = undefined;
export let schemaName = undefined;
export let isFetchingAll = false;
export let isFetchingFromDb = false;
export let fetchAllLoadedCount = 0;
export let allowDefineVirtualReferences = false;
export let formatterFunction;
export let passAllRows = null;
@@ -647,6 +664,21 @@
return canRefresh() && !!conid && !!database;
}
export function canFetchAll() {
return !!onFetchAllRows && !isLoadedAll && !isFetchingAll && !isLoading;
}
export function fetchAll() {
if (!canFetchAll()) return;
const settings = $settingsValue || {};
if (settings['dataGrid.skipFetchAllConfirm']) {
onFetchAllRows();
} else {
showModal(FetchAllConfirmModal, { onConfirm: () => onFetchAllRows() });
}
}
export async function deepRefresh() {
callUnsubscribeDbRefresh();
await apiCall('database-connections/sync-model', { conid, database });
@@ -1977,6 +2009,7 @@
registerMenu(
{ command: 'dataGrid.refresh' },
{ command: 'dataGrid.fetchAll', hideDisabled: true },
{ placeTag: 'copy' },
{
text: _t('datagrid.copyAdvanced', { defaultMessage: 'Copy advanced' }),
@@ -2404,11 +2437,7 @@
</div>
{:else if allRowCountError && multipleGridsOnTab}
<!-- svelte-ignore a11y-click-events-have-key-events -->
<div
class="row-count-label row-count-error"
title={allRowCountError}
on:click={onReloadRowCount}
>
<div class="row-count-label row-count-error" title={allRowCountError} on:click={onReloadRowCount}>
{_t('datagrid.rows', { defaultMessage: 'Rows' })}: {_t('datagrid.rowCountMany', { defaultMessage: 'Many' })}
</div>
{/if}
@@ -2417,6 +2446,18 @@
<LoadingInfo wrapper message="Loading data" />
{/if}
{#if isFetchingAll}
<LoadingInfo
wrapper
message={isFetchingFromDb
? _t('datagrid.fetchAll.progressDb', { defaultMessage: 'Fetching data from database...' })
: _t('datagrid.fetchAll.progress', {
defaultMessage: 'Fetching all rows... {count} loaded',
values: { count: fetchAllLoadedCount.toLocaleString() },
})}
/>
{/if}
{#if !tabControlHiddenTab && !multipleGridsOnTab && allRowCount != null}
<StatusBarTabItem text={`${_t('datagrid.rows', { defaultMessage: 'Rows' })}: ${allRowCount.toLocaleString()}`} />
{:else if !tabControlHiddenTab && !multipleGridsOnTab && allRowCountError}

View File

@@ -1,14 +1,18 @@
<script lang="ts">
import { getIntSettingsValue } from '../settings/settingsTools';
import { onDestroy } from 'svelte';
import createRef from '../utility/createRef';
import { useSettings } from '../utility/metadataLoaders';
import { fetchAll, type FetchAllHandle } from '../utility/fetchAll';
import { apiCall } from '../utility/api';
import DataGridCore from './DataGridCore.svelte';
export let loadDataPage;
export let dataPageAvailable;
export let loadRowCount;
export let startFetchAll = null;
export let grider;
export let display;
export let masterLoadedTime = undefined;
@@ -29,6 +33,12 @@
let errorMessage = null;
let domGrid;
let isFetchingAll = false;
let isFetchingFromDb = false;
let fetchAllLoadedCount = 0;
let fetchAllHandle: FetchAllHandle | null = null;
let readerJslid: string | null = null;
const loadNextDataRef = createRef(false);
const loadedTimeRef = createRef(null);
@@ -96,11 +106,161 @@
// console.log('LOADED', nextRows, loadedRows);
}
async function fetchAllRows() {
if (isFetchingAll || isLoadedAll) return;
const jslid = ($$props as any).jslid;
if (jslid) {
// Already have a JSONL file (e.g. query tab) — read directly
fetchAllViaJslid(jslid);
} else if (startFetchAll) {
// SQL/table grid: execute full query → stream to JSONL → read from it
fetchAllViaReader();
} else {
fetchAllRowsLegacy();
}
}
function stopReader() {
if (readerJslid) {
apiCall('sessions/stop-loading-reader', { jslid: readerJslid });
readerJslid = null;
}
}
async function fetchAllViaReader() {
isFetchingAll = true;
isFetchingFromDb = true;
fetchAllLoadedCount = loadedRows.length;
errorMessage = null;
// Token guards against a reload/destroy that happens while we await startFetchAll.
// loadedTimeRef is already updated by reload(), so we reuse it as our token.
const token = loadedTime;
let jslid;
try {
jslid = await startFetchAll($$props);
} catch (err) {
if (loadedTime !== token) return; // reload() already reset state
errorMessage = err?.message ?? 'Failed to start data reader';
isFetchingAll = false;
isFetchingFromDb = false;
return;
}
// If reload()/onDestroy ran while we were awaiting, discard the result and
// immediately stop the reader that was just started on the server.
if (loadedTime !== token) {
if (jslid) apiCall('sessions/stop-loading-reader', { jslid });
return;
}
if (!jslid) {
errorMessage = 'Failed to start data reader';
isFetchingAll = false;
isFetchingFromDb = false;
return;
}
readerJslid = jslid;
fetchAllViaJslid(jslid);
}
function fetchAllViaJslid(jslid: string) {
if (!isFetchingAll) {
isFetchingAll = true;
fetchAllLoadedCount = loadedRows.length;
errorMessage = null;
}
const pageSize = getIntSettingsValue('dataGrid.pageSize', 100, 5, 50000);
const buffer: any[] = [];
const jslLoadDataPage = async (offset: number, limit: number) => {
return apiCall('jsldata/get-rows', { jslid, offset, limit });
};
fetchAllHandle = fetchAll(
jslid,
jslLoadDataPage,
{
onPage(rows) {
if (rows.length > 0) isFetchingFromDb = false;
const processed = preprocessLoadedRow ? rows.map(preprocessLoadedRow) : rows;
buffer.push(...processed);
fetchAllLoadedCount = buffer.length;
},
onFinished() {
loadedRows = buffer;
isLoadedAll = true;
isFetchingAll = false;
isFetchingFromDb = false;
fetchAllHandle = null;
readerJslid = null;
if (allRowCount == null && !isRawMode) handleLoadRowCount();
},
onError(msg) {
errorMessage = msg;
isFetchingAll = false;
isFetchingFromDb = false;
fetchAllHandle = null;
stopReader();
},
},
pageSize
);
}
async function fetchAllRowsLegacy() {
isFetchingAll = true;
fetchAllLoadedCount = loadedRows.length;
errorMessage = null;
const pageSize = getIntSettingsValue('dataGrid.pageSize', 100, 5, 50000);
const fetchStart = new Date().getTime();
loadedTimeRef.set(fetchStart);
// Accumulate into a local buffer to avoid O(n²) full-array copies each iteration.
const buffer = [...loadedRows];
try {
while (!isLoadedAll) {
const nextRows = await loadDataPage($$props, buffer.length, pageSize);
if (loadedTimeRef.get() !== fetchStart) {
// a reload was triggered; abort without overwriting loadedRows with stale data
return;
}
if (nextRows.errorMessage) {
errorMessage = nextRows.errorMessage;
break;
}
if (nextRows.length === 0) {
isLoadedAll = true;
break;
}
const processed = preprocessLoadedRow ? nextRows.map(preprocessLoadedRow) : nextRows;
buffer.push(...processed);
fetchAllLoadedCount = buffer.length;
}
// Single assignment triggers Svelte reactivity once for all accumulated rows.
loadedRows = buffer;
if (allRowCount == null && !isRawMode) handleLoadRowCount();
} finally {
isFetchingAll = false;
}
}
// $: griderProps = { ...$$props, sourceRows: loadProps.loadedRows };
// $: grider = griderFactory(griderProps);
function handleLoadNextData() {
if (!isLoadedAll && !errorMessage && (!grider.disableLoadNextPage || loadedRows.length == 0)) {
if (!isLoadedAll && !errorMessage && !isFetchingAll && (!grider.disableLoadNextPage || loadedRows.length == 0)) {
if (dataPageAvailable($$props)) {
// If not, callbacks to load missing metadata are dispatched
loadNextData();
@@ -109,14 +269,23 @@
}
function reload() {
if (fetchAllHandle) {
fetchAllHandle.cancel();
fetchAllHandle = null;
}
stopReader();
isFetchingFromDb = false;
allRowCount = null;
allRowCountError = null;
isLoading = false;
isFetchingAll = false;
fetchAllLoadedCount = 0;
loadedRows = [];
isLoadedAll = false;
loadedTime = new Date().getTime();
errorMessage = null;
loadNextDataRef.set(false);
loadedTimeRef.set(null);
// loadNextDataToken = 0;
}
@@ -130,6 +299,13 @@
}
}
onDestroy(() => {
if (fetchAllHandle) {
fetchAllHandle.cancel();
}
stopReader();
});
$: if (setLoadedRows) setLoadedRows(loadedRows);
</script>
@@ -137,10 +313,14 @@
{...$$props}
bind:this={domGrid}
onLoadNextData={handleLoadNextData}
onFetchAllRows={fetchAllRows}
{errorMessage}
{isLoading}
{isFetchingAll}
{isFetchingFromDb}
{fetchAllLoadedCount}
allRowCount={rowCountLoaded || allRowCount}
allRowCountError={allRowCountError}
{allRowCountError}
onReloadRowCount={handleLoadRowCount}
{isLoadedAll}
{loadedTime}

View File

@@ -2,13 +2,13 @@
import { getActiveComponent } from '../utility/createActivator';
import registerCommand from '../commands/registerCommand';
import hasPermission from '../utility/hasPermission';
import { __t, _t } from '../translations'
import { __t, _t } from '../translations';
const getCurrentEditor = () => getActiveComponent('SqlDataGridCore');
registerCommand({
id: 'sqlDataGrid.openQuery',
category: __t('command.datagrid', { defaultMessage: 'Data grid' }),
name: __t('command.openQuery', { defaultMessage : 'Open query' }),
name: __t('command.openQuery', { defaultMessage: 'Open query' }),
testEnabled: () => getCurrentEditor() != null && hasPermission('dbops/query'),
onClick: () => getCurrentEditor().openQuery(),
});
@@ -16,7 +16,7 @@
registerCommand({
id: 'sqlDataGrid.export',
category: __t('command.datagrid', { defaultMessage: 'Data grid' }),
name: __t('common.export', { defaultMessage : 'Export' }),
name: __t('common.export', { defaultMessage: 'Export' }),
icon: 'icon export',
keyText: 'CtrlOrCommand+E',
testEnabled: () => getCurrentEditor() != null && hasPermission('dbops/export'),
@@ -232,6 +232,20 @@
return { errorMessage: err.message || 'Error loading row count' };
}
}
async function startFetchAll(props) {
const { display, conid, database } = props;
const sql = display.getExportQuery();
if (!sql) return null;
const resp = await apiCall('sessions/execute-reader', {
conid,
database,
sql,
});
if (!resp || resp.errorMessage) return null;
return resp.jslid;
}
</script>
<LoadingDataGridCore
@@ -239,6 +253,7 @@
{loadDataPage}
{dataPageAvailable}
{loadRowCount}
{startFetchAll}
setLoadedRows={handleSetLoadedRows}
onPublishedCellsChanged={value => {
publishedCells = value;

View File

@@ -0,0 +1,74 @@
<script lang="ts">
import FormStyledButton from '../buttons/FormStyledButton.svelte';
import FormProvider from '../forms/FormProvider.svelte';
import FormSubmit from '../forms/FormSubmit.svelte';
import TemplatedCheckboxField from '../forms/TemplatedCheckboxField.svelte';
import FontIcon from '../icons/FontIcon.svelte';
import ModalBase from './ModalBase.svelte';
import { closeCurrentModal } from './modalTools';
import { apiCall } from '../utility/api';
import { _t } from '../translations';
export let onConfirm;
const SKIP_SETTING_KEY = 'dataGrid.skipFetchAllConfirm';
let dontAskAgain = false;
</script>
<FormProvider>
<ModalBase {...$$restProps} data-testid="FetchAllConfirmModal">
<svelte:fragment slot="header">
{_t('datagrid.fetchAll.title', { defaultMessage: 'Fetch All Rows' })}
</svelte:fragment>
<div class="message">
<FontIcon icon="img warn" />
<span>
{_t('datagrid.fetchAll.warning', {
defaultMessage:
'This will load all remaining rows into memory. For large tables, this may consume a significant amount of memory and could affect application performance.',
})}
</span>
</div>
<div class="mt-2">
<TemplatedCheckboxField
label={_t('common.dontAskAgain', { defaultMessage: "Don't ask again" })}
templateProps={{ noMargin: true }}
checked={dontAskAgain}
on:change={e => {
dontAskAgain = e.detail;
apiCall('config/update-settings', { [SKIP_SETTING_KEY]: e.detail });
}}
data-testid="FetchAllConfirmModal_dontAskAgain"
/>
</div>
<svelte:fragment slot="footer">
<FormSubmit
value={_t('datagrid.fetchAll.confirm', { defaultMessage: 'Fetch All' })}
on:click={() => {
closeCurrentModal();
onConfirm();
}}
data-testid="FetchAllConfirmModal_okButton"
/>
<FormStyledButton
type="button"
value={_t('common.close', { defaultMessage: 'Close' })}
on:click={closeCurrentModal}
data-testid="FetchAllConfirmModal_closeButton"
/>
</svelte:fragment>
</ModalBase>
</FormProvider>
<style>
.message {
display: flex;
align-items: flex-start;
gap: 8px;
line-height: 1.5;
}
</style>

View File

@@ -55,6 +55,12 @@
defaultMessage: 'Skip confirmation when saving collection data (NoSQL)',
})}
/>
<FormCheckboxField
name="dataGrid.skipFetchAllConfirm"
label={_t('settings.confirmations.skipFetchAllConfirm', {
defaultMessage: 'Skip confirmation when fetching all rows',
})}
/>
</FormValues>
</div>

View File

@@ -2,223 +2,230 @@
import { getActiveComponent } from '../utility/createActivator';
import registerCommand from '../commands/registerCommand';
import { __t } from '../translations';
const getCurrentEditor = () => getActiveComponent('CollectionDataTab');
export const matchingProps = ['conid', 'database', 'schemaName', 'pureName'];
export const allowAddToFavorites = props => true;
export const allowSwitchDatabase = props => true;
registerCommand({
id: 'collectionTable.save',
group: 'save',
category: __t('command.collectionData', { defaultMessage: 'Collection data' }),
name: __t('command.collectionData.save', { defaultMessage: 'Save' }),
// keyText: 'CtrlOrCommand+S',
toolbar: true,
isRelatedToTab: true,
icon: 'icon save',
testEnabled: () => getCurrentEditor()?.canSave(),
onClick: () => getCurrentEditor().save(),
});
</script>
<script lang="ts">
import App from '../App.svelte';
import DataGrid from '../datagrid/DataGrid.svelte';
import useGridConfig from '../utility/useGridConfig';
import {
createChangeSet,
createGridCache,
CollectionGridDisplay,
changeSetContainsChanges,
runMacroOnChangeSet,
changeSetChangedCount,
} from 'dbgate-datalib';
import { findEngineDriver } from 'dbgate-tools';
import { writable } from 'svelte/store';
import createUndoReducer from '../utility/createUndoReducer';
import invalidateCommands from '../commands/invalidateCommands';
import CollectionDataGridCore from '../datagrid/CollectionDataGridCore.svelte';
import { useCollectionInfo, useConnectionInfo, useSettings } from '../utility/metadataLoaders';
import { extensions } from '../stores';
import CollectionJsonView from '../formview/CollectionJsonView.svelte';
import createActivator from '../utility/createActivator';
import { showModal } from '../modals/modalTools';
import ErrorMessageModal from '../modals/ErrorMessageModal.svelte';
import ConfirmNoSqlModal from '../modals/ConfirmNoSqlModal.svelte';
import { registerMenu } from '../utility/contextMenu';
import { setContext } from 'svelte';
import _ from 'lodash';
import { apiCall } from '../utility/api';
import { getLocalStorage, setLocalStorage } from '../utility/storageCache';
import ToolStripContainer from '../buttons/ToolStripContainer.svelte';
import ToolStripCommandButton from '../buttons/ToolStripCommandButton.svelte';
import ToolStripExportButton, { createQuickExportHandlerRef } from '../buttons/ToolStripExportButton.svelte';
import { getBoolSettingsValue } from '../settings/settingsTools';
import useEditorData from '../query/useEditorData';
import { markTabSaved, markTabUnsaved } from '../utility/common';
import { getNumberIcon } from '../icons/FontIcon.svelte';
export let tabid;
export let conid;
export let database;
export let schemaName;
export let pureName;
let loadedRows;
export const activator = createActivator('CollectionDataTab', true);
const config = useGridConfig(tabid);
const cache = writable(createGridCache());
const settingsValue = useSettings();
const { editorState, editorValue, setEditorData } = useEditorData({
tabid,
onInitialData: value => {
dispatchChangeSet({ type: 'reset', value });
invalidateCommands();
if (changeSetContainsChanges(value)) {
markTabUnsaved(tabid);
}
},
});
const [changeSetStore, dispatchChangeSet] = createUndoReducer(createChangeSet());
$: {
setEditorData($changeSetStore.value);
if (changeSetContainsChanges($changeSetStore?.value)) {
markTabUnsaved(tabid);
} else {
markTabSaved(tabid);
}
}
$: {
$changeSetStore;
invalidateCommands();
}
$: connection = useConnectionInfo({ conid });
$: collectionInfo = useCollectionInfo({ conid, database, schemaName, pureName });
$: display =
$collectionInfo && $connection
? new CollectionGridDisplay(
$collectionInfo,
findEngineDriver($connection, $extensions),
//@ts-ignore
$config,
config.update,
$cache,
cache.update,
loadedRows,
$changeSetStore?.value,
$connection?.isReadOnly,
$settingsValue
)
: null;
// $: console.log('LOADED ROWS MONGO', loadedRows);
async function handleConfirmChange(changeSet) {
const resp = await apiCall('database-connections/update-collection', {
conid,
database,
changeSet: {
...changeSet,
updates: changeSet.updates.map(update => ({
...update,
fields: _.mapValues(update.fields, (v, k) => (v === undefined ? { $$undefined$$: true } : v)),
})),
},
});
const { errorMessage } = resp || {};
if (errorMessage) {
showModal(ErrorMessageModal, { title: 'Error when saving', message: errorMessage });
} else {
dispatchChangeSet({ type: 'reset', value: createChangeSet() });
display?.reload();
}
}
export function canSave() {
return changeSetContainsChanges($changeSetStore?.value);
}
export function save() {
const json = $changeSetStore?.value;
const driver = findEngineDriver($connection, $extensions);
const script = driver.getCollectionUpdateScript ? driver.getCollectionUpdateScript(json, $collectionInfo) : null;
if (script) {
if (getBoolSettingsValue('skipConfirm.collectionDataSave', false)) {
handleConfirmChange(json);
} else {
showModal(ConfirmNoSqlModal, {
script,
onConfirm: () => handleConfirmChange(json),
engine: display.engine,
skipConfirmSettingKey: 'skipConfirm.collectionDataSave',
});
}
} else {
handleConfirmChange(json);
}
}
function handleRunMacro(macro, params, cells) {
const newChangeSet = runMacroOnChangeSet(macro, params, cells, $changeSetStore?.value, display, false);
if (newChangeSet) {
dispatchChangeSet({ type: 'set', value: newChangeSet });
}
}
registerMenu({ command: 'collectionTable.save', tag: 'save' });
const collapsedLeftColumnStore = writable(getLocalStorage('collection_collapsedLeftColumn', false));
setContext('collapsedLeftColumnStore', collapsedLeftColumnStore);
$: setLocalStorage('collection_collapsedLeftColumn', $collapsedLeftColumnStore);
const quickExportHandlerRef = createQuickExportHandlerRef();
function handleSetLoadedRows(rows) {
loadedRows = rows;
}
</script>
<ToolStripContainer>
<DataGrid
setLoadedRows={handleSetLoadedRows}
{...$$props}
config={$config}
setConfig={config.update}
cache={$cache}
setCache={cache.update}
changeSetState={$changeSetStore}
focusOnVisible
{display}
{changeSetStore}
{dispatchChangeSet}
gridCoreComponent={CollectionDataGridCore}
jsonViewComponent={CollectionJsonView}
isDynamicStructure
showMacros
macroCondition={macro => macro.type == 'transformValue'}
onRunMacro={handleRunMacro}
/>
<svelte:fragment slot="toolstrip">
<ToolStripCommandButton command="dataGrid.refresh" hideDisabled />
<ToolStripCommandButton command="dataForm.refresh" hideDisabled />
<ToolStripCommandButton
command="collectionTable.save"
iconAfter={getNumberIcon(changeSetChangedCount($changeSetStore?.value))}
/>
<ToolStripCommandButton command="dataGrid.revertAllChanges" hideDisabled />
<ToolStripCommandButton command="dataGrid.insertNewRow" hideDisabled />
<ToolStripCommandButton command="dataGrid.deleteSelectedRows" hideDisabled />
<ToolStripCommandButton command="dataGrid.addNewColumn" hideDisabled />
<ToolStripCommandButton command="dataGrid.switchToJson" hideDisabled />
<ToolStripCommandButton command="dataGrid.switchToTable" hideDisabled />
<ToolStripExportButton {quickExportHandlerRef} command="collectionDataGrid.export" />
<ToolStripCommandButton command="collectionJsonView.expandAll" hideDisabled />
<ToolStripCommandButton command="collectionJsonView.collapseAll" hideDisabled />
<ToolStripCommandButton command="dataGrid.toggleCellDataView" hideDisabled data-testid="CollectionDataTab_toggleCellDataView" />
const getCurrentEditor = () => getActiveComponent('CollectionDataTab');
export const matchingProps = ['conid', 'database', 'schemaName', 'pureName'];
export const allowAddToFavorites = props => true;
export const allowSwitchDatabase = props => true;
registerCommand({
id: 'collectionTable.save',
group: 'save',
category: __t('command.collectionData', { defaultMessage: 'Collection data' }),
name: __t('command.collectionData.save', { defaultMessage: 'Save' }),
// keyText: 'CtrlOrCommand+S',
toolbar: true,
isRelatedToTab: true,
icon: 'icon save',
testEnabled: () => getCurrentEditor()?.canSave(),
onClick: () => getCurrentEditor().save(),
});
</script>
<script lang="ts">
import App from '../App.svelte';
import DataGrid from '../datagrid/DataGrid.svelte';
import useGridConfig from '../utility/useGridConfig';
import {
createChangeSet,
createGridCache,
CollectionGridDisplay,
changeSetContainsChanges,
runMacroOnChangeSet,
changeSetChangedCount,
} from 'dbgate-datalib';
import { findEngineDriver } from 'dbgate-tools';
import { writable } from 'svelte/store';
import createUndoReducer from '../utility/createUndoReducer';
import invalidateCommands from '../commands/invalidateCommands';
import CollectionDataGridCore from '../datagrid/CollectionDataGridCore.svelte';
import { useCollectionInfo, useConnectionInfo, useSettings } from '../utility/metadataLoaders';
import { extensions } from '../stores';
import CollectionJsonView from '../formview/CollectionJsonView.svelte';
import createActivator from '../utility/createActivator';
import { showModal } from '../modals/modalTools';
import ErrorMessageModal from '../modals/ErrorMessageModal.svelte';
import ConfirmNoSqlModal from '../modals/ConfirmNoSqlModal.svelte';
import { registerMenu } from '../utility/contextMenu';
import { setContext } from 'svelte';
import _ from 'lodash';
import { apiCall } from '../utility/api';
import { getLocalStorage, setLocalStorage } from '../utility/storageCache';
import ToolStripContainer from '../buttons/ToolStripContainer.svelte';
import ToolStripCommandButton from '../buttons/ToolStripCommandButton.svelte';
import ToolStripExportButton, { createQuickExportHandlerRef } from '../buttons/ToolStripExportButton.svelte';
import { getBoolSettingsValue } from '../settings/settingsTools';
import useEditorData from '../query/useEditorData';
import { markTabSaved, markTabUnsaved } from '../utility/common';
import { getNumberIcon } from '../icons/FontIcon.svelte';
export let tabid;
export let conid;
export let database;
export let schemaName;
export let pureName;
let loadedRows;
export const activator = createActivator('CollectionDataTab', true);
const config = useGridConfig(tabid);
const cache = writable(createGridCache());
const settingsValue = useSettings();
const { editorState, editorValue, setEditorData } = useEditorData({
tabid,
onInitialData: value => {
dispatchChangeSet({ type: 'reset', value });
invalidateCommands();
if (changeSetContainsChanges(value)) {
markTabUnsaved(tabid);
}
},
});
const [changeSetStore, dispatchChangeSet] = createUndoReducer(createChangeSet());
$: {
setEditorData($changeSetStore.value);
if (changeSetContainsChanges($changeSetStore?.value)) {
markTabUnsaved(tabid);
} else {
markTabSaved(tabid);
}
}
$: {
$changeSetStore;
invalidateCommands();
}
$: connection = useConnectionInfo({ conid });
$: collectionInfo = useCollectionInfo({ conid, database, schemaName, pureName });
$: display =
$collectionInfo && $connection
? new CollectionGridDisplay(
$collectionInfo,
findEngineDriver($connection, $extensions),
//@ts-ignore
$config,
config.update,
$cache,
cache.update,
loadedRows,
$changeSetStore?.value,
$connection?.isReadOnly,
$settingsValue
)
: null;
// $: console.log('LOADED ROWS MONGO', loadedRows);
async function handleConfirmChange(changeSet) {
const resp = await apiCall('database-connections/update-collection', {
conid,
database,
changeSet: {
...changeSet,
updates: changeSet.updates.map(update => ({
...update,
fields: _.mapValues(update.fields, (v, k) => (v === undefined ? { $$undefined$$: true } : v)),
})),
},
});
const { errorMessage } = resp || {};
if (errorMessage) {
showModal(ErrorMessageModal, { title: 'Error when saving', message: errorMessage });
} else {
dispatchChangeSet({ type: 'reset', value: createChangeSet() });
display?.reload();
}
}
export function canSave() {
return changeSetContainsChanges($changeSetStore?.value);
}
export function save() {
const json = $changeSetStore?.value;
const driver = findEngineDriver($connection, $extensions);
const script = driver.getCollectionUpdateScript ? driver.getCollectionUpdateScript(json, $collectionInfo) : null;
if (script) {
if (getBoolSettingsValue('skipConfirm.collectionDataSave', false)) {
handleConfirmChange(json);
} else {
showModal(ConfirmNoSqlModal, {
script,
onConfirm: () => handleConfirmChange(json),
engine: display.engine,
skipConfirmSettingKey: 'skipConfirm.collectionDataSave',
});
}
} else {
handleConfirmChange(json);
}
}
function handleRunMacro(macro, params, cells) {
const newChangeSet = runMacroOnChangeSet(macro, params, cells, $changeSetStore?.value, display, false);
if (newChangeSet) {
dispatchChangeSet({ type: 'set', value: newChangeSet });
}
}
registerMenu({ command: 'collectionTable.save', tag: 'save' });
const collapsedLeftColumnStore = writable(getLocalStorage('collection_collapsedLeftColumn', false));
setContext('collapsedLeftColumnStore', collapsedLeftColumnStore);
$: setLocalStorage('collection_collapsedLeftColumn', $collapsedLeftColumnStore);
const quickExportHandlerRef = createQuickExportHandlerRef();
function handleSetLoadedRows(rows) {
loadedRows = rows;
}
</script>
<ToolStripContainer>
<DataGrid
setLoadedRows={handleSetLoadedRows}
{...$$props}
config={$config}
setConfig={config.update}
cache={$cache}
setCache={cache.update}
changeSetState={$changeSetStore}
focusOnVisible
{display}
{changeSetStore}
{dispatchChangeSet}
gridCoreComponent={CollectionDataGridCore}
jsonViewComponent={CollectionJsonView}
isDynamicStructure
showMacros
macroCondition={macro => macro.type == 'transformValue'}
onRunMacro={handleRunMacro}
/>
<svelte:fragment slot="toolstrip">
<ToolStripCommandButton command="dataGrid.refresh" hideDisabled />
<ToolStripCommandButton command="dataForm.refresh" hideDisabled />
<ToolStripCommandButton
command="collectionTable.save"
iconAfter={getNumberIcon(changeSetChangedCount($changeSetStore?.value))}
/>
<ToolStripCommandButton command="dataGrid.revertAllChanges" hideDisabled />
<ToolStripCommandButton command="dataGrid.insertNewRow" hideDisabled />
<ToolStripCommandButton command="dataGrid.deleteSelectedRows" hideDisabled />
<ToolStripCommandButton command="dataGrid.addNewColumn" hideDisabled />
<ToolStripCommandButton command="dataGrid.switchToJson" hideDisabled />
<ToolStripCommandButton command="dataGrid.switchToTable" hideDisabled />
<ToolStripExportButton {quickExportHandlerRef} command="collectionDataGrid.export" />
<ToolStripCommandButton command="dataGrid.fetchAll" hideDisabled />
<ToolStripCommandButton command="collectionJsonView.expandAll" hideDisabled />
<ToolStripCommandButton command="collectionJsonView.collapseAll" hideDisabled />
<ToolStripCommandButton
command="dataGrid.toggleCellDataView"
hideDisabled
data-testid="CollectionDataTab_toggleCellDataView"
/>
</svelte:fragment>
</ToolStripContainer>

View File

@@ -2,434 +2,436 @@
import { getActiveComponent } from '../utility/createActivator';
import registerCommand from '../commands/registerCommand';
import { __t } from '../translations';
const getCurrentEditor = () => getActiveComponent('TableDataTab');
const INTERVALS = [5, 10, 15, 30, 60];
const INTERVAL_COMMANDS = [
{
time: 5,
name: __t('command.datagrid.setAutoRefresh.5', { defaultMessage: 'Refresh every 5 seconds' }),
},
{
time: 10,
name: __t('command.datagrid.setAutoRefresh.10', { defaultMessage: 'Refresh every 10 seconds' }),
},
{
time: 15,
name: __t('command.datagrid.setAutoRefresh.15', { defaultMessage: 'Refresh every 15 seconds' }),
},
{
time: 30,
name: __t('command.datagrid.setAutoRefresh.30', { defaultMessage: 'Refresh every 30 seconds' }),
},
{
time: 60,
name: __t('command.datagrid.setAutoRefresh.60', { defaultMessage: 'Refresh every 60 seconds' }),
},
];
registerCommand({
id: 'tableData.save',
group: 'save',
category: __t('command.tableData', { defaultMessage: 'Table data' }),
name: __t('command.tableData.save', { defaultMessage: 'Save' }),
// keyText: 'CtrlOrCommand+S',
toolbar: true,
isRelatedToTab: true,
icon: 'icon save',
testEnabled: () => getCurrentEditor()?.canSave(),
onClick: () => getCurrentEditor().save(),
});
registerCommand({
id: 'tableData.setAutoRefresh.1',
category: __t('command.datagrid', { defaultMessage: 'Data grid' }),
name: __t('command.datagrid.setAutoRefresh.1', { defaultMessage: 'Refresh every 1 second' }),
isRelatedToTab: true,
testEnabled: () => !!getCurrentEditor(),
onClick: () => getCurrentEditor().setAutoRefresh(1),
});
for (const { time, name } of INTERVAL_COMMANDS) {
registerCommand({
id: `tableData.setAutoRefresh.${time}`,
category: __t('command.datagrid', { defaultMessage: 'Data grid' }),
name,
isRelatedToTab: true,
testEnabled: () => !!getCurrentEditor(),
onClick: () => getCurrentEditor().setAutoRefresh(time),
});
}
registerCommand({
id: 'tableData.stopAutoRefresh',
category: __t('command.datagrid', { defaultMessage: 'Data grid' }),
name: __t('command.datagrid.stopAutoRefresh', { defaultMessage: 'Stop auto refresh' }),
isRelatedToTab: true,
keyText: 'CtrlOrCommand+Shift+R',
testEnabled: () => getCurrentEditor()?.isAutoRefresh() === true,
onClick: () => getCurrentEditor().stopAutoRefresh(null),
});
registerCommand({
id: 'tableData.startAutoRefresh',
category: __t('command.datagrid', { defaultMessage: 'Data grid' }),
name: __t('command.datagrid.startAutoRefresh', { defaultMessage: 'Start auto refresh' }),
isRelatedToTab: true,
keyText: 'CtrlOrCommand+Shift+R',
testEnabled: () => getCurrentEditor()?.isAutoRefresh() === false,
onClick: () => getCurrentEditor().startAutoRefresh(),
});
export const matchingProps = ['conid', 'database', 'schemaName', 'pureName', 'isRawMode'];
export const allowAddToFavorites = props => true;
export const allowSwitchDatabase = props => true;
</script>
<script lang="ts">
import _ from 'lodash';
import App from '../App.svelte';
import TableDataGrid from '../datagrid/TableDataGrid.svelte';
import useGridConfig from '../utility/useGridConfig';
import {
changeSetChangedCount,
changeSetContainsChanges,
changeSetToSql,
createChangeSet,
createGridCache,
getDeleteCascades,
} from 'dbgate-datalib';
import { findEngineDriver } from 'dbgate-tools';
import { reloadDataCacheFunc } from 'dbgate-datalib';
import { writable } from 'svelte/store';
import createUndoReducer from '../utility/createUndoReducer';
import invalidateCommands from '../commands/invalidateCommands';
import { showModal } from '../modals/modalTools';
import ErrorMessageModal from '../modals/ErrorMessageModal.svelte';
import { getTableInfo, useConnectionInfo, useDatabaseInfo } from '../utility/metadataLoaders';
import { scriptToSql } from 'dbgate-sqltree';
import { extensions, lastUsedDefaultActions } from '../stores';
import ConfirmSqlModal from '../modals/ConfirmSqlModal.svelte';
import createActivator from '../utility/createActivator';
import { registerMenu } from '../utility/contextMenu';
import { showSnackbarSuccess } from '../utility/snackbar';
import openNewTab from '../utility/openNewTab';
import { onDestroy, setContext } from 'svelte';
import { apiCall } from '../utility/api';
import { getLocalStorage, setLocalStorage } from '../utility/storageCache';
import ToolStripContainer from '../buttons/ToolStripContainer.svelte';
import ToolStripCommandButton from '../buttons/ToolStripCommandButton.svelte';
import ToolStripExportButton, { createQuickExportHandlerRef } from '../buttons/ToolStripExportButton.svelte';
import ToolStripCommandSplitButton from '../buttons/ToolStripCommandSplitButton.svelte';
import { getBoolSettingsValue, getIntSettingsValue } from '../settings/settingsTools';
import useEditorData from '../query/useEditorData';
import { markTabSaved, markTabUnsaved } from '../utility/common';
import ToolStripButton from '../buttons/ToolStripButton.svelte';
import { getNumberIcon } from '../icons/FontIcon.svelte';
import { _t } from '../translations';
export let tabid;
export let conid;
export let database;
export let schemaName;
export let pureName;
export let isRawMode = false;
export let tabPreviewMode;
export const activator = createActivator('TableDataTab', true);
const config = useGridConfig(tabid);
const cache = writable(createGridCache());
const dbinfo = useDatabaseInfo({ conid, database });
let autoRefreshInterval = getIntSettingsValue('dataGrid.defaultAutoRefreshInterval', 10, 1, 3600);
let autoRefreshStarted = false;
let autoRefreshTimer = null;
$: connection = useConnectionInfo({ conid });
const { editorState, editorValue, setEditorData } = useEditorData({
tabid,
onInitialData: value => {
dispatchChangeSet({ type: 'reset', value });
invalidateCommands();
if (changeSetContainsChanges(value)) {
markTabUnsaved(tabid);
}
},
});
const [changeSetStore, dispatchChangeSet] = createUndoReducer(createChangeSet());
$: {
setEditorData($changeSetStore.value);
if (changeSetContainsChanges($changeSetStore?.value)) {
markTabUnsaved(tabid);
} else {
markTabSaved(tabid);
}
}
async function handleConfirmSql(sql) {
const resp = await apiCall('database-connections/run-script', { conid, database, sql, useTransaction: true });
const { errorMessage } = resp || {};
if (errorMessage) {
showModal(ErrorMessageModal, {
title: _t('tableData.errorWhenSaving', { defaultMessage: 'Error when saving' }),
message: errorMessage,
});
} else {
dispatchChangeSet({ type: 'reset', value: createChangeSet() });
cache.update(reloadDataCacheFunc);
showSnackbarSuccess(_t('tableData.savedToDatabase', { defaultMessage: 'Saved to database' }));
}
}
export async function save() {
const driver = findEngineDriver($connection, $extensions);
const tablePermissionRole = (await getTableInfo({ conid, database, schemaName, pureName }))?.tablePermissionRole;
if (tablePermissionRole == 'create_update_delete' || tablePermissionRole == 'update_only') {
const resp = await apiCall('database-connections/save-table-data', {
conid,
database,
changeSet: $changeSetStore?.value,
});
const { errorMessage } = resp || {};
if (errorMessage) {
showModal(ErrorMessageModal, {
title: _t('tableData.errorWhenSaving', { defaultMessage: 'Error when saving' }),
message: errorMessage,
});
} else {
dispatchChangeSet({ type: 'reset', value: createChangeSet() });
cache.update(reloadDataCacheFunc);
showSnackbarSuccess(_t('tableData.savedToDatabase', { defaultMessage: 'Saved to database' }));
}
} else {
const script = driver.createSaveChangeSetScript($changeSetStore?.value, $dbinfo, () =>
changeSetToSql($changeSetStore?.value, $dbinfo, driver.dialect)
);
const deleteCascades = getDeleteCascades($changeSetStore?.value, $dbinfo);
const sql = scriptToSql(driver, script);
const deleteCascadesScripts = _.map(deleteCascades, ({ title, commands }) => ({
title,
script: scriptToSql(driver, commands),
}));
// console.log('deleteCascadesScripts', deleteCascadesScripts);
if (getBoolSettingsValue('skipConfirm.tableDataSave', false) && !deleteCascadesScripts?.length) {
handleConfirmSql(sql);
} else {
showModal(ConfirmSqlModal, {
sql,
onConfirm: confirmedSql => handleConfirmSql(confirmedSql),
engine: driver.engine,
deleteCascadesScripts,
skipConfirmSettingKey: deleteCascadesScripts?.length ? null : 'skipConfirm.tableDataSave',
});
}
}
}
export function canSave() {
return changeSetContainsChanges($changeSetStore?.value);
}
export function setAutoRefresh(interval) {
autoRefreshInterval = interval;
startAutoRefresh();
invalidateCommands();
}
export function isAutoRefresh() {
return autoRefreshStarted;
}
export function startAutoRefresh() {
closeRefreshTimer();
autoRefreshTimer = setInterval(() => {
cache.update(reloadDataCacheFunc);
}, autoRefreshInterval * 1000);
autoRefreshStarted = true;
invalidateCommands();
}
export function stopAutoRefresh() {
closeRefreshTimer();
autoRefreshStarted = false;
invalidateCommands();
}
function closeRefreshTimer() {
if (autoRefreshTimer) {
clearInterval(autoRefreshTimer);
autoRefreshTimer = null;
}
}
$: {
$changeSetStore;
invalidateCommands();
}
registerMenu({ command: 'tableData.save', tag: 'save' });
const collapsedLeftColumnStore = writable(getLocalStorage('dataGrid_collapsedLeftColumn', false));
setContext('collapsedLeftColumnStore', collapsedLeftColumnStore);
$: setLocalStorage('dataGrid_collapsedLeftColumn', $collapsedLeftColumnStore);
onDestroy(() => {
closeRefreshTimer();
});
const quickExportHandlerRef = createQuickExportHandlerRef();
function createAutoRefreshMenu() {
return [
{ divider: true },
{ command: 'dataGrid.deepRefresh', hideDisabled: true },
{ command: 'tableData.stopAutoRefresh', hideDisabled: true },
{ command: 'tableData.startAutoRefresh', hideDisabled: true },
'tableData.setAutoRefresh.1',
...INTERVALS.map(seconds => ({
command: `tableData.setAutoRefresh.${seconds}`,
text: `...${seconds}` + ' ' + _t('command.datagrid.autoRefresh.seconds', { defaultMessage: 'seconds' }),
})),
];
}
</script>
<ToolStripContainer>
<TableDataGrid
{...$$props}
config={$config}
setConfig={config.update}
cache={$cache}
setCache={cache.update}
changeSetState={$changeSetStore}
focusOnVisible
{changeSetStore}
{dispatchChangeSet}
/>
<svelte:fragment slot="toolstrip">
<ToolStripButton
icon="icon structure"
iconAfter="icon arrow-link"
on:click={() => {
if (tabPreviewMode && getBoolSettingsValue('defaultAction.useLastUsedAction', true)) {
lastUsedDefaultActions.update(actions => ({
...actions,
tables: 'openStructure',
}));
}
openNewTab({
title: pureName,
icon: 'img table-structure',
tabComponent: 'TableStructureTab',
tabPreviewMode: true,
props: {
schemaName,
pureName,
conid,
database,
objectTypeField: 'tables',
defaultActionId: 'openStructure',
},
});
}}>{_t('datagrid.structure', { defaultMessage: 'Structure' })}</ToolStripButton
>
<ToolStripButton
icon="img sql-file"
iconAfter="icon arrow-link"
on:click={() => {
if (tabPreviewMode && getBoolSettingsValue('defaultAction.useLastUsedAction', true)) {
lastUsedDefaultActions.update(actions => ({
...actions,
tables: 'showSql',
}));
}
openNewTab({
title: pureName,
icon: 'img sql-file',
tabComponent: 'SqlObjectTab',
tabPreviewMode: true,
props: {
schemaName,
pureName,
conid,
database,
objectTypeField: 'tables',
defaultActionId: 'showSql',
},
});
}}>SQL</ToolStripButton
>
<ToolStripCommandSplitButton
buttonLabel={autoRefreshStarted
? _t('tableData.refreshEvery', {
defaultMessage: 'Refresh (every {autoRefreshInterval}s)',
values: { autoRefreshInterval },
})
: null}
commands={['dataGrid.refresh', ...createAutoRefreshMenu()]}
hideDisabled
data-testid="TableDataTab_refreshGrid"
/>
<ToolStripCommandSplitButton
buttonLabel={autoRefreshStarted
? _t('tableData.refreshEvery', {
defaultMessage: 'Refresh (every {autoRefreshInterval}s)',
values: { autoRefreshInterval },
})
: null}
commands={['dataForm.refresh', ...createAutoRefreshMenu()]}
hideDisabled
data-testid="TableDataTab_refreshForm"
/>
<!-- <ToolStripCommandButton command="dataGrid.refresh" hideDisabled />
<ToolStripCommandButton command="dataForm.refresh" hideDisabled /> -->
<ToolStripCommandButton command="dataForm.goToFirst" hideDisabled data-testid="TableDataTab_goToFirst" />
<ToolStripCommandButton command="dataForm.goToPrevious" hideDisabled data-testid="TableDataTab_goToPrevious" />
<ToolStripCommandButton command="dataForm.goToNext" hideDisabled data-testid="TableDataTab_goToNext" />
<ToolStripCommandButton command="dataForm.goToLast" hideDisabled data-testid="TableDataTab_goToLast" />
<ToolStripCommandButton
command="tableData.save"
iconAfter={getNumberIcon(changeSetChangedCount($changeSetStore?.value))}
data-testid="TableDataTab_save"
/>
<ToolStripCommandButton
command="dataGrid.revertAllChanges"
hideDisabled
data-testid="TableDataTab_revertAllChanges"
/>
<ToolStripCommandButton command="dataGrid.insertNewRow" hideDisabled data-testid="TableDataTab_insertNewRow" />
<ToolStripCommandButton
command="dataGrid.deleteSelectedRows"
hideDisabled
data-testid="TableDataTab_deleteSelectedRows"
/>
<ToolStripCommandButton command="dataGrid.switchToForm" hideDisabled data-testid="TableDataTab_switchToForm" />
<ToolStripCommandButton command="dataGrid.switchToTable" hideDisabled data-testid="TableDataTab_switchToTable" />
<ToolStripExportButton {quickExportHandlerRef} />
<ToolStripButton
icon={$collapsedLeftColumnStore ? 'icon columns-outline' : 'icon columns'}
on:click={() => collapsedLeftColumnStore.update(x => !x)}
>{_t('tableData.viewColumns', { defaultMessage: 'View columns' })}</ToolStripButton
>
<ToolStripCommandButton
command="dataGrid.toggleCellDataView"
hideDisabled
data-testid="TableDataTab_toggleCellDataView"
/>
</svelte:fragment>
const getCurrentEditor = () => getActiveComponent('TableDataTab');
const INTERVALS = [5, 10, 15, 30, 60];
const INTERVAL_COMMANDS = [
{
time: 5,
name: __t('command.datagrid.setAutoRefresh.5', { defaultMessage: 'Refresh every 5 seconds' }),
},
{
time: 10,
name: __t('command.datagrid.setAutoRefresh.10', { defaultMessage: 'Refresh every 10 seconds' }),
},
{
time: 15,
name: __t('command.datagrid.setAutoRefresh.15', { defaultMessage: 'Refresh every 15 seconds' }),
},
{
time: 30,
name: __t('command.datagrid.setAutoRefresh.30', { defaultMessage: 'Refresh every 30 seconds' }),
},
{
time: 60,
name: __t('command.datagrid.setAutoRefresh.60', { defaultMessage: 'Refresh every 60 seconds' }),
},
];
registerCommand({
id: 'tableData.save',
group: 'save',
category: __t('command.tableData', { defaultMessage: 'Table data' }),
name: __t('command.tableData.save', { defaultMessage: 'Save' }),
// keyText: 'CtrlOrCommand+S',
toolbar: true,
isRelatedToTab: true,
icon: 'icon save',
testEnabled: () => getCurrentEditor()?.canSave(),
onClick: () => getCurrentEditor().save(),
});
registerCommand({
id: 'tableData.setAutoRefresh.1',
category: __t('command.datagrid', { defaultMessage: 'Data grid' }),
name: __t('command.datagrid.setAutoRefresh.1', { defaultMessage: 'Refresh every 1 second' }),
isRelatedToTab: true,
testEnabled: () => !!getCurrentEditor(),
onClick: () => getCurrentEditor().setAutoRefresh(1),
});
for (const { time, name } of INTERVAL_COMMANDS) {
registerCommand({
id: `tableData.setAutoRefresh.${time}`,
category: __t('command.datagrid', { defaultMessage: 'Data grid' }),
name,
isRelatedToTab: true,
testEnabled: () => !!getCurrentEditor(),
onClick: () => getCurrentEditor().setAutoRefresh(time),
});
}
registerCommand({
id: 'tableData.stopAutoRefresh',
category: __t('command.datagrid', { defaultMessage: 'Data grid' }),
name: __t('command.datagrid.stopAutoRefresh', { defaultMessage: 'Stop auto refresh' }),
isRelatedToTab: true,
keyText: 'CtrlOrCommand+Shift+R',
testEnabled: () => getCurrentEditor()?.isAutoRefresh() === true,
onClick: () => getCurrentEditor().stopAutoRefresh(null),
});
registerCommand({
id: 'tableData.startAutoRefresh',
category: __t('command.datagrid', { defaultMessage: 'Data grid' }),
name: __t('command.datagrid.startAutoRefresh', { defaultMessage: 'Start auto refresh' }),
isRelatedToTab: true,
keyText: 'CtrlOrCommand+Shift+R',
testEnabled: () => getCurrentEditor()?.isAutoRefresh() === false,
onClick: () => getCurrentEditor().startAutoRefresh(),
});
export const matchingProps = ['conid', 'database', 'schemaName', 'pureName', 'isRawMode'];
export const allowAddToFavorites = props => true;
export const allowSwitchDatabase = props => true;
</script>
<script lang="ts">
import _ from 'lodash';
import App from '../App.svelte';
import TableDataGrid from '../datagrid/TableDataGrid.svelte';
import useGridConfig from '../utility/useGridConfig';
import {
changeSetChangedCount,
changeSetContainsChanges,
changeSetToSql,
createChangeSet,
createGridCache,
getDeleteCascades,
} from 'dbgate-datalib';
import { findEngineDriver } from 'dbgate-tools';
import { reloadDataCacheFunc } from 'dbgate-datalib';
import { writable } from 'svelte/store';
import createUndoReducer from '../utility/createUndoReducer';
import invalidateCommands from '../commands/invalidateCommands';
import { showModal } from '../modals/modalTools';
import ErrorMessageModal from '../modals/ErrorMessageModal.svelte';
import { getTableInfo, useConnectionInfo, useDatabaseInfo } from '../utility/metadataLoaders';
import { scriptToSql } from 'dbgate-sqltree';
import { extensions, lastUsedDefaultActions } from '../stores';
import ConfirmSqlModal from '../modals/ConfirmSqlModal.svelte';
import createActivator from '../utility/createActivator';
import { registerMenu } from '../utility/contextMenu';
import { showSnackbarSuccess } from '../utility/snackbar';
import openNewTab from '../utility/openNewTab';
import { onDestroy, setContext } from 'svelte';
import { apiCall } from '../utility/api';
import { getLocalStorage, setLocalStorage } from '../utility/storageCache';
import ToolStripContainer from '../buttons/ToolStripContainer.svelte';
import ToolStripCommandButton from '../buttons/ToolStripCommandButton.svelte';
import ToolStripExportButton, { createQuickExportHandlerRef } from '../buttons/ToolStripExportButton.svelte';
import ToolStripCommandSplitButton from '../buttons/ToolStripCommandSplitButton.svelte';
import { getBoolSettingsValue, getIntSettingsValue } from '../settings/settingsTools';
import useEditorData from '../query/useEditorData';
import { markTabSaved, markTabUnsaved } from '../utility/common';
import ToolStripButton from '../buttons/ToolStripButton.svelte';
import { getNumberIcon } from '../icons/FontIcon.svelte';
import { _t } from '../translations';
export let tabid;
export let conid;
export let database;
export let schemaName;
export let pureName;
export let isRawMode = false;
export let tabPreviewMode;
export const activator = createActivator('TableDataTab', true);
const config = useGridConfig(tabid);
const cache = writable(createGridCache());
const dbinfo = useDatabaseInfo({ conid, database });
let autoRefreshInterval = getIntSettingsValue('dataGrid.defaultAutoRefreshInterval', 10, 1, 3600);
let autoRefreshStarted = false;
let autoRefreshTimer = null;
$: connection = useConnectionInfo({ conid });
const { editorState, editorValue, setEditorData } = useEditorData({
tabid,
onInitialData: value => {
dispatchChangeSet({ type: 'reset', value });
invalidateCommands();
if (changeSetContainsChanges(value)) {
markTabUnsaved(tabid);
}
},
});
const [changeSetStore, dispatchChangeSet] = createUndoReducer(createChangeSet());
$: {
setEditorData($changeSetStore.value);
if (changeSetContainsChanges($changeSetStore?.value)) {
markTabUnsaved(tabid);
} else {
markTabSaved(tabid);
}
}
async function handleConfirmSql(sql) {
const resp = await apiCall('database-connections/run-script', { conid, database, sql, useTransaction: true });
const { errorMessage } = resp || {};
if (errorMessage) {
showModal(ErrorMessageModal, {
title: _t('tableData.errorWhenSaving', { defaultMessage: 'Error when saving' }),
message: errorMessage,
});
} else {
dispatchChangeSet({ type: 'reset', value: createChangeSet() });
cache.update(reloadDataCacheFunc);
showSnackbarSuccess(_t('tableData.savedToDatabase', { defaultMessage: 'Saved to database' }));
}
}
export async function save() {
const driver = findEngineDriver($connection, $extensions);
const tablePermissionRole = (await getTableInfo({ conid, database, schemaName, pureName }))?.tablePermissionRole;
if (tablePermissionRole == 'create_update_delete' || tablePermissionRole == 'update_only') {
const resp = await apiCall('database-connections/save-table-data', {
conid,
database,
changeSet: $changeSetStore?.value,
});
const { errorMessage } = resp || {};
if (errorMessage) {
showModal(ErrorMessageModal, {
title: _t('tableData.errorWhenSaving', { defaultMessage: 'Error when saving' }),
message: errorMessage,
});
} else {
dispatchChangeSet({ type: 'reset', value: createChangeSet() });
cache.update(reloadDataCacheFunc);
showSnackbarSuccess(_t('tableData.savedToDatabase', { defaultMessage: 'Saved to database' }));
}
} else {
const script = driver.createSaveChangeSetScript($changeSetStore?.value, $dbinfo, () =>
changeSetToSql($changeSetStore?.value, $dbinfo, driver.dialect)
);
const deleteCascades = getDeleteCascades($changeSetStore?.value, $dbinfo);
const sql = scriptToSql(driver, script);
const deleteCascadesScripts = _.map(deleteCascades, ({ title, commands }) => ({
title,
script: scriptToSql(driver, commands),
}));
// console.log('deleteCascadesScripts', deleteCascadesScripts);
if (getBoolSettingsValue('skipConfirm.tableDataSave', false) && !deleteCascadesScripts?.length) {
handleConfirmSql(sql);
} else {
showModal(ConfirmSqlModal, {
sql,
onConfirm: confirmedSql => handleConfirmSql(confirmedSql),
engine: driver.engine,
deleteCascadesScripts,
skipConfirmSettingKey: deleteCascadesScripts?.length ? null : 'skipConfirm.tableDataSave',
});
}
}
}
export function canSave() {
return changeSetContainsChanges($changeSetStore?.value);
}
export function setAutoRefresh(interval) {
autoRefreshInterval = interval;
startAutoRefresh();
invalidateCommands();
}
export function isAutoRefresh() {
return autoRefreshStarted;
}
export function startAutoRefresh() {
closeRefreshTimer();
autoRefreshTimer = setInterval(() => {
cache.update(reloadDataCacheFunc);
}, autoRefreshInterval * 1000);
autoRefreshStarted = true;
invalidateCommands();
}
export function stopAutoRefresh() {
closeRefreshTimer();
autoRefreshStarted = false;
invalidateCommands();
}
function closeRefreshTimer() {
if (autoRefreshTimer) {
clearInterval(autoRefreshTimer);
autoRefreshTimer = null;
}
}
$: {
$changeSetStore;
invalidateCommands();
}
registerMenu({ command: 'tableData.save', tag: 'save' });
const collapsedLeftColumnStore = writable(getLocalStorage('dataGrid_collapsedLeftColumn', false));
setContext('collapsedLeftColumnStore', collapsedLeftColumnStore);
$: setLocalStorage('dataGrid_collapsedLeftColumn', $collapsedLeftColumnStore);
onDestroy(() => {
closeRefreshTimer();
});
const quickExportHandlerRef = createQuickExportHandlerRef();
function createAutoRefreshMenu() {
return [
{ divider: true },
{ command: 'dataGrid.deepRefresh', hideDisabled: true },
{ command: 'tableData.stopAutoRefresh', hideDisabled: true },
{ command: 'tableData.startAutoRefresh', hideDisabled: true },
'tableData.setAutoRefresh.1',
...INTERVALS.map(seconds => ({
command: `tableData.setAutoRefresh.${seconds}`,
text: `...${seconds}` + ' ' + _t('command.datagrid.autoRefresh.seconds', { defaultMessage: 'seconds' }),
})),
];
}
</script>
<ToolStripContainer>
<TableDataGrid
{...$$props}
config={$config}
setConfig={config.update}
cache={$cache}
setCache={cache.update}
changeSetState={$changeSetStore}
focusOnVisible
{changeSetStore}
{dispatchChangeSet}
/>
<svelte:fragment slot="toolstrip">
<ToolStripButton
icon="icon structure"
iconAfter="icon arrow-link"
on:click={() => {
if (tabPreviewMode && getBoolSettingsValue('defaultAction.useLastUsedAction', true)) {
lastUsedDefaultActions.update(actions => ({
...actions,
tables: 'openStructure',
}));
}
openNewTab({
title: pureName,
icon: 'img table-structure',
tabComponent: 'TableStructureTab',
tabPreviewMode: true,
props: {
schemaName,
pureName,
conid,
database,
objectTypeField: 'tables',
defaultActionId: 'openStructure',
},
});
}}>{_t('datagrid.structure', { defaultMessage: 'Structure' })}</ToolStripButton
>
<ToolStripButton
icon="img sql-file"
iconAfter="icon arrow-link"
on:click={() => {
if (tabPreviewMode && getBoolSettingsValue('defaultAction.useLastUsedAction', true)) {
lastUsedDefaultActions.update(actions => ({
...actions,
tables: 'showSql',
}));
}
openNewTab({
title: pureName,
icon: 'img sql-file',
tabComponent: 'SqlObjectTab',
tabPreviewMode: true,
props: {
schemaName,
pureName,
conid,
database,
objectTypeField: 'tables',
defaultActionId: 'showSql',
},
});
}}>SQL</ToolStripButton
>
<ToolStripCommandSplitButton
buttonLabel={autoRefreshStarted
? _t('tableData.refreshEvery', {
defaultMessage: 'Refresh (every {autoRefreshInterval}s)',
values: { autoRefreshInterval },
})
: null}
commands={['dataGrid.refresh', ...createAutoRefreshMenu()]}
hideDisabled
data-testid="TableDataTab_refreshGrid"
/>
<ToolStripCommandSplitButton
buttonLabel={autoRefreshStarted
? _t('tableData.refreshEvery', {
defaultMessage: 'Refresh (every {autoRefreshInterval}s)',
values: { autoRefreshInterval },
})
: null}
commands={['dataForm.refresh', ...createAutoRefreshMenu()]}
hideDisabled
data-testid="TableDataTab_refreshForm"
/>
<!-- <ToolStripCommandButton command="dataGrid.refresh" hideDisabled />
<ToolStripCommandButton command="dataForm.refresh" hideDisabled /> -->
<ToolStripCommandButton command="dataForm.goToFirst" hideDisabled data-testid="TableDataTab_goToFirst" />
<ToolStripCommandButton command="dataForm.goToPrevious" hideDisabled data-testid="TableDataTab_goToPrevious" />
<ToolStripCommandButton command="dataForm.goToNext" hideDisabled data-testid="TableDataTab_goToNext" />
<ToolStripCommandButton command="dataForm.goToLast" hideDisabled data-testid="TableDataTab_goToLast" />
<ToolStripCommandButton
command="tableData.save"
iconAfter={getNumberIcon(changeSetChangedCount($changeSetStore?.value))}
data-testid="TableDataTab_save"
/>
<ToolStripCommandButton
command="dataGrid.revertAllChanges"
hideDisabled
data-testid="TableDataTab_revertAllChanges"
/>
<ToolStripCommandButton command="dataGrid.insertNewRow" hideDisabled data-testid="TableDataTab_insertNewRow" />
<ToolStripCommandButton
command="dataGrid.deleteSelectedRows"
hideDisabled
data-testid="TableDataTab_deleteSelectedRows"
/>
<ToolStripCommandButton command="dataGrid.switchToForm" hideDisabled data-testid="TableDataTab_switchToForm" />
<ToolStripCommandButton command="dataGrid.switchToTable" hideDisabled data-testid="TableDataTab_switchToTable" />
<ToolStripExportButton {quickExportHandlerRef} />
<ToolStripCommandButton command="dataGrid.fetchAll" hideDisabled data-testid="TableDataTab_fetchAll" />
<ToolStripButton
icon={$collapsedLeftColumnStore ? 'icon columns-outline' : 'icon columns'}
on:click={() => collapsedLeftColumnStore.update(x => !x)}
>{_t('tableData.viewColumns', { defaultMessage: 'View columns' })}</ToolStripButton
>
<ToolStripCommandButton
command="dataGrid.toggleCellDataView"
hideDisabled
data-testid="TableDataTab_toggleCellDataView"
/>
</svelte:fragment>
</ToolStripContainer>

View File

@@ -129,6 +129,7 @@
<ToolStripCommandButton command="dataGrid.refresh" />
<ToolStripExportButton {quickExportHandlerRef} />
<ToolStripCommandButton command="dataGrid.fetchAll" hideDisabled />
<ToolStripCommandButton command="dataGrid.toggleCellDataView" hideDisabled />
</svelte:fragment>
</ToolStripContainer>

View File

@@ -0,0 +1,353 @@
import { apiCall, apiOff, apiOn } from './api';
import getElectron from './getElectron';
import resolveApi, { resolveApiHeaders } from './resolveApi';
export interface FetchAllCallbacks {
/** Called with each page of rows as they arrive. */
onPage(rows: object[]): void;
/** Called once when all data has been received. */
onFinished(): void;
/** Called if an error occurs. */
onError(message: string): void;
}
export interface FetchAllHandle {
/** Signal the loader to stop fetching. */
cancel(): void;
}
const STREAM_BATCH_SIZE = 1000;
const WEB_PAGE_SIZE = 5000;
/**
* Fetches all rows from a JSONL source.
*
* Electron: uses paginated `jsldata/get-rows` via IPC (already fast).
* Web: waits for source to finish, then streams the entire JSONL file in a
* single HTTP request via `jsldata/stream-rows`, parsing lines
* progressively with ReadableStream. Falls back to paginated reads
* with larger page sizes if streaming is unavailable.
*/
export function fetchAll(
jslid: string,
loadDataPage: (offset: number, limit: number) => Promise<any>,
callbacks: FetchAllCallbacks,
pageSize: number = 100
): FetchAllHandle {
const isElectron = !!getElectron();
if (isElectron) {
return fetchAllPaginated(jslid, loadDataPage, callbacks, pageSize);
} else {
return fetchAllWeb(jslid, loadDataPage, callbacks);
}
}
/**
* Web strategy: listen to SSE stats for progress, once source is finished
* stream the entire JSONL in one HTTP request.
*/
function fetchAllWeb(
jslid: string,
loadDataPage: (offset: number, limit: number) => Promise<any>,
callbacks: FetchAllCallbacks
): FetchAllHandle {
let cancelled = false;
let streamStarted = false;
let abortController: AbortController | null = null;
let streamReader: ReadableStreamDefaultReader<Uint8Array> | null = null;
// Initialize cancelFn before registering the SSE handler to avoid TDZ errors
// if an immediate stats event triggers fallbackToPaginated() before initialization.
let cancelFn = () => {
cancelled = true;
if (streamReader) {
streamReader.cancel().catch(() => {});
streamReader = null;
}
if (abortController) {
abortController.abort();
abortController = null;
}
cleanup();
};
const handleStats = (stats: { rowCount: number; changeIndex: number; isFinished: boolean }) => {
if (cancelled || streamStarted) return;
// Report progress while source is still writing
if (!stats.isFinished) {
callbacks.onPage([]); // trigger UI update with count info
return;
}
// Source finished — stream all rows at once
streamStarted = true;
startStream();
};
apiOn(`jsldata-stats-${jslid}`, handleStats);
async function startStream() {
abortController = new AbortController();
try {
const resp = await fetch(`${resolveApi()}/jsldata/stream-rows?jslid=${encodeURIComponent(jslid)}`, {
method: 'GET',
cache: 'no-cache',
signal: abortController.signal,
headers: {
...resolveApiHeaders(),
},
});
if (!resp.body || resp.status === 404 || resp.status === 405) {
// Streaming endpoint not available in this environment — fall back to paginated reads
cleanup();
fallbackToPaginated();
return;
}
if (!resp.ok) {
// Non-recoverable server error (e.g. 403 security rejection, 5xx) — surface it
callbacks.onError(`HTTP ${resp.status}: ${resp.statusText}`);
cleanup();
return;
}
streamReader = resp.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
let isFirstLine = true;
let batch: any[] = [];
while (!cancelled) {
const { done, value } = await streamReader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n');
buffer = lines.pop() || '';
for (const line of lines) {
if (cancelled) break;
const trimmed = line.trim();
if (!trimmed) continue;
if (isFirstLine) {
isFirstLine = false;
// Check if first line is a header
try {
const parsed = JSON.parse(trimmed);
if (parsed.__isStreamHeader) continue;
// Not a header — it's a data row
batch.push(parsed);
} catch {
continue;
}
continue;
}
try {
batch.push(JSON.parse(trimmed));
} catch {
// skip malformed lines
}
if (batch.length >= STREAM_BATCH_SIZE) {
if (cancelled) break;
callbacks.onPage(batch);
batch = [];
}
}
}
// Flush the decoder — any bytes held for multi-byte char completion are released
const flushed = decoder.decode();
if (flushed) buffer += flushed;
// Process remaining buffer
const remainingBuffer = buffer.trim();
if (remainingBuffer && !cancelled) {
try {
const parsed = JSON.parse(remainingBuffer);
if (!parsed.__isStreamHeader) {
batch.push(parsed);
}
} catch {
// ignore
}
}
if (batch.length > 0 && !cancelled) {
callbacks.onPage(batch);
}
if (!cancelled) {
callbacks.onFinished();
}
} catch (err) {
if (!cancelled) {
callbacks.onError(err?.message ?? String(err));
}
} finally {
streamReader = null;
abortController = null;
cleanup();
}
}
function fallbackToPaginated() {
const handle = fetchAllPaginated(jslid, loadDataPage, callbacks, WEB_PAGE_SIZE);
cancelFn = handle.cancel;
}
function cleanup() {
apiOff(`jsldata-stats-${jslid}`, handleStats);
}
// Check if data is already finished
checkInitialState();
async function checkInitialState() {
try {
const stats = await apiCall('jsldata/get-stats', { jslid });
if (stats && stats.isFinished && stats.rowCount > 0) {
streamStarted = true;
startStream();
} else if (stats && stats.isFinished && stats.rowCount === 0) {
// Source finished with zero rows — no SSE event will follow, finish immediately
cleanup();
callbacks.onFinished();
}
// Source still writing or no stats yet — SSE events will trigger stream when done
} catch {
// Stats not available yet — SSE events will arrive
}
}
return {
cancel() {
cancelFn();
},
};
}
/**
* Paginated strategy (Electron / fallback): uses `jsldata/get-rows` with
* SSE stats events to know when new data is available.
*/
function fetchAllPaginated(
jslid: string,
loadDataPage: (offset: number, limit: number) => Promise<any>,
callbacks: FetchAllCallbacks,
pageSize: number
): FetchAllHandle {
let cancelled = false;
let finished = false;
let offset = 0;
let isRunning = false;
let isSourceFinished = false;
let drainRequested = false;
function finish() {
if (finished) return;
finished = true;
callbacks.onFinished();
cleanup();
}
const handleStats = (stats: { rowCount: number; changeIndex: number; isFinished: boolean }) => {
isSourceFinished = stats.isFinished;
if (stats.rowCount > offset) {
scheduleDrain();
} else if (stats.isFinished && stats.rowCount === offset) {
finish();
}
};
function scheduleDrain() {
if (isRunning) {
drainRequested = true;
return;
}
drain();
}
apiOn(`jsldata-stats-${jslid}`, handleStats);
async function drain() {
if (isRunning || cancelled) return;
isRunning = true;
drainRequested = false;
try {
while (!cancelled) {
const rows = await loadDataPage(offset, pageSize);
if (cancelled) break;
if (rows.errorMessage) {
callbacks.onError(rows.errorMessage);
cleanup();
return;
}
if (rows.length > 0) {
offset += rows.length;
callbacks.onPage(rows);
}
if (rows.length < pageSize) {
if (isSourceFinished) {
finish();
return;
}
break;
}
await new Promise(resolve => setTimeout(resolve, 0));
}
} catch (err) {
if (!cancelled) {
const msg = err?.message ?? String(err);
if (msg.includes('ENOENT')) {
// File not ready yet
} else {
callbacks.onError(msg);
cleanup();
}
}
} finally {
isRunning = false;
if (drainRequested && !cancelled) {
scheduleDrain();
}
}
}
function cleanup() {
apiOff(`jsldata-stats-${jslid}`, handleStats);
}
checkInitialState();
async function checkInitialState() {
try {
const stats = await apiCall('jsldata/get-stats', { jslid });
if (stats) {
isSourceFinished = stats.isFinished;
if (stats.rowCount > 0) {
scheduleDrain();
} else if (stats.isFinished && !cancelled) {
// rowCount === 0: source finished empty — no SSE event will follow
finish();
}
}
} catch {
// Stats not available yet
}
}
return {
cancel() {
cancelled = true;
cleanup();
},
};
}