diff --git a/src/common/customizations/mysql.js b/src/common/customizations/mysql.js index 673adea2..79fae6bc 100644 --- a/src/common/customizations/mysql.js +++ b/src/common/customizations/mysql.js @@ -30,6 +30,7 @@ module.exports = { functionAdd: true, schedulerAdd: true, schemaEdit: true, + schemaExport: true, tableSettings: true, viewSettings: true, triggerSettings: true, diff --git a/src/common/customizations/postgresql.js b/src/common/customizations/postgresql.js index 602663e4..655a716b 100644 --- a/src/common/customizations/postgresql.js +++ b/src/common/customizations/postgresql.js @@ -27,6 +27,7 @@ module.exports = { routineAdd: true, functionAdd: true, databaseEdit: false, + schemaExport: true, tableSettings: true, viewSettings: true, triggerSettings: true, diff --git a/src/main/ipc-handlers/application.js b/src/main/ipc-handlers/application.js index 31a3fe0d..a8033a2d 100644 --- a/src/main/ipc-handlers/application.js +++ b/src/main/ipc-handlers/application.js @@ -1,4 +1,4 @@ -import { app, ipcMain } from 'electron'; +import { app, ipcMain, dialog } from 'electron'; export default () => { ipcMain.on('close-app', () => { @@ -9,4 +9,12 @@ export default () => { const key = false; event.returnValue = key; }); + + ipcMain.handle('showOpenDialog', (event, options) => { + return dialog.showOpenDialog(options); + }); + + ipcMain.handle('get-download-dir-path', () => { + return app.getPath('downloads'); + }); }; diff --git a/src/main/ipc-handlers/schema.js b/src/main/ipc-handlers/schema.js index 10d71447..7380823e 100644 --- a/src/main/ipc-handlers/schema.js +++ b/src/main/ipc-handlers/schema.js @@ -1,7 +1,12 @@ +import { ipcMain, dialog, Notification } from 'electron'; +import path from 'path'; +import fs from 'fs'; -import { ipcMain } from 'electron'; +import MysqlExporter from '../libs/exporters/sql/MysqlExporter'; export default connections => { + let exporter = null; + ipcMain.handle('create-schema', async (event, params) => { try { await connections[params.uid].createSchema(params); @@ -37,9 +42,16 @@ export default connections => { ipcMain.handle('get-schema-collation', async (event, params) => { try { - const collation = await connections[params.uid].getDatabaseCollation(params); + const collation = await connections[params.uid].getDatabaseCollation( + params + ); - return { status: 'success', response: collation.rows.length ? collation.rows[0].DEFAULT_COLLATION_NAME : '' }; + return { + status: 'success', + response: collation.rows.length + ? collation.rows[0].DEFAULT_COLLATION_NAME + : '' + }; } catch (err) { return { status: 'error', response: err.toString() }; @@ -48,7 +60,9 @@ export default connections => { ipcMain.handle('get-structure', async (event, params) => { try { - const structure = await connections[params.uid].getStructure(params.schemas); + const structure = await connections[params.uid].getStructure( + params.schemas + ); return { status: 'success', response: structure }; } @@ -152,4 +166,98 @@ export default connections => { return { status: 'error', response: err.toString() }; } }); + + ipcMain.handle('export', async (event, { uid, ...rest }) => { + if (exporter !== null) return; + + const type = connections[uid]._client; + + switch (type) { + case 'mysql': + exporter = new MysqlExporter(connections[uid], rest); + break; + default: + return { + status: 'error', + response: `${type} exporter not aviable` + }; + } + + const outputFileName = path.basename(rest.outputFile); + + if (fs.existsSync(rest.outputFile)) { + const result = await dialog.showMessageBox({ + type: 'warning', + message: `File ${outputFileName} already exists. Do you want to replace it?`, + detail: + 'A file with the same name already exists in the target folder. Replacing it will overwrite its current contents.', + buttons: ['Cancel', 'Replace'], + defaultId: 0, + cancelId: 0 + }); + + if (result.response !== 1) { + exporter = null; + return { status: 'error', response: 'Operation aborted' }; + } + } + + return new Promise((resolve, reject) => { + exporter.once('error', err => { + reject(err); + }); + + exporter.once('end', () => { + resolve({ cancelled: exporter.isCancelled }); + }); + + exporter.on('progress', state => { + event.sender.send('export-progress', state); + }); + + exporter.run(); + }) + .then(response => { + if (!response.cancelled) { + new Notification({ + title: 'Export finished', + body: `Finished exporting to ${outputFileName}` + }).show(); + } + return { status: 'success', response }; + }) + .catch(err => { + new Notification({ + title: 'Export error', + body: err.toString() + }).show(); + + return { status: 'error', response: err.toString() }; + }) + .finally(() => { + exporter.removeAllListeners(); + exporter = null; + }); + }); + + ipcMain.handle('abort-export', async event => { + let willAbort = false; + + if (exporter) { + const result = await dialog.showMessageBox({ + type: 'warning', + message: 'Are you sure you want to abort the export', + buttons: ['Cancel', 'Abort'], + defaultId: 0, + cancelId: 0 + }); + + if (result.response === 1) { + willAbort = true; + exporter.cancel(); + } + } + + return { status: 'success', response: { willAbort } }; + }); }; diff --git a/src/main/libs/exporters/BaseExporter.js b/src/main/libs/exporters/BaseExporter.js new file mode 100644 index 00000000..fe5e5970 --- /dev/null +++ b/src/main/libs/exporters/BaseExporter.js @@ -0,0 +1,73 @@ +import fs from 'fs'; +import path from 'path'; +import EventEmitter from 'events'; + +export class BaseExporter extends EventEmitter { + constructor (options) { + super(); + this._options = options; + this._isCancelled = false; + this._outputStream = fs.createWriteStream(this._options.outputFile, { + flags: 'w' + }); + this._state = {}; + + this._outputStream.once('error', err => { + this._isCancelled = true; + this.emit('error', err); + }); + } + + async run () { + try { + this.emit('start', this); + await this.dump(); + } + catch (err) { + this.emit('error', err); + throw err; + } + finally { + this._outputStream.end(); + this.emit('end'); + } + } + + get isCancelled () { + return this._isCancelled; + } + + outputFileExists () { + return fs.existsSync(this._options.outputFile); + } + + cancel () { + this._isCancelled = true; + this.emit('cancel'); + this.emitUpdate({ op: 'cancelling' }); + } + + emitUpdate (state) { + this.emit('progress', { ...this._state, ...state }); + } + + writeString (data) { + if (this._isCancelled) return; + + try { + fs.accessSync(this._options.outputFile); + } + catch (err) { + this._isCancelled = true; + + const fileName = path.basename(this._options.outputFile); + this.emit('error', `The file ${fileName} is not accessible`); + } + + this._outputStream.write(data); + } + + dump () { + throw new Error('Exporter must implement the "dump" method'); + } +} diff --git a/src/main/libs/exporters/ExporterFactory.js b/src/main/libs/exporters/ExporterFactory.js new file mode 100644 index 00000000..7c3165dc --- /dev/null +++ b/src/main/libs/exporters/ExporterFactory.js @@ -0,0 +1,35 @@ +import { MysqlExporter } from './sql/MysqlExporter'; + +export class ExporterFactory { + /** + * Returns a data exporter class instance. + * + * @param {Object} args + * @param {String} args.client + * @param {Object} args.params + * @param {String} args.params.host + * @param {Number} args.params.port + * @param {String} args.params.password + * @param {String=} args.params.database + * @param {String=} args.params.schema + * @param {String} args.params.ssh.host + * @param {String} args.params.ssh.username + * @param {String} args.params.ssh.password + * @param {Number} args.params.ssh.port + * @param {Number=} args.poolSize + * @returns Exporter Instance + * @memberof ExporterFactory + */ + static get (args) { + switch (type) { + case 'mysql': + exporter = new MysqlExporter(connections[uid], rest); + break; + default: + return { + status: 'error', + response: `${type} exporter not aviable` + }; + } + } +} diff --git a/src/main/libs/exporters/sql/MysqlExporter.js b/src/main/libs/exporters/sql/MysqlExporter.js new file mode 100644 index 00000000..875cd0b7 --- /dev/null +++ b/src/main/libs/exporters/sql/MysqlExporter.js @@ -0,0 +1,108 @@ +import { SqlExporter } from './SqlExporter'; +import { BLOB, BIT } from 'common/fieldTypes'; +import hexToBinary from 'common/libs/hexToBinary'; + +export default class MysqlExporter extends SqlExporter { + async getSqlHeader () { + let dump = await super.getSqlHeader(); + dump += ` + + +/*!40101 SET @OLD_CHARACTER_SET_CLIENT=@@CHARACTER_SET_CLIENT */; +/*!40101 SET @OLD_CHARACTER_SET_RESULTS=@@CHARACTER_SET_RESULTS */; +/*!40101 SET @OLD_COLLATION_CONNECTION=@@COLLATION_CONNECTION */; +SET NAMES utf8mb4; +/*!40014 SET @OLD_FOREIGN_KEY_CHECKS=@@FOREIGN_KEY_CHECKS, FOREIGN_KEY_CHECKS=0 */; +/*!40101 SET @OLD_SQL_MODE='NO_AUTO_VALUE_ON_ZERO', SQL_MODE='NO_AUTO_VALUE_ON_ZERO' */; +/*!40111 SET @OLD_SQL_NOTES=@@SQL_NOTES, SQL_NOTES=0 */;`; + + return dump; + } + + async getFooter () { + return `/*!40111 SET SQL_NOTES=@OLD_SQL_NOTES */; +/*!40101 SET SQL_MODE=@OLD_SQL_MODE */; +/*!40014 SET FOREIGN_KEY_CHECKS=@OLD_FOREIGN_KEY_CHECKS */; +/*!40101 SET CHARACTER_SET_CLIENT=@OLD_CHARACTER_SET_CLIENT */; +/*!40101 SET CHARACTER_SET_RESULTS=@OLD_CHARACTER_SET_RESULTS */; +/*!40101 SET COLLATION_CONNECTION=@OLD_COLLATION_CONNECTION */;`; + } + + async getCreateTable (tableName) { + const { rows } = await this._client.raw(`SHOW CREATE TABLE \`${this.schemaName}\`.\`${tableName}\``); + + if (rows.length !== 1) + return ''; + + return rows[0]['Create Table'] + ';'; + } + + getDropTable (tableName) { + return `DROP TABLE IF EXISTS \`${tableName}\`;`; + } + + async getTableInsert (tableName) { + let rowCount = 0; + let sqlStr = ''; + + const countResults = await this._client.raw(`SELECT COUNT(1) as count FROM \`${this.schemaName}\`.\`${tableName}\``); + if (countResults.rows.length === 1) + rowCount = countResults.rows[0].count; + + if (rowCount > 0) { + const columns = await this._client.getTableColumns({ table: tableName, schema: this.schemaName }); + const columnNames = columns.map(col => '`' + col.name + '`'); + const insertStmt = `INSERT INTO \`${tableName}\` (${columnNames.join(', ')}) VALUES`; + + const tableResult = await this._client.raw(`SELECT ${columnNames.join(', ')} FROM \`${this.schemaName}\`.\`${tableName}\``); + + sqlStr += `LOCK TABLES \`${tableName}\` WRITE;\n`; + sqlStr += `/*!40000 ALTER TABLE \`${tableName}\` DISABLE KEYS */;`; + sqlStr += '\n\n'; + + sqlStr += insertStmt; + sqlStr += '\n'; + + for (const row of tableResult.rows) { + sqlStr += '\t('; + + for (const i in columns) { + const column = columns[i]; + const val = row[column.name]; + + if (val === null) + sqlStr += 'NULL'; + + else if (BIT.includes(column.type)) + sqlStr += `b'${hexToBinary(Buffer.from(val).toString('hex'))}'`; + + else if (BLOB.includes(column.type)) + sqlStr += `X'${val.toString('hex').toUpperCase()}'`; + + else if (val === '') + sqlStr += '\'\''; + + else + sqlStr += typeof val === 'string' ? this.escapeAndQuote(val) : val; + + if (parseInt(i) !== columns.length - 1) + sqlStr += ', '; + } + + sqlStr += '),\n'; + } + + sqlStr += '\n'; + + sqlStr += `/*!40000 ALTER TABLE \`${tableName}\` ENABLE KEYS */;\n`; + sqlStr += 'UNLOCK TABLES;'; + } + + return sqlStr; + } + + escapeAndQuote (value) { + if (!value) return null; + return `'${value.replaceAll(/'/g, '\'\'')}'`; + } +} diff --git a/src/main/libs/exporters/sql/SqlExporter.js b/src/main/libs/exporters/sql/SqlExporter.js new file mode 100644 index 00000000..6d488dd5 --- /dev/null +++ b/src/main/libs/exporters/sql/SqlExporter.js @@ -0,0 +1,122 @@ +import { app } from 'electron'; +import moment from 'moment'; +import { BaseExporter } from '../BaseExporter'; + +export class SqlExporter extends BaseExporter { + constructor (client, options) { + super(options); + this._client = client; + this._commentChar = '#'; + } + + get schemaName () { + return this._options.schema; + } + + get host () { + return this._client._params.host; + } + + async getServerVersion () { + const version = await this._client.getVersion(); + return `${version.name} ${version.number}`; + } + + async dump () { + const exportState = { + totalItems: this._options.items.length, + currentItemIndex: 0, + currentItem: '', + op: '' + }; + + const header = await this.getSqlHeader(); + this.writeString(header); + this.writeString('\n\n\n'); + + for (const item of this._options.items) { + // user abort operation + if (this.isCancelled) + return; + + // skip item if not set to output any detail for them + if (!item.includeStructure && !item.includeContent && !item.includeDropStatement) + continue; + + exportState.currentItemIndex++; + exportState.currentItem = item.table; + exportState.op = 'PROCESSING'; + + this.emitUpdate(exportState); + + const tableHeader = this.buildComment(`Dump of table ${item.table}\n------------------------------------------------------------`); + this.writeString(tableHeader); + this.writeString('\n\n'); + + if (item.includeDropStatement) { + const dropTableSyntax = this.getDropTable(item.table); + this.writeString(dropTableSyntax); + this.writeString('\n\n'); + } + + if (item.includeStructure) { + const createTableSyntax = await this.getCreateTable(item.table); + this.writeString(createTableSyntax); + this.writeString('\n\n'); + } + + if (item.includeContent) { + exportState.op = 'FETCH'; + this.emitUpdate(exportState); + const tableInsertSyntax = await this.getTableInsert(item.table); + + exportState.op = 'WRITE'; + this.emitUpdate(exportState); + this.writeString(tableInsertSyntax); + this.writeString('\n\n'); + } + + this.writeString('\n\n'); + } + + const footer = await this.getFooter(); + this.writeString(footer); + } + + buildComment (text) { + return text.split('\n').map(txt => `${this._commentChar} ${txt}`).join('\n'); + } + + async getSqlHeader () { + const serverVersion = await this.getServerVersion(); + const header = `************************************************************ +Antares - SQL Client +Version ${app.getVersion()} + +https://antares-sql.app/ +https://github.com/Fabio286/antares + +Host: ${this.host} (${serverVersion}) +Database: ${this.schemaName} +Generation time: ${moment().format()} +************************************************************`; + + return this.buildComment(header); + } + + async getFooter () { + return ''; + } + + getCreateTable (tableName) { + throw new Error('Sql Exporter must implement the "getCreateTable" method'); + } + + getDropTable (tableName) { + throw new Error('Sql Exporter must implement the "getDropTable" method'); + } + + getTableInsert (tableName) { + throw new Error('Sql Exporter must implement the "getTableInsert" method'); + } +} diff --git a/src/main/workers/ExportService.js b/src/main/workers/ExportService.js new file mode 100644 index 00000000..e82524a5 --- /dev/null +++ b/src/main/workers/ExportService.js @@ -0,0 +1,19 @@ +import { Worker, isMainThread, workerData, parentPort } from 'worker_threads'; +import + +if (isMainThread) { + module.exports = function run (workerData) { + return new Promise((resolve, reject) => { + const worker = new Worker(__filename, { workerData }); + worker.on('message', resolve); + worker.on('error', reject); + worker.on('exit', (code) => { + if (code !== 0) + reject(new Error(`Worker stopped with exit code ${code}`)); + }); + }); + }; +} +else { + +} diff --git a/src/renderer/components/ModalExportSchema.vue b/src/renderer/components/ModalExportSchema.vue new file mode 100644 index 00000000..5ed537ff --- /dev/null +++ b/src/renderer/components/ModalExportSchema.vue @@ -0,0 +1,304 @@ + + + + + diff --git a/src/renderer/components/WorkspaceExploreBarSchemaContext.vue b/src/renderer/components/WorkspaceExploreBarSchemaContext.vue index ea056c86..19e35add 100644 --- a/src/renderer/components/WorkspaceExploreBarSchemaContext.vue +++ b/src/renderer/components/WorkspaceExploreBarSchemaContext.vue @@ -58,6 +58,13 @@ +
+ {{ $t('word.export') }} +
+ @@ -99,6 +111,7 @@ import { mapGetters, mapActions } from 'vuex'; import BaseContextMenu from '@/components/BaseContextMenu'; import ConfirmModal from '@/components/BaseConfirmModal'; import ModalEditSchema from '@/components/ModalEditSchema'; +import ModalExportSchema from '@/components/ModalExportSchema'; import Schema from '@/ipc-api/Schema'; export default { @@ -106,7 +119,8 @@ export default { components: { BaseContextMenu, ConfirmModal, - ModalEditSchema + ModalEditSchema, + ModalExportSchema }, props: { contextEvent: MouseEvent, @@ -115,7 +129,8 @@ export default { data () { return { isDeleteModal: false, - isEditModal: false + isEditModal: false, + isExportSchemaModal: false }; }, computed: { @@ -166,6 +181,12 @@ export default { this.isEditModal = false; this.closeContext(); }, + showExportSchemaModal () { + this.isExportSchemaModal = true; + }, + hideExportSchemaModal () { + this.isExportSchemaModal = false; + }, closeContext () { this.$emit('close-context'); }, diff --git a/src/renderer/i18n/en-US.js b/src/renderer/i18n/en-US.js index 3985f11c..1b30ddc4 100644 --- a/src/renderer/i18n/en-US.js +++ b/src/renderer/i18n/en-US.js @@ -121,7 +121,8 @@ module.exports = { history: 'History', select: 'Select', passphrase: 'Passphrase', - filter: 'Filter' + filter: 'Filter', + change: 'Change' }, message: { appWelcome: 'Welcome to Antares SQL Client!', @@ -246,7 +247,9 @@ module.exports = { thereIsNoQueriesYet: 'There is no queries yet', searchForQueries: 'Search for queries', killProcess: 'Kill process', - closeTab: 'Close tab' + closeTab: 'Close tab', + exportSchema: 'Export schema', + directoryPath: 'Directory path' }, faker: { address: 'Address', diff --git a/src/renderer/i18n/it-IT.js b/src/renderer/i18n/it-IT.js index dc0be03f..6adbc630 100644 --- a/src/renderer/i18n/it-IT.js +++ b/src/renderer/i18n/it-IT.js @@ -121,7 +121,8 @@ module.exports = { history: 'Cronologia', select: 'Seleziona', passphrase: 'Passphrase', - filter: 'Filtra' + filter: 'Filtra', + change: 'Cambia' }, message: { appWelcome: 'Benvenuto in Antares SQL Client!', @@ -233,7 +234,9 @@ module.exports = { duplicateTable: 'Duplica tabella', noOpenTabs: 'Non ci sono tab aperte, naviga nella barra sinistra o:', noSchema: 'Nessuno schema', - restorePreviourSession: 'Ripristina sessione precedente' + restorePreviourSession: 'Ripristina sessione precedente', + exportSchema: 'Esporta schema', + directoryPath: 'Percorso directory' }, faker: { address: 'Indirizzo', diff --git a/src/renderer/ipc-api/Application.js b/src/renderer/ipc-api/Application.js index e221e0cc..c93b0c2e 100644 --- a/src/renderer/ipc-api/Application.js +++ b/src/renderer/ipc-api/Application.js @@ -5,4 +5,12 @@ export default class { static getKey (params) { return ipcRenderer.sendSync('get-key', params); } + + static showOpenDialog (options) { + return ipcRenderer.invoke('showOpenDialog', options); + } + + static getDownloadPathDirectory () { + return ipcRenderer.invoke('get-download-dir-path'); + } } diff --git a/src/renderer/ipc-api/Schema.js b/src/renderer/ipc-api/Schema.js index 12fde22a..ebe9c115 100644 --- a/src/renderer/ipc-api/Schema.js +++ b/src/renderer/ipc-api/Schema.js @@ -53,4 +53,12 @@ export default class { static rawQuery (params) { return ipcRenderer.invoke('raw-query', params); } + + static export (params) { + return ipcRenderer.invoke('export', params); + } + + static abortExport () { + return ipcRenderer.invoke('abort-export'); + } }