diff --git a/src/common/customizations/mysql.js b/src/common/customizations/mysql.js index 79fae6bc..3312f35f 100644 --- a/src/common/customizations/mysql.js +++ b/src/common/customizations/mysql.js @@ -31,6 +31,7 @@ module.exports = { schedulerAdd: true, schemaEdit: true, schemaExport: true, + schemaImport: true, tableSettings: true, viewSettings: true, triggerSettings: true, diff --git a/src/common/customizations/postgresql.js b/src/common/customizations/postgresql.js index 655a716b..3f413bb5 100644 --- a/src/common/customizations/postgresql.js +++ b/src/common/customizations/postgresql.js @@ -28,6 +28,7 @@ module.exports = { functionAdd: true, databaseEdit: false, schemaExport: true, + schemaImport: false, tableSettings: true, viewSettings: true, triggerSettings: true, diff --git a/src/common/libs/sqlParser.js b/src/common/libs/sqlParser.js new file mode 100644 index 00000000..4da17e7c --- /dev/null +++ b/src/common/libs/sqlParser.js @@ -0,0 +1,97 @@ +import { Duplex } from 'stream'; + +export default class SqlParser extends Duplex { + constructor (opts) { + opts = { + delimiter: ';', + encoding: 'utf8', + writableObjectMode: true, + readableObjectMode: true, + ...opts + }; + super(opts); + this._buffer = []; + this.encoding = opts.encoding; + this.delimiter = opts.delimiter; + + this.isEscape = false; + this.currentQuote = ''; + this.isDelimiter = false; + } + + _write (chunk, encoding, next) { + const str = chunk.toString(this.encoding); + + for (let i = 0; i < str.length; i++) { + const currentChar = str[i]; + this.checkEscape(); + this._buffer.push(currentChar); + // this.checkNewDelimiter(currentChar); + this.checkQuote(currentChar); + const query = this.getQuery(); + + if (query) + this.push(query); + } + + next(); + } + + checkEscape () { + if (this._buffer.length > 0) { + this.isEscape = this._buffer[this._buffer.length - 1] === '\\' + ? !this.isEscape + : false; + } + } + + checkNewDelimiter (char) { + if (this.parsedStr.toLowerCase() === 'delimiter' && this.currentQuote === '') { + this.isDelimiter = true; + this._buffer = []; + } + else { + const isNewLine = ['\n', '\r'].includes(char); + if (isNewLine && this.isDelimiter) { + this.isDelimiter = false; + this.delimiter = this.parsedStr; + this._buffer = []; + } + } + } + + checkQuote (char) { + const isQuote = !this.isEscape && ['"', '\''].includes(char); + if (isQuote && this.currentQuote === char) + this.currentQuote = ''; + + else if (isQuote && this.currentQuote === '') + this.currentQuote = char; + } + + getQuery () { + if (this.isDelimiter) + return false; + + let query = false; + let demiliterFound = false; + if (this.currentQuote === '' && this._buffer.length >= this.delimiter.length) + demiliterFound = this.parsedStr.slice(-this.delimiter.length) === this.delimiter; + + if (demiliterFound) { + this._buffer.splice(-this.delimiter.length, this.delimiter.length); + query = this.parsedStr; + this._buffer = []; + } + + return query; + } + + get parsedStr () { + return this._buffer.join('').trim(); + } + + _read (size) { + + } +} diff --git a/src/main/ipc-handlers/schema.js b/src/main/ipc-handlers/schema.js index c59d9e0f..356fd5b2 100644 --- a/src/main/ipc-handlers/schema.js +++ b/src/main/ipc-handlers/schema.js @@ -2,10 +2,13 @@ import { ipcMain, dialog, Notification } from 'electron'; import path from 'path'; import fs from 'fs'; +// @TODO: need some factories import MysqlExporter from '../libs/exporters/sql/MysqlExporter'; +import MysqlImporter from '../libs/importers/sql/MysqlImporter'; export default connections => { let exporter = null; + let importer = null; ipcMain.handle('create-schema', async (event, params) => { try { @@ -263,4 +266,78 @@ export default connections => { return { status: 'success', response: { willAbort } }; }); + + ipcMain.handle('import-sql', async (event, options) => { + if (importer !== null) return; + + switch (options.type) { + case 'mysql': + case 'maria': + importer = new MysqlImporter(connections[options.uid], options); + break; + default: + return { + status: 'error', + response: `${type} importer not aviable` + }; + } + + return new Promise((resolve, reject) => { + importer.once('error', err => { + reject(err); + }); + + importer.once('end', () => { + resolve({ cancelled: importer.isCancelled }); + }); + + importer.on('progress', state => { + event.sender.send('import-progress', state); + }); + + importer.run(); + }) + .then(response => { + if (!response.cancelled) { + new Notification({ + title: 'Import finished', + body: `Finished importing ${path.basename(options.file)}` + }).show(); + } + return { status: 'success', response }; + }) + .catch(err => { + new Notification({ + title: 'Import error', + body: err.toString() + }).show(); + + return { status: 'error', response: err.toString() }; + }) + .finally(() => { + importer.removeAllListeners(); + importer = null; + }); + }); + + ipcMain.handle('abort-import-sql', async event => { + let willAbort = false; + + if (importer) { + const result = await dialog.showMessageBox({ + type: 'warning', + message: 'Are you sure you want to abort the import', + buttons: ['Cancel', 'Abort'], + defaultId: 0, + cancelId: 0 + }); + + if (result.response === 1) { + willAbort = true; + importer.cancel(); + } + } + + return { status: 'success', response: { willAbort } }; + }); }; diff --git a/src/main/libs/importers/BaseImporter.js b/src/main/libs/importers/BaseImporter.js new file mode 100644 index 00000000..55cacd16 --- /dev/null +++ b/src/main/libs/importers/BaseImporter.js @@ -0,0 +1,52 @@ +import fs from 'fs'; +import EventEmitter from 'events'; + +export class BaseImporter extends EventEmitter { + constructor (options) { + super(); + this._options = options; + this._isCancelled = false; + this._fileHandler = fs.createReadStream(this._options.file, { + flags: 'r' + }); + this._state = {}; + + this._fileHandler.once('error', err => { + this._isCancelled = true; + this.emit('error', err); + }); + } + + async run () { + try { + this.emit('start', this); + await this.import(); + } + catch (err) { + this.emit('error', err); + throw err; + } + finally { + this._fileHandler.close(); + this.emit('end'); + } + } + + get isCancelled () { + return this._isCancelled; + } + + cancel () { + this._isCancelled = true; + this.emit('cancel'); + this.emitUpdate({ op: 'cancelling' }); + } + + emitUpdate (state) { + this.emit('progress', { ...this._state, ...state }); + } + + import () { + throw new Error('Exporter must implement the "import" method'); + } +} diff --git a/src/main/libs/importers/sql/MysqlImporter.js b/src/main/libs/importers/sql/MysqlImporter.js new file mode 100644 index 00000000..9e946074 --- /dev/null +++ b/src/main/libs/importers/sql/MysqlImporter.js @@ -0,0 +1,60 @@ +import fs from 'fs/promises'; +import SqlParser from '../../../../common/libs/sqlParser'; +import { BaseImporter } from '../BaseImporter'; + +export default class MysqlImporter extends BaseImporter { + constructor (client, options) { + super(options); + } + + async import () { + const { size: totalFileSize } = await fs.stat(this._options.file); + const parser = new SqlParser(); + let readPosition = 0; + let queryCount = 0; + + this.emitUpdate({ + fileSize: totalFileSize, + readPosition: 0, + percentage: 0 + }); + + // 1. detect file encoding + // 2. set fh encoding + // 3. detect sql mode + // 4. restore sql mode in case of exception + + return new Promise((resolve, reject) => { + this._fileHandler.pipe(parser); + + parser.on('error', (err) => { + console.log(err); + reject(err); + }); + + parser.on('finish', () => { + console.log('TOTAL QUERIES', queryCount); + console.log('import end'); + resolve(); + }); + + parser.on('data', (q) => { + console.log('query: ', q); + queryCount++; + }); + + this._fileHandler.on('data', (chunk) => { + readPosition += chunk.length; + this.emitUpdate({ + readPosition, + percentage: readPosition / totalFileSize * 100 + }); + }); + + this._fileHandler.on('error', (e) => { + console.log(e); + reject(err); + }); + }); + } +} diff --git a/src/renderer/components/ModalImportSchema.vue b/src/renderer/components/ModalImportSchema.vue new file mode 100644 index 00000000..ca249a28 --- /dev/null +++ b/src/renderer/components/ModalImportSchema.vue @@ -0,0 +1,142 @@ + + + + + diff --git a/src/renderer/components/WorkspaceExploreBarSchemaContext.vue b/src/renderer/components/WorkspaceExploreBarSchemaContext.vue index 19e35add..879d9fc3 100644 --- a/src/renderer/components/WorkspaceExploreBarSchemaContext.vue +++ b/src/renderer/components/WorkspaceExploreBarSchemaContext.vue @@ -65,6 +65,13 @@ > {{ $t('word.export') }} +
+ {{ $t('word.import') }} +
+ @@ -112,7 +125,9 @@ import BaseContextMenu from '@/components/BaseContextMenu'; import ConfirmModal from '@/components/BaseConfirmModal'; import ModalEditSchema from '@/components/ModalEditSchema'; import ModalExportSchema from '@/components/ModalExportSchema'; +import ModalImportSchema from '@/components/ModalImportSchema'; import Schema from '@/ipc-api/Schema'; +import Application from '@/ipc-api/Application'; export default { name: 'WorkspaceExploreBarSchemaContext', @@ -120,7 +135,8 @@ export default { BaseContextMenu, ConfirmModal, ModalEditSchema, - ModalExportSchema + ModalExportSchema, + ModalImportSchema }, props: { contextEvent: MouseEvent, @@ -130,7 +146,8 @@ export default { return { isDeleteModal: false, isEditModal: false, - isExportSchemaModal: false + isExportSchemaModal: false, + isImportSchemaModal: false }; }, computed: { @@ -187,6 +204,22 @@ export default { hideExportSchemaModal () { this.isExportSchemaModal = false; }, + showImportSchemaModal () { + this.isImportSchemaModal = true; + }, + hideImportSchemaModal () { + this.isImportSchemaModal = false; + }, + async initImport () { + const result = await Application.showOpenDialog({ properties: ['openFile'], filters: [{ name: 'SQL', extensions: ['sql'] }] }); + if (result && !result.canceled) { + const file = result.filePaths[0]; + this.showImportSchemaModal(); + this.$nextTick(() => { + this.$refs.importModalRef.startImport(file); + }); + } + }, closeContext () { this.$emit('close-context'); }, diff --git a/src/renderer/i18n/en-US.js b/src/renderer/i18n/en-US.js index 0eefe78f..9fa4c1f2 100644 --- a/src/renderer/i18n/en-US.js +++ b/src/renderer/i18n/en-US.js @@ -80,6 +80,7 @@ module.exports = { deterministic: 'Deterministic', context: 'Context', export: 'Export', + import: 'Import', returns: 'Returns', timing: 'Timing', state: 'State', @@ -259,6 +260,7 @@ module.exports = { killProcess: 'Kill process', closeTab: 'Close tab', exportSchema: 'Export schema', + importSchema: 'Import schema', directoryPath: 'Directory path', newInserStmtEvery: 'New INSERT statement every', processingTableExport: 'Processing {table}', diff --git a/src/renderer/i18n/it-IT.js b/src/renderer/i18n/it-IT.js index d4abd9ae..6ef8c1b2 100644 --- a/src/renderer/i18n/it-IT.js +++ b/src/renderer/i18n/it-IT.js @@ -80,6 +80,7 @@ module.exports = { deterministic: 'Deterministico', context: 'Contesto', export: 'Esporta', + import: 'Importa', returns: 'Ritorna', timing: 'Temporizzazione', state: 'Stato', @@ -246,6 +247,7 @@ module.exports = { noSchema: 'Nessuno schema', restorePreviourSession: 'Ripristina sessione precedente', exportSchema: 'Esporta schema', + importSchema: 'Importa schema', directoryPath: 'Percorso directory', newInserStmtEvery: 'Nuova istruzione INSERT ogni', processingTableExport: 'Processo {table}', diff --git a/src/renderer/ipc-api/Schema.js b/src/renderer/ipc-api/Schema.js index ebe9c115..c5d49aee 100644 --- a/src/renderer/ipc-api/Schema.js +++ b/src/renderer/ipc-api/Schema.js @@ -61,4 +61,12 @@ export default class { static abortExport () { return ipcRenderer.invoke('abort-export'); } + + static import (params) { + return ipcRenderer.invoke('import-sql', params); + } + + static abortImport () { + return ipcRenderer.invoke('abort-import-sql'); + } }