Merge branch 'feature/restore-script'

This commit is contained in:
SPRINX0\prochazka
2025-12-01 14:27:29 +01:00
7 changed files with 197 additions and 16 deletions

View File

@@ -1,7 +1,7 @@
import type { SqlDumper } from 'dbgate-types'; import type { SqlDumper } from 'dbgate-types';
import { Command, Select, Update, Delete, Insert } from './types'; import { Command, Select, Update, Delete, Insert } from './types';
import { dumpSqlExpression } from './dumpSqlExpression'; import { dumpSqlExpression } from './dumpSqlExpression';
import { dumpSqlFromDefinition, dumpSqlSourceRef } from './dumpSqlSource'; import { dumpSqlFromDefinition, dumpSqlSourceDef, dumpSqlSourceRef } from './dumpSqlSource';
import { dumpSqlCondition } from './dumpSqlCondition'; import { dumpSqlCondition } from './dumpSqlCondition';
export function dumpSqlSelect(dmp: SqlDumper, cmd: Select) { export function dumpSqlSelect(dmp: SqlDumper, cmd: Select) {
@@ -115,7 +115,10 @@ export function dumpSqlInsert(dmp: SqlDumper, cmd: Insert) {
cmd.fields.map(x => x.targetColumn) cmd.fields.map(x => x.targetColumn)
); );
dmp.putCollection(',', cmd.fields, x => dumpSqlExpression(dmp, x)); dmp.putCollection(',', cmd.fields, x => dumpSqlExpression(dmp, x));
if (dmp.dialect.requireFromDual) { if (cmd.whereNotExistsSource) {
dmp.put(' ^from ');
dumpSqlSourceDef(dmp, cmd.whereNotExistsSource);
} else if (dmp.dialect.requireFromDual) {
dmp.put(' ^from ^dual '); dmp.put(' ^from ^dual ');
} }
dmp.put(' ^where ^not ^exists (^select * ^from %f ^where ', cmd.targetTable); dmp.put(' ^where ^not ^exists (^select * ^from %f ^where ', cmd.targetTable);

View File

@@ -2,6 +2,7 @@ import _ from 'lodash';
import type { SqlDumper } from 'dbgate-types'; import type { SqlDumper } from 'dbgate-types';
import { Expression, ColumnRefExpression } from './types'; import { Expression, ColumnRefExpression } from './types';
import { dumpSqlSourceRef } from './dumpSqlSource'; import { dumpSqlSourceRef } from './dumpSqlSource';
import { dumpSqlSelect } from './dumpSqlCommand';
export function dumpSqlExpression(dmp: SqlDumper, expr: Expression) { export function dumpSqlExpression(dmp: SqlDumper, expr: Expression) {
switch (expr.exprType) { switch (expr.exprType) {
@@ -67,5 +68,11 @@ export function dumpSqlExpression(dmp: SqlDumper, expr: Expression) {
}); });
dmp.put(')'); dmp.put(')');
break; break;
case 'select':
dmp.put('(');
dumpSqlSelect(dmp, expr.select);
dmp.put(')');
break;
} }
} }

View File

@@ -44,6 +44,7 @@ export interface Insert {
fields: UpdateField[]; fields: UpdateField[];
targetTable: NamedObjectInfo; targetTable: NamedObjectInfo;
insertWhereNotExistsCondition?: Condition; insertWhereNotExistsCondition?: Condition;
whereNotExistsSource?: Source;
} }
export interface AllowIdentityInsert { export interface AllowIdentityInsert {
@@ -226,6 +227,11 @@ export interface RowNumberExpression {
orderBy: OrderByExpression[]; orderBy: OrderByExpression[];
} }
export interface SelectExpression {
exprType: 'select';
select: Select;
}
export type Expression = export type Expression =
| ColumnRefExpression | ColumnRefExpression
| ValueExpression | ValueExpression
@@ -235,7 +241,8 @@ export type Expression =
| CallExpression | CallExpression
| MethodCallExpression | MethodCallExpression
| TranformExpression | TranformExpression
| RowNumberExpression; | RowNumberExpression
| SelectExpression;
export type OrderByExpression = Expression & { direction: 'ASC' | 'DESC' }; export type OrderByExpression = Expression & { direction: 'ASC' | 'DESC' };
export type ResultField = Expression & { alias?: string }; export type ResultField = Expression & { alias?: string };

View File

@@ -16,6 +16,7 @@ export interface SqlDumper extends AlterProcessor {
transform(type: TransformType, dumpExpr: () => void); transform(type: TransformType, dumpExpr: () => void);
createDatabase(name: string); createDatabase(name: string);
dropDatabase(name: string); dropDatabase(name: string);
comment(value: string);
callableTemplate(func: CallableObjectInfo); callableTemplate(func: CallableObjectInfo);

View File

@@ -1,6 +1,7 @@
<script lang="ts" context="module"> <script lang="ts" context="module">
import { copyTextToClipboard } from '../utility/clipboard'; import { copyTextToClipboard } from '../utility/clipboard';
import { _t, _tval, DefferedTranslationResult } from '../translations'; import { _t, _tval, DefferedTranslationResult } from '../translations';
import sqlFormatter from 'sql-formatter';
export const extractKey = ({ schemaName, pureName }) => (schemaName ? `${schemaName}.${pureName}` : pureName); export const extractKey = ({ schemaName, pureName }) => (schemaName ? `${schemaName}.${pureName}` : pureName);
export const createMatcher = export const createMatcher =
@@ -88,7 +89,8 @@
isRename?: boolean; isRename?: boolean;
isTruncate?: boolean; isTruncate?: boolean;
isCopyTableName?: boolean; isCopyTableName?: boolean;
isDuplicateTable?: boolean; isTableBackup?: boolean;
isTableRestore?: boolean;
isDiagram?: boolean; isDiagram?: boolean;
functionName?: string; functionName?: string;
isExport?: boolean; isExport?: boolean;
@@ -106,6 +108,8 @@
} }
function createMenusCore(objectTypeField, driver, data): DbObjMenuItem[] { function createMenusCore(objectTypeField, driver, data): DbObjMenuItem[] {
const backupMatch = data.objectTypeField === 'tables' ? data.pureName.match(TABLE_BACKUP_REGEX) : null;
switch (objectTypeField) { switch (objectTypeField) {
case 'tables': case 'tables':
return [ return [
@@ -175,11 +179,18 @@
isCopyTableName: true, isCopyTableName: true,
requiresWriteAccess: false, requiresWriteAccess: false,
}, },
hasPermission('dbops/table/backup') && { hasPermission('dbops/table/backup') &&
label: _t('dbObject.createTableBackup', { defaultMessage: 'Create table backup' }), !backupMatch && {
isDuplicateTable: true, label: _t('dbObject.createTableBackup', { defaultMessage: 'Create table backup' }),
requiresWriteAccess: true, isTableBackup: true,
}, requiresWriteAccess: true,
},
hasPermission('dbops/table/restore') &&
backupMatch && {
label: _t('dbObject.createRestoreScript', { defaultMessage: 'Create restore script' }),
isTableRestore: true,
requiresWriteAccess: true,
},
hasPermission('dbops/model/view') && { hasPermission('dbops/model/view') && {
label: _t('dbObject.showDiagram', { defaultMessage: 'Show diagram' }), label: _t('dbObject.showDiagram', { defaultMessage: 'Show diagram' }),
isDiagram: true, isDiagram: true,
@@ -637,7 +648,7 @@
}); });
}, },
}); });
} else if (menu.isDuplicateTable) { } else if (menu.isTableBackup) {
const driver = await getDriver(); const driver = await getDriver();
const dmp = driver.createDumper(); const dmp = driver.createDumper();
const newTable = _.cloneDeep(data); const newTable = _.cloneDeep(data);
@@ -671,6 +682,25 @@
}, },
engine: driver.engine, engine: driver.engine,
}); });
} else if (menu.isTableRestore) {
const backupMatch = data.objectTypeField === 'tables' ? data.pureName.match(TABLE_BACKUP_REGEX) : null;
const driver = await getDriver();
const dmp = driver.createDumper();
const db = await getDatabaseInfo(data);
if (db) {
const originalTable = db?.tables?.find(x => x.pureName == backupMatch[1] && x.schemaName == data.schemaName);
if (originalTable) {
createTableRestoreScript(data, originalTable, dmp);
newQuery({
title: _t('dbObject.restoreScript', {
defaultMessage: 'Restore {name} #',
values: { name: backupMatch[1] },
}),
initialData: sqlFormatter.format(dmp.s),
});
}
}
} else if (menu.isImport) { } else if (menu.isImport) {
const { conid, database } = data; const { conid, database } = data;
openImportExportTab({ openImportExportTab({
@@ -1008,6 +1038,8 @@
return handleDatabaseObjectClick(data, { forceNewTab, tabPreviewMode, focusTab }); return handleDatabaseObjectClick(data, { forceNewTab, tabPreviewMode, focusTab });
} }
export const TABLE_BACKUP_REGEX = /^_(.*)_(\d\d\d\d)-(\d\d)-(\d\d)-(\d\d)-(\d\d)-(\d\d)$/;
</script> </script>
<script lang="ts"> <script lang="ts">
@@ -1025,7 +1057,7 @@
} from '../stores'; } from '../stores';
import openNewTab from '../utility/openNewTab'; import openNewTab from '../utility/openNewTab';
import { extractDbNameFromComposite, filterNameCompoud, getConnectionLabel } from 'dbgate-tools'; import { extractDbNameFromComposite, filterNameCompoud, getConnectionLabel } from 'dbgate-tools';
import { getConnectionInfo } from '../utility/metadataLoaders'; import { getConnectionInfo, getDatabaseInfo } from '../utility/metadataLoaders';
import fullDisplayName from '../utility/fullDisplayName'; import fullDisplayName from '../utility/fullDisplayName';
import { showModal } from '../modals/modalTools'; import { showModal } from '../modals/modalTools';
import { findEngineDriver } from 'dbgate-tools'; import { findEngineDriver } from 'dbgate-tools';
@@ -1047,6 +1079,8 @@
import { getBoolSettingsValue, getOpenDetailOnArrowsSettings } from '../settings/settingsTools'; import { getBoolSettingsValue, getOpenDetailOnArrowsSettings } from '../settings/settingsTools';
import { isProApp } from '../utility/proTools'; import { isProApp } from '../utility/proTools';
import formatFileSize from '../utility/formatFileSize'; import formatFileSize from '../utility/formatFileSize';
import { createTableRestoreScript } from '../utility/tableRestoreScript';
import newQuery from '../query/newQuery';
export let data; export let data;
export let passProps; export let passProps;
@@ -1087,10 +1121,7 @@
$: isPinned = !!$pinnedTables.find(x => testEqual(data, x)); $: isPinned = !!$pinnedTables.find(x => testEqual(data, x));
$: backupParsed = $: backupParsed = data.objectTypeField === 'tables' ? data.pureName.match(TABLE_BACKUP_REGEX) : null;
data.objectTypeField === 'tables'
? data.pureName.match(/^_(.*)_(\d\d\d\d)-(\d\d)-(\d\d)-(\d\d)-(\d\d)-(\d\d)$/)
: null;
$: backupTitle = $: backupTitle =
backupParsed != null backupParsed != null
? `${backupParsed[1]} (${backupParsed[2]}-${backupParsed[3]}-${backupParsed[4]} ${backupParsed[5]}:${backupParsed[6]}:${backupParsed[7]})` ? `${backupParsed[1]} (${backupParsed[2]}-${backupParsed[3]}-${backupParsed[4]} ${backupParsed[5]}:${backupParsed[6]}:${backupParsed[7]})`

View File

@@ -0,0 +1,132 @@
import _ from 'lodash';
import { Condition, dumpSqlInsert, dumpSqlUpdate, Insert, Update, Delete, dumpSqlDelete } from 'dbgate-sqltree';
import { TableInfo, SqlDumper } from 'dbgate-types';
export function createTableRestoreScript(backupTable: TableInfo, originalTable: TableInfo, dmp: SqlDumper) {
const bothColumns = _.intersection(
backupTable.columns.map(x => x.columnName),
originalTable.columns.map(x => x.columnName)
);
const keyColumns = _.intersection(
originalTable.primaryKey?.columns?.map(x => x.columnName) || [],
backupTable.columns.map(x => x.columnName)
);
const valueColumns = _.difference(bothColumns, keyColumns);
function makeColumnCond(colName: string, operator: '=' | '<>' | '<' | '>' | '<=' | '>=' = '='): Condition {
return {
conditionType: 'binary',
operator,
left: {
exprType: 'column',
columnName: colName,
source: { name: originalTable },
},
right: {
exprType: 'column',
columnName: colName,
source: { alias: 'bak' },
},
};
}
function putTitle(title: string) {
dmp.putRaw('\n\n');
dmp.comment(`******************** ${title} ********************`);
dmp.putRaw('\n');
}
dmp.comment(`Restoring data into table ${originalTable.pureName} from backup table ${backupTable.pureName}`);
dmp.putRaw('\n');
dmp.comment(`Key columns: ${keyColumns.join(', ')}`);
dmp.putRaw('\n');
dmp.comment(`Value columns: ${valueColumns.join(', ')}`);
dmp.putRaw('\n');
dmp.comment(`Follows UPDATE, DELETE, INSERT statements to restore data`);
dmp.putRaw('\n');
const update: Update = {
commandType: 'update',
from: { name: originalTable },
fields: valueColumns.map(colName => ({
exprType: 'select',
select: {
commandType: 'select',
from: { name: backupTable, alias: 'bak' },
columns: [
{
exprType: 'column',
columnName: colName,
source: { alias: 'bak' },
},
],
where: {
conditionType: 'and',
conditions: keyColumns.map(colName => makeColumnCond(colName)),
},
},
targetColumn: colName,
})),
where: {
conditionType: 'exists',
subQuery: {
commandType: 'select',
from: { name: backupTable, alias: 'bak' },
selectAll: true,
where: {
conditionType: 'and',
conditions: [
...keyColumns.map(keyColName => makeColumnCond(keyColName)),
{
conditionType: 'or',
conditions: valueColumns.map(colName => makeColumnCond(colName, '<>')),
},
],
},
},
},
};
putTitle('UPDATE');
dumpSqlUpdate(dmp, update);
dmp.endCommand();
const delcmd: Delete = {
commandType: 'delete',
from: { name: originalTable },
where: {
conditionType: 'notExists',
subQuery: {
commandType: 'select',
from: { name: backupTable, alias: 'bak' },
selectAll: true,
where: {
conditionType: 'and',
conditions: keyColumns.map(colName => makeColumnCond(colName)),
},
},
},
};
putTitle('DELETE');
dumpSqlDelete(dmp, delcmd);
dmp.endCommand();
const insert: Insert = {
commandType: 'insert',
targetTable: originalTable,
fields: bothColumns.map(colName => ({
targetColumn: colName,
exprType: 'column',
columnName: colName,
source: { alias: 'bak' },
})),
whereNotExistsSource: { name: backupTable, alias: 'bak' },
insertWhereNotExistsCondition: {
conditionType: 'and',
conditions: keyColumns.map(colName => makeColumnCond(colName)),
},
};
putTitle('INSERT');
dumpSqlInsert(dmp, insert);
dmp.endCommand();
}

View File

@@ -235,7 +235,7 @@
function getAppObjectGroup(data) { function getAppObjectGroup(data) {
if (data.objectTypeField == 'tables') { if (data.objectTypeField == 'tables') {
if (data.pureName.match(/^_(.*)_\d\d\d\d-\d\d-\d\d-\d\d-\d\d-\d\d$/)) { if (data.pureName.match(databaseObjectAppObject.TABLE_BACKUP_REGEX)) {
return _t('dbObject.tableBackups', { defaultMessage: 'Table Backups' }); return _t('dbObject.tableBackups', { defaultMessage: 'Table Backups' });
} }
} }