feat: initial mysql import support

This commit is contained in:
Giulio Ganci 2021-12-28 15:30:07 +01:00
parent d25c62b4da
commit 4e9f8d16ee
11 changed files with 477 additions and 2 deletions

View File

@ -31,6 +31,7 @@ module.exports = {
schedulerAdd: true,
schemaEdit: true,
schemaExport: true,
schemaImport: true,
tableSettings: true,
viewSettings: true,
triggerSettings: true,

View File

@ -28,6 +28,7 @@ module.exports = {
functionAdd: true,
databaseEdit: false,
schemaExport: true,
schemaImport: false,
tableSettings: true,
viewSettings: true,
triggerSettings: true,

View File

@ -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) {
}
}

View File

@ -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 } };
});
};

View File

@ -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');
}
}

View File

@ -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);
});
});
}
}

View File

@ -0,0 +1,142 @@
<template>
<div class="modal active">
<a class="modal-overlay" @click.stop="closeModal" />
<div class="modal-container p-0">
<div class="modal-header pl-2">
<div class="modal-title h6">
<div class="d-flex">
<i class="mdi mdi-24px mdi-database-arrow-up mr-1" />
<span class="cut-text">{{ $t('message.importSchema') }}</span>
</div>
</div>
<a class="btn btn-clear c-hand" @click.stop="closeModal" />
</div>
<div class="modal-body pb-0">
{{ sqlFile }}
</div>
<div class="modal-footer columns">
<div class="column col modal-progress-wrapper text-left">
<div class="import-progress">
<span class="progress-status">
{{ progressPercentage }}% - {{ progressStatus }}
</span>
<progress
class="progress d-block"
:value="progressPercentage"
max="100"
/>
</div>
</div>
<div class="column col-auto px-0">
<button class="btn btn-link" @click.stop="closeModal">
{{ $t('word.cancel') }}
</button>
</div>
</div>
</div>
</div>
</template>
<script>
import { ipcRenderer } from 'electron';
import { mapActions, mapGetters } from 'vuex';
import Schema from '@/ipc-api/Schema';
export default {
name: 'ModalImportSchema',
props: {
selectedSchema: String
},
data () {
return {
sqlFile: '',
isImporting: false,
progressPercentage: 0,
progressStatus: 'Reading'
};
},
computed: {
...mapGetters({
selectedWorkspace: 'workspaces/getSelected',
getWorkspace: 'workspaces/getWorkspace'
}),
currentWorkspace () {
return this.getWorkspace(this.selectedWorkspace);
}
},
async created () {
window.addEventListener('keydown', this.onKey);
ipcRenderer.on('import-progress', this.updateProgress);
},
beforeDestroy () {
window.removeEventListener('keydown', this.onKey);
ipcRenderer.off('import-progress', this.updateProgress);
},
methods: {
...mapActions({
refreshSchema: 'workspaces/refreshSchema'
}),
async startImport (sqlFile) {
this.isImporting = true;
this.sqlFile = sqlFile;
const { uid, client } = this.currentWorkspace;
const params = {
uid,
type: client,
file: sqlFile
};
const result = await Schema.import(params);
console.log(result);
this.isImporting = false;
},
updateProgress (event, state) {
this.progressPercentage = Number(state.percentage).toFixed(1);
},
async closeModal () {
let willClose = true;
if (this.isImporting) {
willClose = false;
const { response } = await Schema.abortImport();
willClose = response.willAbort;
}
if (willClose)
this.$emit('close');
},
onKey (e) {
e.stopPropagation();
if (e.key === 'Escape')
this.closeModal();
}
}
};
</script>
<style lang="scss" scoped>
.modal {
.modal-container {
max-width: 800px;
}
.modal-body {
max-height: 60vh;
display: flex;
flex-direction: column;
}
.modal-footer {
display: flex;
}
}
.progress-status {
font-style: italic;
font-size: 80%;
}
</style>

View File

@ -65,6 +65,13 @@
>
<span class="d-flex"><i class="mdi mdi-18px mdi-database-arrow-down text-light pr-1" /> {{ $t('word.export') }}</span>
</div>
<div
v-if="workspace.customizations.schemaImport"
class="context-element"
@click="initImport"
>
<span class="d-flex"><i class="mdi mdi-18px mdi-database-arrow-up text-light pr-1" /> {{ $t('word.import') }}</span>
</div>
<div
v-if="workspace.customizations.schemaEdit"
class="context-element"
@ -103,6 +110,12 @@
:selected-schema="selectedSchema"
@close="hideExportSchemaModal"
/>
<ModalImportSchema
v-if="isImportSchemaModal"
ref="importModalRef"
:selected-schema="selectedSchema"
@close="hideImportSchemaModal"
/>
</BaseContextMenu>
</template>
@ -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');
},

View File

@ -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}',

View File

@ -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}',

View File

@ -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');
}
}