diff --git a/packages/api/package.json b/packages/api/package.json index 0cb34267..721a664d 100644 --- a/packages/api/package.json +++ b/packages/api/package.json @@ -44,7 +44,6 @@ "line-reader": "^0.4.0", "lodash": "^4.17.21", "ncp": "^2.0.0", - "nedb-promises": "^4.0.1", "node-cron": "^2.0.3", "node-ssh-forward": "^0.7.2", "portfinder": "^1.0.28", diff --git a/packages/api/src/controllers/apps.js b/packages/api/src/controllers/apps.js new file mode 100644 index 00000000..c6504874 --- /dev/null +++ b/packages/api/src/controllers/apps.js @@ -0,0 +1,264 @@ +const fs = require('fs-extra'); +const _ = require('lodash'); +const path = require('path'); +const { appdir } = require('../utility/directories'); +const socket = require('../utility/socket'); +const connections = require('./connections'); + +module.exports = { + folders_meta: true, + async folders() { + const folders = await fs.readdir(appdir()); + return [ + ...folders.map(name => ({ + name, + })), + ]; + }, + + createFolder_meta: true, + async createFolder({ folder }) { + const name = await this.getNewAppFolder({ name: folder }); + await fs.mkdir(path.join(appdir(), name)); + socket.emitChanged('app-folders-changed'); + return name; + }, + + files_meta: true, + async files({ folder }) { + const dir = path.join(appdir(), folder); + if (!(await fs.exists(dir))) return []; + const files = await fs.readdir(dir); + + function fileType(ext, type) { + return files + .filter(name => name.endsWith(ext)) + .map(name => ({ + name: name.slice(0, -ext.length), + label: path.parse(name.slice(0, -ext.length)).base, + type, + })); + } + + return [ + ...fileType('.command.sql', 'command.sql'), + ...fileType('.query.sql', 'query.sql'), + ...fileType('.config.json', 'config.json'), + ]; + }, + + async emitChangedDbApp(folder) { + const used = await this.getUsedAppFolders(); + if (used.includes(folder)) { + socket.emitChanged('used-apps-changed'); + } + }, + + refreshFiles_meta: true, + async refreshFiles({ folder }) { + socket.emitChanged(`app-files-changed-${folder}`); + }, + + refreshFolders_meta: true, + async refreshFolders() { + socket.emitChanged(`app-folders-changed`); + }, + + deleteFile_meta: true, + async deleteFile({ folder, file, fileType }) { + await fs.unlink(path.join(appdir(), folder, `${file}.${fileType}`)); + socket.emitChanged(`app-files-changed-${folder}`); + this.emitChangedDbApp(folder); + }, + + renameFile_meta: true, + async renameFile({ folder, file, newFile, fileType }) { + await fs.rename( + path.join(path.join(appdir(), folder), `${file}.${fileType}`), + path.join(path.join(appdir(), folder), `${newFile}.${fileType}`) + ); + socket.emitChanged(`app-files-changed-${folder}`); + this.emitChangedDbApp(folder); + }, + + renameFolder_meta: true, + async renameFolder({ folder, newFolder }) { + const uniqueName = await this.getNewAppFolder({ name: newFolder }); + await fs.rename(path.join(appdir(), folder), path.join(appdir(), uniqueName)); + socket.emitChanged(`app-folders-changed`); + }, + + deleteFolder_meta: true, + async deleteFolder({ folder }) { + if (!folder) throw new Error('Missing folder parameter'); + await fs.rmdir(path.join(appdir(), folder), { recursive: true }); + socket.emitChanged(`app-folders-changed`); + }, + + async getNewAppFolder({ name }) { + if (!(await fs.exists(path.join(appdir(), name)))) return name; + let index = 2; + while (await fs.exists(path.join(appdir(), `${name}${index}`))) { + index += 1; + } + return `${name}${index}`; + }, + + getUsedAppFolders_meta: true, + async getUsedAppFolders() { + const list = await connections.list(); + const apps = []; + + for (const connection of list) { + for (const db of connection.databases || []) { + for (const key of _.keys(db || {})) { + if (key.startsWith('useApp:') && db[key]) { + apps.push(key.substring('useApp:'.length)); + } + } + } + } + + return _.intersection(_.uniq(apps), await fs.readdir(appdir())); + }, + + getUsedApps_meta: true, + async getUsedApps() { + const apps = await this.getUsedAppFolders(); + const res = []; + + for (const folder of apps) { + res.push(await this.loadApp({ folder })); + } + return res; + }, + + // getAppsForDb_meta: true, + // async getAppsForDb({ conid, database }) { + // const connection = await connections.get({ conid }); + // if (!connection) return []; + // const db = (connection.databases || []).find(x => x.name == database); + // const apps = []; + // const res = []; + // if (db) { + // for (const key of _.keys(db || {})) { + // if (key.startsWith('useApp:') && db[key]) { + // apps.push(key.substring('useApp:'.length)); + // } + // } + // } + // for (const folder of apps) { + // res.push(await this.loadApp({ folder })); + // } + // return res; + // }, + + loadApp_meta: true, + async loadApp({ folder }) { + const res = { + queries: [], + commands: [], + name: folder, + }; + const dir = path.join(appdir(), folder); + if (await fs.exists(dir)) { + const files = await fs.readdir(dir); + + async function processType(ext, field) { + for (const file of files) { + if (file.endsWith(ext)) { + res[field].push({ + name: file.slice(0, -ext.length), + sql: await fs.readFile(path.join(dir, file), { encoding: 'utf-8' }), + }); + } + } + } + + await processType('.command.sql', 'commands'); + await processType('.query.sql', 'queries'); + } + + try { + res.virtualReferences = JSON.parse( + await fs.readFile(path.join(dir, 'virtual-references.config.json'), { encoding: 'utf-8' }) + ); + } catch (err) { + res.virtualReferences = []; + } + try { + res.dictionaryDescriptions = JSON.parse( + await fs.readFile(path.join(dir, 'dictionary-descriptions.config.json'), { encoding: 'utf-8' }) + ); + } catch (err) { + res.dictionaryDescriptions = []; + } + + return res; + }, + + async saveConfigFile(appFolder, filename, filterFunc, newItem) { + const file = path.join(appdir(), appFolder, filename); + + let json; + try { + json = JSON.parse(await fs.readFile(file, { encoding: 'utf-8' })); + } catch (err) { + json = []; + } + + if (filterFunc) { + json = json.filter(filterFunc); + } + + json = [...json, newItem]; + + await fs.writeFile(file, JSON.stringify(json, undefined, 2)); + + socket.emitChanged(`app-files-changed-${appFolder}`); + socket.emitChanged('used-apps-changed'); + }, + + saveVirtualReference_meta: true, + async saveVirtualReference({ appFolder, schemaName, pureName, refSchemaName, refTableName, columns }) { + await this.saveConfigFile( + appFolder, + 'virtual-references.config.json', + columns.length == 1 + ? x => + !( + x.schemaName == schemaName && + x.pureName == pureName && + x.columns.length == 1 && + x.columns[0].columnName == columns[0].columnName + ) + : null, + { + schemaName, + pureName, + refSchemaName, + refTableName, + columns, + } + ); + return true; + }, + + saveDictionaryDescription_meta: true, + async saveDictionaryDescription({ appFolder, pureName, schemaName, expression, columns, delimiter }) { + await this.saveConfigFile( + appFolder, + 'dictionary-descriptions.config.json', + x => !(x.schemaName == schemaName && x.pureName == pureName), + { + schemaName, + pureName, + expression, + columns, + delimiter, + } + ); + + return true; + }, +}; diff --git a/packages/api/src/controllers/connections.js b/packages/api/src/controllers/connections.js index 5ec9873d..ee71c169 100644 --- a/packages/api/src/controllers/connections.js +++ b/packages/api/src/controllers/connections.js @@ -1,7 +1,6 @@ const path = require('path'); const { fork } = require('child_process'); const _ = require('lodash'); -const nedb = require('nedb-promises'); const fs = require('fs-extra'); const { datadir, filesdir } = require('../utility/directories'); @@ -9,6 +8,7 @@ const socket = require('../utility/socket'); const { encryptConnection } = require('../utility/crypting'); const { handleProcessCommunication } = require('../utility/processComm'); const { pickSafeConnectionInfo } = require('../utility/crypting'); +const JsonLinesDatabase = require('../utility/JsonLinesDatabase'); const processArgs = require('../utility/processArgs'); @@ -136,7 +136,7 @@ module.exports = { const dir = datadir(); if (!portalConnections) { // @ts-ignore - this.datastore = nedb.create(path.join(dir, 'connections.jsonl')); + this.datastore = new JsonLinesDatabase(path.join(dir, 'connections.jsonl')); } }, @@ -173,18 +173,22 @@ module.exports = { let res; const encrypted = encryptConnection(connection); if (connection._id) { - res = await this.datastore.update(_.pick(connection, '_id'), encrypted); + res = await this.datastore.update(encrypted); } else { res = await this.datastore.insert(encrypted); } socket.emitChanged('connection-list-changed'); + socket.emitChanged('used-apps-changed'); + // for (const db of connection.databases || []) { + // socket.emitChanged(`db-apps-changed-${connection._id}-${db.name}`); + // } return res; }, update_meta: true, async update({ _id, values }) { if (portalConnections) return; - const res = await this.datastore.update({ _id }, { $set: values }); + const res = await this.datastore.patch(_id, values); socket.emitChanged('connection-list-changed'); return res; }, @@ -192,22 +196,24 @@ module.exports = { updateDatabase_meta: true, async updateDatabase({ conid, database, values }) { if (portalConnections) return; - const conn = await this.datastore.find({ _id: conid }); - let databases = conn[0].databases || []; + const conn = await this.datastore.get(conid); + let databases = (conn && conn.databases) || []; if (databases.find(x => x.name == database)) { databases = databases.map(x => (x.name == database ? { ...x, ...values } : x)); } else { databases = [...databases, { name: database, ...values }]; } - const res = await this.datastore.update({ _id: conid }, { $set: { databases } }); + const res = await this.datastore.patch(conid, { databases }); socket.emitChanged('connection-list-changed'); + socket.emitChanged('used-apps-changed'); + // socket.emitChanged(`db-apps-changed-${conid}-${database}`); return res; }, delete_meta: true, async delete(connection) { if (portalConnections) return; - const res = await this.datastore.remove(_.pick(connection, '_id')); + const res = await this.datastore.remove(connection._id); socket.emitChanged('connection-list-changed'); return res; }, @@ -215,8 +221,8 @@ module.exports = { get_meta: true, async get({ conid }) { if (portalConnections) return portalConnections.find(x => x._id == conid) || null; - const res = await this.datastore.find({ _id: conid }); - return res[0] || null; + const res = await this.datastore.get(conid); + return res || null; }, newSqliteDatabase_meta: true, diff --git a/packages/api/src/controllers/files.js b/packages/api/src/controllers/files.js index 78a29778..14ea526a 100644 --- a/packages/api/src/controllers/files.js +++ b/packages/api/src/controllers/files.js @@ -1,12 +1,13 @@ const uuidv1 = require('uuid/v1'); const fs = require('fs-extra'); const path = require('path'); -const { filesdir, archivedir, resolveArchiveFolder, uploadsdir } = require('../utility/directories'); +const { filesdir, archivedir, resolveArchiveFolder, uploadsdir, appdir } = require('../utility/directories'); const getChartExport = require('../utility/getChartExport'); const hasPermission = require('../utility/hasPermission'); const socket = require('../utility/socket'); const scheduler = require('./scheduler'); const getDiagramExport = require('../utility/getDiagramExport'); +const apps = require('./apps'); function serialize(format, data) { if (format == 'text') return data; @@ -74,6 +75,11 @@ module.exports = { encoding: 'utf-8', }); return deserialize(format, text); + } else if (folder.startsWith('app:')) { + const text = await fs.readFile(path.join(appdir(), folder.substring('app:'.length), file), { + encoding: 'utf-8', + }); + return deserialize(format, text); } else { if (!hasPermission(`files/${folder}/read`)) return null; const text = await fs.readFile(path.join(filesdir(), folder, file), { encoding: 'utf-8' }); @@ -88,6 +94,12 @@ module.exports = { await fs.writeFile(path.join(dir, file), serialize(format, data)); socket.emitChanged(`archive-files-changed-${folder.substring('archive:'.length)}`); return true; + } else if (folder.startsWith('app:')) { + const app = folder.substring('app:'.length); + await fs.writeFile(path.join(appdir(), app, file), serialize(format, data)); + socket.emitChanged(`app-files-changed-${app}`); + apps.emitChangedDbApp(folder); + return true; } else { if (!hasPermission(`files/${folder}/write`)) return false; const dir = path.join(filesdir(), folder); diff --git a/packages/api/src/main.js b/packages/api/src/main.js index 5cb5ec46..2ad5588d 100644 --- a/packages/api/src/main.js +++ b/packages/api/src/main.js @@ -19,6 +19,7 @@ const runners = require('./controllers/runners'); const jsldata = require('./controllers/jsldata'); const config = require('./controllers/config'); const archive = require('./controllers/archive'); +const apps = require('./controllers/apps'); const uploads = require('./controllers/uploads'); const plugins = require('./controllers/plugins'); const files = require('./controllers/files'); @@ -157,6 +158,7 @@ function useAllControllers(app, electron) { useController(app, electron, '/files', files); useController(app, electron, '/scheduler', scheduler); useController(app, electron, '/query-history', queryHistory); + useController(app, electron, '/apps', apps); } function initializeElectronSender(electronSender) { diff --git a/packages/api/src/utility/JsonLinesDatabase.js b/packages/api/src/utility/JsonLinesDatabase.js new file mode 100644 index 00000000..9f4243bb --- /dev/null +++ b/packages/api/src/utility/JsonLinesDatabase.js @@ -0,0 +1,142 @@ +const AsyncLock = require('async-lock'); +const fs = require('fs-extra'); +const uuidv1 = require('uuid/v1'); + +const lock = new AsyncLock(); + +// const lineReader = require('line-reader'); +// const { fetchNextLineFromReader } = require('./JsonLinesDatastore'); + +class JsonLinesDatabase { + constructor(filename) { + this.filename = filename; + this.data = []; + this.loadedOk = false; + this.loadPerformed = false; + } + + async _save() { + if (!this.loadedOk) { + // don't override data + return; + } + await fs.writeFile(this.filename, this.data.map(x => JSON.stringify(x)).join('\n')); + } + + async _ensureLoaded() { + if (!this.loadPerformed) { + await lock.acquire('reader', async () => { + if (!this.loadPerformed) { + if (!(await fs.exists(this.filename))) { + this.loadedOk = true; + this.loadPerformed = true; + return; + } + try { + const text = await fs.readFile(this.filename, { encoding: 'utf-8' }); + this.data = text + .split('\n') + .filter(x => x.trim()) + .map(x => JSON.parse(x)); + this.loadedOk = true; + } catch (err) { + console.error(`Error loading file ${this.filename}`, err); + } + this.loadPerformed = true; + } + }); + } + } + + async insert(obj) { + await this._ensureLoaded(); + if (obj._id && (await this.get(obj._id))) { + throw new Error(`Cannot insert duplicate ID ${obj._id} into ${this.filename}`); + } + const elem = obj._id + ? obj + : { + ...obj, + _id: uuidv1(), + }; + this.data.push(elem); + await this._save(); + return elem; + } + + async get(id) { + await this._ensureLoaded(); + return this.data.find(x => x._id == id); + } + + async find(cond) { + await this._ensureLoaded(); + if (cond) { + return this.data.filter(x => { + for (const key of Object.keys(cond)) { + if (x[key] != cond[key]) return false; + } + return true; + }); + } else { + return this.data; + } + } + + async update(obj) { + await this._ensureLoaded(); + this.data = this.data.map(x => (x._id == obj._id ? obj : x)); + await this._save(); + return obj; + } + + async patch(id, values) { + await this._ensureLoaded(); + this.data = this.data.map(x => (x._id == id ? { ...x, ...values } : x)); + await this._save(); + return this.data.find(x => x._id == id); + } + + async remove(id) { + await this._ensureLoaded(); + const removed = this.data.find(x => x._id == id); + this.data = this.data.filter(x => x._id != id); + await this._save(); + return removed; + } + + // async _openReader() { + // return new Promise((resolve, reject) => + // lineReader.open(this.filename, (err, reader) => { + // if (err) reject(err); + // resolve(reader); + // }) + // ); + // } + + // async _read() { + // this.data = []; + // if (!(await fs.exists(this.filename))) return; + // try { + // const reader = await this._openReader(); + // for (;;) { + // const line = await fetchNextLineFromReader(reader); + // if (!line) break; + // this.data.push(JSON.parse(line)); + // } + // } catch (err) { + // console.error(`Error loading file ${this.filename}`, err); + // } + // } + + // async _write() { + // const fw = fs.createWriteStream(this.filename); + // for (const obj of this.data) { + // await fw.write(JSON.stringify(obj)); + // await fw.write('\n'); + // } + // await fw.end(); + // } +} + +module.exports = JsonLinesDatabase; diff --git a/packages/api/src/utility/JsonLinesDatastore.js b/packages/api/src/utility/JsonLinesDatastore.js index b9965ae8..37a7220f 100644 --- a/packages/api/src/utility/JsonLinesDatastore.js +++ b/packages/api/src/utility/JsonLinesDatastore.js @@ -4,7 +4,7 @@ const lock = new AsyncLock(); const stableStringify = require('json-stable-stringify'); const { evaluateCondition } = require('dbgate-sqltree'); -async function fetchNextLine(reader) { +function fetchNextLineFromReader(reader) { return new Promise((resolve, reject) => { if (!reader.hasNextLine()) { resolve(null); @@ -62,7 +62,7 @@ class JsonLinesDatastore { async _readLine(parse) { for (;;) { - const line = await fetchNextLine(this.reader); + const line = await fetchNextLineFromReader(this.reader); if (!line) { // EOF return null; diff --git a/packages/api/src/utility/directories.js b/packages/api/src/utility/directories.js index 9af898e1..35b5c887 100644 --- a/packages/api/src/utility/directories.js +++ b/packages/api/src/utility/directories.js @@ -38,6 +38,7 @@ const rundir = dirFunc('run', true); const uploadsdir = dirFunc('uploads', true); const pluginsdir = dirFunc('plugins'); const archivedir = dirFunc('archive'); +const appdir = dirFunc('apps'); const filesdir = dirFunc('files'); function packagedPluginsDir() { @@ -103,6 +104,7 @@ module.exports = { rundir, uploadsdir, archivedir, + appdir, ensureDirectory, pluginsdir, filesdir, diff --git a/packages/datalib/src/GridDisplay.ts b/packages/datalib/src/GridDisplay.ts index e347af3e..29604143 100644 --- a/packages/datalib/src/GridDisplay.ts +++ b/packages/datalib/src/GridDisplay.ts @@ -28,6 +28,7 @@ export interface DisplayColumn { autoIncrement?: boolean; isPrimaryKey?: boolean; foreignKey?: ForeignKeyInfo; + isForeignKeyUnique?: boolean; isExpandable?: boolean; isChecked?: boolean; hintColumnNames?: string[]; diff --git a/packages/datalib/src/TableGridDisplay.ts b/packages/datalib/src/TableGridDisplay.ts index cd8227a2..e9480835 100644 --- a/packages/datalib/src/TableGridDisplay.ts +++ b/packages/datalib/src/TableGridDisplay.ts @@ -1,5 +1,5 @@ import _ from 'lodash'; -import { filterName } from 'dbgate-tools'; +import { filterName, isTableColumnUnique } from 'dbgate-tools'; import { GridDisplay, ChangeCacheFunc, DisplayColumn, DisplayedColumnInfo, ChangeConfigFunc } from './GridDisplay'; import { TableInfo, @@ -79,10 +79,11 @@ export class TableGridDisplay extends GridDisplay { ...col, isChecked: this.isColumnChecked(col), hintColumnNames: - this.getFkDictionaryDescription(col.foreignKey)?.columns?.map( + this.getFkDictionaryDescription(col.isForeignKeyUnique ? col.foreignKey : null)?.columns?.map( columnName => `hint_${col.uniqueName}_${columnName}` ) || null, - hintColumnDelimiter: this.getFkDictionaryDescription(col.foreignKey)?.delimiter, + hintColumnDelimiter: this.getFkDictionaryDescription(col.isForeignKeyUnique ? col.foreignKey : null) + ?.delimiter, isExpandable: !!col.foreignKey, })) || [] ); @@ -203,7 +204,8 @@ export class TableGridDisplay extends GridDisplay { } getFkTarget(column: DisplayColumn) { - const { uniqueName, foreignKey } = column; + const { uniqueName, foreignKey, isForeignKeyUnique } = column; + if (!isForeignKeyUnique) return null; const pureName = foreignKey.refTableName; const schemaName = foreignKey.refSchemaName; return this.findTable({ schemaName, pureName }); @@ -230,7 +232,7 @@ export class TableGridDisplay extends GridDisplay { const uniquePath = [...parentPath, col.columnName]; const uniqueName = uniquePath.join('.'); // console.log('this.config.addedColumns', this.config.addedColumns, uniquePath); - return { + const res = { ...col, pureName: table.pureName, schemaName: table.schemaName, @@ -241,7 +243,19 @@ export class TableGridDisplay extends GridDisplay { foreignKey: table.foreignKeys && table.foreignKeys.find(fk => fk.columns.length == 1 && fk.columns[0].columnName == col.columnName), + isForeignKeyUnique: false, }; + + if (res.foreignKey) { + const refTableInfo = this.dbinfo.tables.find( + x => x.schemaName == res.foreignKey.refSchemaName && x.pureName == res.foreignKey.refTableName + ); + if (refTableInfo && isTableColumnUnique(refTableInfo, res.foreignKey.columns[0].refColumnName)) { + res.isForeignKeyUnique = true; + } + } + + return res; } addAddedColumnsToSelect( diff --git a/packages/tools/src/createBulkInsertStreamBase.ts b/packages/tools/src/createBulkInsertStreamBase.ts index eed503a4..068912c3 100644 --- a/packages/tools/src/createBulkInsertStreamBase.ts +++ b/packages/tools/src/createBulkInsertStreamBase.ts @@ -29,18 +29,18 @@ export function createBulkInsertStreamBase(driver, stream, pool, name, options): // console.log('ANALYSING', name, structure); if (structure && options.dropIfExists) { console.log(`Dropping table ${fullNameQuoted}`); - await driver.query(pool, `DROP TABLE ${fullNameQuoted}`); + await driver.script(pool, `DROP TABLE ${fullNameQuoted}`); } if (options.createIfNotExists && (!structure || options.dropIfExists)) { console.log(`Creating table ${fullNameQuoted}`); const dmp = driver.createDumper(); dmp.createTable(prepareTableForImport({ ...writable.structure, ...name })); console.log(dmp.s); - await driver.query(pool, dmp.s); + await driver.script(pool, dmp.s); structure = await driver.analyseSingleTable(pool, name); } if (options.truncate) { - await driver.query(pool, `TRUNCATE TABLE ${fullNameQuoted}`); + await driver.script(pool, `TRUNCATE TABLE ${fullNameQuoted}`); } writable.columnNames = _intersection( diff --git a/packages/tools/src/structureTools.ts b/packages/tools/src/structureTools.ts index 8c6d9248..f3d08351 100644 --- a/packages/tools/src/structureTools.ts +++ b/packages/tools/src/structureTools.ts @@ -1,4 +1,4 @@ -import { DatabaseInfo, TableInfo } from 'dbgate-types'; +import { DatabaseInfo, TableInfo, ApplicationDefinition } from 'dbgate-types'; import _flatten from 'lodash/flatten'; export function addTableDependencies(db: DatabaseInfo): DatabaseInfo { @@ -90,3 +90,31 @@ function fillDatabaseExtendedInfo(db: DatabaseInfo): DatabaseInfo { export function extendDatabaseInfo(db: DatabaseInfo): DatabaseInfo { return fillDatabaseExtendedInfo(addTableDependencies(db)); } + +export function extendDatabaseInfoFromApps(db: DatabaseInfo, apps: ApplicationDefinition[]): DatabaseInfo { + if (!db || !apps) return db; + const dbExt = { + ...db, + tables: db.tables.map(table => ({ + ...table, + foreignKeys: [ + ...(table.foreignKeys || []), + ..._flatten(apps.map(app => app.virtualReferences || [])) + .filter(fk => fk.pureName == table.pureName && fk.schemaName == table.schemaName) + .map(fk => ({ ...fk, constraintType: 'foreignKey', isVirtual: true })), + ], + })), + } as DatabaseInfo; + return addTableDependencies(dbExt); +} + +export function isTableColumnUnique(table: TableInfo, column: string) { + if (table.primaryKey && table.primaryKey.columns.length == 1 && table.primaryKey.columns[0].columnName == column) { + return true; + } + const uqs = [...(table.uniques || []), ...(table.indexes || []).filter(x => x.isUnique)]; + if (uqs.find(uq => uq.columns.length == 1 && uq.columns[0].columnName == column)) { + return true; + } + return false; +} diff --git a/packages/tools/src/tableTransforms.ts b/packages/tools/src/tableTransforms.ts index 994161b7..403426cf 100644 --- a/packages/tools/src/tableTransforms.ts +++ b/packages/tools/src/tableTransforms.ts @@ -4,6 +4,9 @@ import _cloneDeep from 'lodash/cloneDeep'; export function prepareTableForImport(table: TableInfo): TableInfo { const res = _cloneDeep(table); res.foreignKeys = []; + res.indexes = []; + res.uniques = []; + res.checks = []; if (res.primaryKey) res.primaryKey.constraintName = null; return res; } diff --git a/packages/types/appdefs.d.ts b/packages/types/appdefs.d.ts new file mode 100644 index 00000000..b9826beb --- /dev/null +++ b/packages/types/appdefs.d.ts @@ -0,0 +1,37 @@ +interface ApplicationCommand { + name: string; + sql: string; +} + +interface ApplicationQuery { + name: string; + sql: string; +} + +interface VirtualReferenceDefinition { + pureName: string; + schemaName?: string; + refSchemaName?: string; + refTableName: string; + columns: { + columnName: string; + refColumnName: string; + }[]; +} + +interface DictionaryDescriptionDefinition { + pureName: string; + schemaName?: string; + expression: string; + columns: string[]; + delimiter: string; +} + +export interface ApplicationDefinition { + name: string; + + queries: ApplicationQuery[]; + commands: ApplicationCommand[]; + virtualReferences: VirtualReferenceDefinition[]; + dictionaryDescriptions: DictionaryDescriptionDefinition[]; +} diff --git a/packages/types/extensions.d.ts b/packages/types/extensions.d.ts index fca442fe..a60dcff7 100644 --- a/packages/types/extensions.d.ts +++ b/packages/types/extensions.d.ts @@ -22,9 +22,10 @@ export interface FileFormatDefinition { } export interface ThemeDefinition { - className: string; + themeClassName: string; themeName: string; themeType: 'light' | 'dark'; + themeCss?: string; } export interface PluginDefinition { diff --git a/packages/types/index.d.ts b/packages/types/index.d.ts index aed04c88..e7f8ad0a 100644 --- a/packages/types/index.d.ts +++ b/packages/types/index.d.ts @@ -43,3 +43,4 @@ export * from './dumper'; export * from './dbtypes'; export * from './extensions'; export * from './alter-processor'; +export * from './appdefs'; diff --git a/packages/web/src/App.svelte b/packages/web/src/App.svelte index b77d962c..a0527321 100644 --- a/packages/web/src/App.svelte +++ b/packages/web/src/App.svelte @@ -15,6 +15,7 @@ import { subscribeConnectionPingers } from './utility/connectionsPinger'; import { subscribePermissionCompiler } from './utility/hasPermission'; import { apiCall } from './utility/api'; + import { getUsedApps } from './utility/metadataLoaders'; let loadedApi = false; @@ -30,7 +31,8 @@ const settings = await apiCall('config/get-settings'); const connections = await apiCall('connections/list'); const config = await apiCall('config/get'); - loadedApi = settings && connections && config; + const apps = await getUsedApps(); + loadedApi = settings && connections && config && apps; if (loadedApi) { subscribeApiDependendStores(); diff --git a/packages/web/src/Screen.svelte b/packages/web/src/Screen.svelte index 8d2e919d..a05fbbd8 100644 --- a/packages/web/src/Screen.svelte +++ b/packages/web/src/Screen.svelte @@ -26,6 +26,12 @@ $: currentThemeType = $currentThemeDefinition?.themeType == 'dark' ? 'theme-type-dark' : 'theme-type-light'; + + {#if $currentThemeDefinition?.themeCss} + {@html ``} + {/if} + +
+ async function openTextFile(fileName, fileType, folderName, tabComponent, icon) { + const connProps: any = {}; + let tooltip = undefined; + + const resp = await apiCall('files/load', { + folder: 'app:' + folderName, + file: fileName + '.' + fileType, + format: 'text', + }); + + openNewTab( + { + title: fileName, + icon, + tabComponent, + tooltip, + props: { + savedFile:fileName + '.' + fileType, + savedFolder: 'app:' + folderName, + savedFormat: 'text', + appFolder: folderName, + ...connProps, + }, + }, + { editor: resp } + ); + } + + export const extractKey = data => data.fileName; + export const createMatcher = ({ fileName }) => filter => filterName(filter, fileName); + const APP_ICONS = { + 'config.json': 'img json', + 'command.sql': 'img app-command', + 'query.sql': 'img app-query', + }; + + function getAppIcon(data) { + return APP_ICONS[data.fileType]; + } + + + + + diff --git a/packages/web/src/appobj/AppFolderAppObject.svelte b/packages/web/src/appobj/AppFolderAppObject.svelte new file mode 100644 index 00000000..a04f1976 --- /dev/null +++ b/packages/web/src/appobj/AppFolderAppObject.svelte @@ -0,0 +1,96 @@ + + + + + ($currentApplication = data.name)} + menu={createMenu} +/> diff --git a/packages/web/src/appobj/ArchiveFileAppObject.svelte b/packages/web/src/appobj/ArchiveFileAppObject.svelte index 83e24c4a..bc2b3cbf 100644 --- a/packages/web/src/appobj/ArchiveFileAppObject.svelte +++ b/packages/web/src/appobj/ArchiveFileAppObject.svelte @@ -81,7 +81,7 @@ markArchiveFileAsDataSheet, markArchiveFileAsReadonly, } from '../utility/archiveTools'; -import { apiCall } from '../utility/api'; + import { apiCall } from '../utility/api'; export let data; diff --git a/packages/web/src/appobj/ConnectionAppObject.svelte b/packages/web/src/appobj/ConnectionAppObject.svelte index afc8e0ab..d7577220 100644 --- a/packages/web/src/appobj/ConnectionAppObject.svelte +++ b/packages/web/src/appobj/ConnectionAppObject.svelte @@ -26,7 +26,7 @@ import { getDatabaseMenuItems } from './DatabaseAppObject.svelte'; import getElectron from '../utility/getElectron'; import getConnectionLabel from '../utility/getConnectionLabel'; - import { getDatabaseList } from '../utility/metadataLoaders'; + import { getDatabaseList, useUsedApps } from '../utility/metadataLoaders'; import { getLocalStorage } from '../utility/storageCache'; import { apiCall } from '../utility/api'; @@ -97,7 +97,7 @@ value: 'newdb', label: 'Database name', onConfirm: name => - apiCall('server-connections/create-database', { + apiCall('server-connections/create-database', { conid: data._id, name, }), @@ -153,7 +153,7 @@ ], data.singleDatabase && [ { divider: true }, - getDatabaseMenuItems(data, data.defaultDatabase, $extensions, $currentDatabase), + getDatabaseMenuItems(data, data.defaultDatabase, $extensions, $currentDatabase, $apps), ], ]; }; @@ -186,6 +186,8 @@ statusTitle = null; } } + + $: apps = useUsedApps(); export const extractKey = props => props.name; - export function getDatabaseMenuItems(connection, name, $extensions, $currentDatabase) { + export function getDatabaseMenuItems(connection, name, $extensions, $currentDatabase, $apps) { + const apps = filterAppsForDatabase(connection, name, $apps); const handleNewQuery = () => { const tooltip = `${getConnectionLabel(connection)}\n${name}`; openNewTab({ @@ -157,8 +158,20 @@ openJsonDocument(db, name); }; + async function handleConfirmSql(sql) { + const resp = await apiCall('database-connections/run-script', { conid: connection._id, database: name, sql }); + const { errorMessage } = resp || {}; + if (errorMessage) { + showModal(ErrorMessageModal, { title: 'Error when executing script', message: errorMessage }); + } else { + showSnackbarSuccess('Saved to database'); + } + } + const driver = findEngineDriver(connection, getExtensions()); + const commands = _.flatten((apps || []).map(x => x.commands || [])); + return [ { onClick: handleNewQuery, text: 'New query', isNewQuery: true }, !driver?.dialect?.nosql && { onClick: handleNewTable, text: 'New table' }, @@ -180,6 +193,20 @@ _.get($currentDatabase, 'connection._id') == _.get(connection, '_id') && _.get($currentDatabase, 'name') == name && { onClick: handleDisconnect, text: 'Disconnect' }, + + commands.length > 0 && [ + { divider: true }, + commands.map((cmd: any) => ({ + text: cmd.name, + onClick: () => { + showModal(ConfirmSqlModal, { + sql: cmd.sql, + onConfirm: () => handleConfirmSql(cmd.sql), + engine: driver.engine, + }); + }, + })), + ], ]; } @@ -207,18 +234,22 @@ import { showSnackbarSuccess } from '../utility/snackbar'; import { findEngineDriver } from 'dbgate-tools'; import InputTextModal from '../modals/InputTextModal.svelte'; - import { getDatabaseInfo } from '../utility/metadataLoaders'; + import { getDatabaseInfo, useUsedApps } from '../utility/metadataLoaders'; import { openJsonDocument } from '../tabs/JsonTab.svelte'; import { apiCall } from '../utility/api'; + import ErrorMessageModal from '../modals/ErrorMessageModal.svelte'; + import ConfirmSqlModal from '../modals/ConfirmSqlModal.svelte'; + import { filterAppsForDatabase } from '../utility/appTools'; export let data; export let passProps; function createMenu() { - return getDatabaseMenuItems(data.connection, data.name, $extensions, $currentDatabase); + return getDatabaseMenuItems(data.connection, data.name, $extensions, $currentDatabase, $apps); } $: isPinned = !!$pinnedDatabases.find(x => x.name == data.name && x.connection?._id == data.connection?._id); + $: apps = useUsedApps(); currentTheme.set(theme.className), + onClick: () => currentTheme.set(theme.themeClassName), // onPreview: () => { // const old = get(currentTheme); // currentTheme.set(css); @@ -128,6 +128,23 @@ registerCommand({ }, }); +registerCommand({ + id: 'new.application', + category: 'New', + icon: 'img app', + name: 'Application', + onClick: () => { + showModal(InputTextModal, { + value: '', + label: 'New application name', + header: 'Create application', + onConfirm: async folder => { + apiCall('apps/create-folder', { folder }); + }, + }); + }, +}); + registerCommand({ id: 'new.table', category: 'New', diff --git a/packages/web/src/datagrid/ColumnHeaderControl.svelte b/packages/web/src/datagrid/ColumnHeaderControl.svelte index 762ed303..17e142a4 100644 --- a/packages/web/src/datagrid/ColumnHeaderControl.svelte +++ b/packages/web/src/datagrid/ColumnHeaderControl.svelte @@ -7,6 +7,8 @@ import { isTypeDateTime } from 'dbgate-tools'; import { openDatabaseObjectDetail } from '../appobj/DatabaseObjectAppObject.svelte'; import { copyTextToClipboard } from '../utility/clipboard'; + import VirtualForeignKeyEditorModal from '../tableeditor/VirtualForeignKeyEditorModal.svelte'; + import { showModal } from '../modals/modalTools'; export let column; export let conid = undefined; @@ -14,6 +16,7 @@ export let setSort; export let grouping = undefined; export let order = undefined; + export let allowDefineVirtualReferences = false; export let setGrouping; const openReferencedTable = () => { @@ -26,6 +29,16 @@ }); }; + const handleDefineVirtualForeignKey = () => { + showModal(VirtualForeignKeyEditorModal, { + schemaName: column.schemaName, + pureName: column.pureName, + conid, + database, + columnName: column.columnName, + }); + }; + function getMenu() { return [ setSort && { onClick: () => setSort('ASC'), text: 'Sort ascending' }, @@ -49,6 +62,11 @@ { onClick: () => setGrouping('GROUP:MONTH'), text: 'Group by MONTH' }, { onClick: () => setGrouping('GROUP:DAY'), text: 'Group by DAY' }, ], + + allowDefineVirtualReferences && [ + { divider: true }, + { onClick: handleDefineVirtualForeignKey, text: 'Define virtual foreign key' }, + ], ]; } diff --git a/packages/web/src/datagrid/DataGridCore.svelte b/packages/web/src/datagrid/DataGridCore.svelte index 6ef9b98f..e3f6175f 100644 --- a/packages/web/src/datagrid/DataGridCore.svelte +++ b/packages/web/src/datagrid/DataGridCore.svelte @@ -303,6 +303,7 @@ export let errorMessage = undefined; export let pureName = undefined; export let schemaName = undefined; + export let allowDefineVirtualReferences = false; export let isLoadedAll; export let loadedTime; @@ -1425,6 +1426,7 @@ }} setGrouping={display.groupable ? groupFunc => display.setGrouping(col.uniqueName, groupFunc) : null} grouping={display.getGrouping(col.uniqueName)} + {allowDefineVirtualReferences} /> {/each} diff --git a/packages/web/src/datagrid/TableDataGrid.svelte b/packages/web/src/datagrid/TableDataGrid.svelte index 2dc81a4f..e2e9331c 100644 --- a/packages/web/src/datagrid/TableDataGrid.svelte +++ b/packages/web/src/datagrid/TableDataGrid.svelte @@ -7,7 +7,7 @@ TableGridDisplay, } from 'dbgate-datalib'; import { getFilterValueExpression } from 'dbgate-filterparser'; - import { findEngineDriver } from 'dbgate-tools'; + import { extendDatabaseInfoFromApps, findEngineDriver } from 'dbgate-tools'; import _ from 'lodash'; import { writable } from 'svelte/store'; import VerticalSplitter from '../elements/VerticalSplitter.svelte'; @@ -16,9 +16,11 @@ import { useConnectionInfo, + useConnectionList, useDatabaseInfo, useDatabaseServerVersion, useServerVersion, + useUsedApps, } from '../utility/metadataLoaders'; import DataGrid from './DataGrid.svelte'; @@ -46,6 +48,9 @@ $: connection = useConnectionInfo({ conid }); $: dbinfo = useDatabaseInfo({ conid, database }); $: serverVersion = useDatabaseServerVersion({ conid, database }); + $: apps = useUsedApps(); + $: extendedDbInfo = extendDatabaseInfoFromApps($dbinfo, $apps); + $: connections = useConnectionList(); // $: console.log('serverVersion', $serverVersion); @@ -64,10 +69,10 @@ setConfig, cache, setCache, - $dbinfo, + extendedDbInfo, { showHintColumns: getBoolSettingsValue('dataGrid.showHintColumns', true) }, $serverVersion, - table => getDictionaryDescription(table, conid, database) + table => getDictionaryDescription(table, conid, database, $apps, $connections) ) : null; @@ -80,10 +85,10 @@ setConfig, cache, setCache, - $dbinfo, + extendedDbInfo, { showHintColumns: getBoolSettingsValue('dataGrid.showHintColumns', true) }, $serverVersion, - table => getDictionaryDescription(table, conid, database) + table => getDictionaryDescription(table, conid, database, $apps, $connections) ) : null; @@ -159,6 +164,7 @@ macroCondition={macro => macro.type == 'transformValue'} onReferenceSourceChanged={reference ? handleReferenceSourceChanged : null} multipleGridsOnTab={multipleGridsOnTab || !!reference} + allowDefineVirtualReferences onReferenceClick={value => { if (value && value.referenceId && reference && reference.referenceId == value.referenceId) { // reference not changed diff --git a/packages/web/src/designer/Designer.svelte b/packages/web/src/designer/Designer.svelte index 9c0676a7..f7f13ff4 100644 --- a/packages/web/src/designer/Designer.svelte +++ b/packages/web/src/designer/Designer.svelte @@ -29,7 +29,7 @@ import DesignerTable from './DesignerTable.svelte'; import { isConnectedByReference } from './designerTools'; import uuidv1 from 'uuid/v1'; - import { getTableInfo, useDatabaseInfo } from '../utility/metadataLoaders'; + import { getTableInfo, useDatabaseInfo, useUsedApps } from '../utility/metadataLoaders'; import cleanupDesignColumns from './cleanupDesignColumns'; import _ from 'lodash'; import { writable } from 'svelte/store'; @@ -46,6 +46,7 @@ import { showModal } from '../modals/modalTools'; import ChooseColorModal from '../modals/ChooseColorModal.svelte'; import { currentThemeDefinition } from '../stores'; + import { extendDatabaseInfoFromApps } from 'dbgate-tools'; export let value; export let onChange; @@ -67,10 +68,12 @@ const targetDragColumn$ = writable(null); const dbInfo = settings?.updateFromDbInfo ? useDatabaseInfo({ conid, database }) : null; + $: dbInfoExtended = $dbInfo ? extendDatabaseInfoFromApps($dbInfo, $apps) : null; $: tables = value?.tables as any[]; $: references = value?.references as any[]; $: zoomKoef = settings?.customizeStyle && value?.style?.zoomKoef ? value?.style?.zoomKoef : 1; + $: apps = useUsedApps(); $: isMultipleTableSelection = tables.filter(x => x.isSelectedTable).length >= 2; @@ -94,8 +97,8 @@ } $: { - if (dbInfo) { - updateFromDbInfo($dbInfo); + if (dbInfoExtended) { + updateFromDbInfo(dbInfoExtended as any); } } @@ -104,13 +107,13 @@ } $: { - if (dbInfo && value?.autoLayout) { - performAutoActions($dbInfo); + if (dbInfoExtended && value?.autoLayout) { + performAutoActions(dbInfoExtended); } } function updateFromDbInfo(db = 'auto') { - if (db == 'auto' && dbInfo) db = $dbInfo; + if (db == 'auto' && dbInfo) db = dbInfoExtended as any; if (!settings?.updateFromDbInfo || !db) return; onChange(current => { @@ -372,8 +375,8 @@ }; const handleAddTableReferences = async table => { - if (!dbInfo) return; - const db = $dbInfo; + if (!dbInfoExtended) return; + const db = dbInfoExtended; if (!db) return; callChange(current => { return getTablesWithReferences(db, table, current); @@ -692,13 +695,17 @@ if (css) css += '\n'; css += cssItem; } + if ($currentThemeDefinition?.themeCss) { + if (css) css += '\n'; + css += $currentThemeDefinition?.themeCss; + } saveFileToDisk(async filePath => { await apiCall('files/export-diagram', { filePath, html: domCanvas.outerHTML, css, themeType: $currentThemeDefinition?.themeType, - themeClassName: $currentThemeDefinition?.className, + themeClassName: $currentThemeDefinition?.themeClassName, }); }); } diff --git a/packages/web/src/designer/DesignerTable.svelte b/packages/web/src/designer/DesignerTable.svelte index d8fb21e0..ff7d9b9d 100644 --- a/packages/web/src/designer/DesignerTable.svelte +++ b/packages/web/src/designer/DesignerTable.svelte @@ -9,6 +9,7 @@ import InputTextModal from '../modals/InputTextModal.svelte'; import { showModal } from '../modals/modalTools'; import { currentThemeDefinition } from '../stores'; + import VirtualForeignKeyEditorModal from '../tableeditor/VirtualForeignKeyEditorModal.svelte'; import contextMenu from '../utility/contextMenu'; import moveDrag from '../utility/moveDrag'; import ColumnLine from './ColumnLine.svelte'; @@ -166,6 +167,15 @@ }); }; + const handleDefineVirtualForeignKey = table => { + showModal(VirtualForeignKeyEditorModal, { + schemaName: table.schemaName, + pureName: table.pureName, + conid, + database, + }); + }; + function createMenu() { return [ { text: 'Remove', onClick: () => onRemoveTable({ designerId }) }, @@ -185,6 +195,11 @@ settings?.allowAddAllReferences && !isMultipleTableSelection && { text: 'Add references', onClick: () => onAddAllReferences(table) }, settings?.allowChangeColor && { text: 'Change color', onClick: () => onChangeTableColor(table) }, + settings?.allowDefineVirtualReferences && + !isMultipleTableSelection && { + text: 'Define virtual foreign key', + onClick: () => handleDefineVirtualForeignKey(table), + }, settings?.appendTableSystemMenu && !isMultipleTableSelection && [{ divider: true }, createDatabaseObjectMenu({ ...table, conid, database })], ]; diff --git a/packages/web/src/designer/DiagramDesigner.svelte b/packages/web/src/designer/DiagramDesigner.svelte index 7f0ec71b..d5f62433 100644 --- a/packages/web/src/designer/DiagramDesigner.svelte +++ b/packages/web/src/designer/DiagramDesigner.svelte @@ -21,6 +21,7 @@ allowChangeColor: true, appendTableSystemMenu: true, customizeStyle: true, + allowDefineVirtualReferences: true, }} referenceComponent={DiagramDesignerReference} /> diff --git a/packages/web/src/designer/QueryDesigner.svelte b/packages/web/src/designer/QueryDesigner.svelte index 7eb74fa2..508a970b 100644 --- a/packages/web/src/designer/QueryDesigner.svelte +++ b/packages/web/src/designer/QueryDesigner.svelte @@ -21,6 +21,7 @@ allowChangeColor: false, appendTableSystemMenu: false, customizeStyle: false, + allowDefineVirtualReferences: false, }} referenceComponent={QueryDesignerReference} /> diff --git a/packages/web/src/forms/FormSelectFieldRaw.svelte b/packages/web/src/forms/FormSelectFieldRaw.svelte index e16fc6e8..d67d7660 100644 --- a/packages/web/src/forms/FormSelectFieldRaw.svelte +++ b/packages/web/src/forms/FormSelectFieldRaw.svelte @@ -9,11 +9,13 @@ export let name; export let options; export let isClearable = false; + export let selectFieldComponent = SelectField; const { values, setFieldValue } = getFormContext(); - + import _ from 'lodash'; + import { createEventDispatcher } from 'svelte'; + + import SelectField from '../forms/SelectField.svelte'; + import { currentDatabase } from '../stores'; + import { filterAppsForDatabase } from '../utility/appTools'; + import { useAppFolders, useUsedApps } from '../utility/metadataLoaders'; + + export let value = '#new'; + export let disableInitialize = false; + + const dispatch = createEventDispatcher(); + + $: appFolders = useAppFolders(); + $: usedApps = useUsedApps(); + + $: { + if (!disableInitialize && value == '#new' && $currentDatabase) { + const filtered = filterAppsForDatabase($currentDatabase.connection, $currentDatabase.name, $usedApps || []); + const common = _.intersection( + ($appFolders || []).map(x => x.name), + filtered.map(x => x.name) + ); + if (common.length > 0) { + value = common[0] as string; + dispatch('change', value); + } + } + } + + + { + value = e.detail; + dispatch('change', value); + }} + options={[ + { label: '(New application linked to current DB)', value: '#new' }, + ...($appFolders || []).map(app => ({ + label: app.name, + value: app.name, + })), + ]} +/> diff --git a/packages/web/src/icons/FontIcon.svelte b/packages/web/src/icons/FontIcon.svelte index 2c79d71f..90aebcfe 100644 --- a/packages/web/src/icons/FontIcon.svelte +++ b/packages/web/src/icons/FontIcon.svelte @@ -26,6 +26,7 @@ 'icon version': 'mdi mdi-ticket-confirmation', 'icon pin': 'mdi mdi-pin', 'icon arrange': 'mdi mdi-arrange-send-to-back', + 'icon app': 'mdi mdi-layers-triple', 'icon columns': 'mdi mdi-view-column', 'icon columns-outline': 'mdi mdi-view-column-outline', @@ -126,6 +127,9 @@ 'img diagram': 'mdi mdi-graph color-icon-blue', 'img yaml': 'mdi mdi-code-brackets color-icon-red', 'img compare': 'mdi mdi-compare color-icon-red', + 'img app': 'mdi mdi-layers-triple color-icon-magenta', + 'img app-command': 'mdi mdi-flash color-icon-green', + 'img app-query': 'mdi mdi-view-comfy color-icon-magenta', 'img add': 'mdi mdi-plus-circle color-icon-green', 'img minus': 'mdi mdi-minus-circle color-icon-red', diff --git a/packages/web/src/modals/DefineDictionaryDescriptionModal.svelte b/packages/web/src/modals/DefineDictionaryDescriptionModal.svelte index 899e768c..50b32757 100644 --- a/packages/web/src/modals/DefineDictionaryDescriptionModal.svelte +++ b/packages/web/src/modals/DefineDictionaryDescriptionModal.svelte @@ -1,10 +1,11 @@ @@ -75,7 +103,14 @@ - + + + - export const className = 'theme-dark'; + export const themeClassName = 'theme-dark'; export const themeName = 'Dark'; export const themeType = 'dark'; diff --git a/packages/web/src/plugins/ThemeLight.svelte b/packages/web/src/plugins/ThemeLight.svelte index 1956d393..fbc44e3b 100644 --- a/packages/web/src/plugins/ThemeLight.svelte +++ b/packages/web/src/plugins/ThemeLight.svelte @@ -1,5 +1,5 @@ diff --git a/packages/web/src/stores.ts b/packages/web/src/stores.ts index 068ba507..8bc5649f 100644 --- a/packages/web/src/stores.ts +++ b/packages/web/src/stores.ts @@ -64,6 +64,7 @@ export const openedModals = writable([]); export const openedSnackbars = writable([]); export const nullStore = readable(null, () => {}); export const currentArchive = writableWithStorage('default', 'currentArchive'); +export const currentApplication = writableWithStorage(null, 'currentApplication'); export const isFileDragActive = writable(false); export const selectedCellsCallback = writable(null); export const loadingPluginStore = writable({ @@ -72,7 +73,7 @@ export const loadingPluginStore = writable({ }); export const currentThemeDefinition = derived([currentTheme, extensions], ([$currentTheme, $extensions]) => - $extensions.themes.find(x => x.className == $currentTheme) + $extensions.themes.find(x => x.themeClassName == $currentTheme) ); subscribeCssVariable(selectedWidget, x => (x ? 1 : 0), '--dim-visible-left-panel'); diff --git a/packages/web/src/tableeditor/ForeignKeyEditorModal.svelte b/packages/web/src/tableeditor/ForeignKeyEditorModal.svelte index c13fb04c..4c8a8f1d 100644 --- a/packages/web/src/tableeditor/ForeignKeyEditorModal.svelte +++ b/packages/web/src/tableeditor/ForeignKeyEditorModal.svelte @@ -81,7 +81,7 @@ value={fullNameToString({ pureName: refTableName, schemaName: refSchemaName })} isNative notSelected - options={(dbInfo?.tables || []).map(tbl => ({ + options={_.sortBy(dbInfo?.tables || [], ['schemaName', 'pureName']).map(tbl => ({ label: fullNameToLabel(tbl), value: fullNameToString(tbl), }))} diff --git a/packages/web/src/tableeditor/VirtualForeignKeyEditorModal.svelte b/packages/web/src/tableeditor/VirtualForeignKeyEditorModal.svelte new file mode 100644 index 00000000..8124ba50 --- /dev/null +++ b/packages/web/src/tableeditor/VirtualForeignKeyEditorModal.svelte @@ -0,0 +1,190 @@ + + + + + Virtual foreign key + +
+
+
Referenced table
+
+ ({ + label: fullNameToLabel(tbl), + value: fullNameToString(tbl), + }))} + on:change={e => { + if (e.detail) { + const name = fullNameFromString(e.detail); + refTableName = name.pureName; + refSchemaName = name.schemaName; + } + }} + /> +
+
+ +
+
+ Base column - {$tableInfo.pureName} +
+
+ Ref column - {refTableName || '(table not set)'} +
+
+ + {#each columns as column, index} +
+
+ {#key column.columnName} + ({ + label: col.columnName, + value: col.columnName, + }))} + on:change={e => { + if (e.detail) { + columns = columns.map((col, i) => (i == index ? { ...col, columnName: e.detail } : col)); + } + }} + /> + {/key} +
+
+ {#key column.refColumnName} + ({ + label: col.columnName, + value: col.columnName, + }))} + on:change={e => { + if (e.detail) { + columns = columns.map((col, i) => (i == index ? { ...col, refColumnName: e.detail } : col)); + } + }} + /> + {/key} +
+
+ { + const x = [...columns]; + x.splice(index, 1); + columns = x; + }} + /> +
+
+ {/each} + + { + columns = [...columns, {}]; + }} + /> + +
+
Target application
+
+ +
+
+
+ + + { + const appFolder = await saveDbToApp(conid, database, dstApp); + await apiCall('apps/save-virtual-reference', { + appFolder, + schemaName, + pureName, + refSchemaName, + refTableName, + columns, + }); + closeCurrentModal(); + }} + /> + + + +
+
+ + diff --git a/packages/web/src/tabs/JsonEditorTab.svelte b/packages/web/src/tabs/JsonEditorTab.svelte new file mode 100644 index 00000000..e9a1e51e --- /dev/null +++ b/packages/web/src/tabs/JsonEditorTab.svelte @@ -0,0 +1,83 @@ + + + + + setEditorData(e.detail)} + on:focus={() => { + activator.activate(); + invalidateCommands(); + }} + bind:this={domEditor} + mode="json" +/> diff --git a/packages/web/src/tabs/YamlEditorTab.svelte b/packages/web/src/tabs/YamlEditorTab.svelte index 6cab222f..1540137b 100644 --- a/packages/web/src/tabs/YamlEditorTab.svelte +++ b/packages/web/src/tabs/YamlEditorTab.svelte @@ -31,7 +31,7 @@ const tabVisible: any = getContext('tabVisible'); - export const activator = createActivator('MarkdownEditorTab', false); + export const activator = createActivator('YamlEditorTab', false); let domEditor; @@ -63,8 +63,6 @@ function createMenu() { return [ - { command: 'yaml.preview' }, - { divider: true }, { command: 'yaml.toggleComment' }, { divider: true }, { command: 'yaml.save' }, diff --git a/packages/web/src/tabs/index.js b/packages/web/src/tabs/index.js index e3002e0d..b0d7f714 100644 --- a/packages/web/src/tabs/index.js +++ b/packages/web/src/tabs/index.js @@ -15,6 +15,7 @@ import * as FavoriteEditorTab from './FavoriteEditorTab.svelte'; import * as QueryDesignTab from './QueryDesignTab.svelte'; import * as CommandListTab from './CommandListTab.svelte'; import * as YamlEditorTab from './YamlEditorTab.svelte'; +import * as JsonEditorTab from './JsonEditorTab.svelte'; import * as CompareModelTab from './CompareModelTab.svelte'; import * as JsonTab from './JsonTab.svelte'; import * as ChangelogTab from './ChangelogTab.svelte'; @@ -38,6 +39,7 @@ export default { QueryDesignTab, CommandListTab, YamlEditorTab, + JsonEditorTab, CompareModelTab, JsonTab, ChangelogTab, diff --git a/packages/web/src/utility/appTools.ts b/packages/web/src/utility/appTools.ts new file mode 100644 index 00000000..506e1aaa --- /dev/null +++ b/packages/web/src/utility/appTools.ts @@ -0,0 +1,33 @@ +import { ApplicationDefinition, StoredConnection } from 'dbgate-types'; +import { apiCall } from '../utility/api'; + +export async function saveDbToApp(conid: string, database: string, app: string) { + if (app == '#new') { + const folder = await apiCall('apps/create-folder', { folder: database }); + + await apiCall('connections/update-database', { + conid, + database, + values: { + [`useApp:${folder}`]: true, + }, + }); + + return folder; + } + + await apiCall('connections/update-database', { + conid, + database, + values: { + [`useApp:${app}`]: true, + }, + }); + + return app; +} + +export function filterAppsForDatabase(connection, database: string, $apps): ApplicationDefinition[] { + const db = (connection?.databases || []).find(x => x.name == database); + return $apps.filter(app => db && db[`useApp:${app.name}`]); +} diff --git a/packages/web/src/utility/dictionaryDescriptionTools.ts b/packages/web/src/utility/dictionaryDescriptionTools.ts index 0454ccc7..87cc9557 100644 --- a/packages/web/src/utility/dictionaryDescriptionTools.ts +++ b/packages/web/src/utility/dictionaryDescriptionTools.ts @@ -1,7 +1,8 @@ import { DictionaryDescription } from 'dbgate-datalib'; -import { TableInfo } from 'dbgate-types'; +import { ApplicationDefinition, TableInfo } from 'dbgate-types'; import _ from 'lodash'; -import { getLocalStorage, setLocalStorage, removeLocalStorage } from './storageCache'; +import { apiCall } from './api'; +import { filterAppsForDatabase, saveDbToApp } from './appTools'; function checkDescriptionColumns(columns: string[], table: TableInfo) { if (!columns?.length) return false; @@ -14,17 +15,20 @@ export function getDictionaryDescription( table: TableInfo, conid: string, database: string, + apps: ApplicationDefinition[], + connections, skipCheckSaved: boolean = false ): DictionaryDescription { - const keySpecific = `dictionary_spec_${table.schemaName}||${table.pureName}||${conid}||${database}`; - const keyCommon = `dictionary_spec_${table.schemaName}||${table.pureName}`; + const conn = connections.find(x => x._id == conid); + const dbApps = filterAppsForDatabase(conn, database, apps); - const cachedSpecific = getLocalStorage(keySpecific); - const cachedCommon = getLocalStorage(keyCommon); + const cached = _.flatten(dbApps.map(x => x.dictionaryDescriptions || [])).find( + x => x.pureName == table.pureName && x.schemaName == table.schemaName + ); - if (cachedSpecific && (skipCheckSaved || checkDescriptionColumns(cachedSpecific.columns, table))) - return cachedSpecific; - if (cachedCommon && (skipCheckSaved || checkDescriptionColumns(cachedCommon.columns, table))) return cachedCommon; + if (cached && (skipCheckSaved || checkDescriptionColumns(cached.columns, table))) { + return cached; + } const descColumn = table.columns.find(x => x?.dataType?.toLowerCase()?.includes('char')); if (descColumn) { @@ -57,29 +61,22 @@ export function changeDelimitedColumnList(columns, columnName, isChecked) { return parsed.join(','); } -export function saveDictionaryDescription( +export async function saveDictionaryDescription( table: TableInfo, conid: string, database: string, expression: string, delimiter: string, - useForAllDatabases: boolean + targetApplication: string ) { - const keySpecific = `dictionary_spec_${table.schemaName}||${table.pureName}||${conid}||${database}`; - const keyCommon = `dictionary_spec_${table.schemaName}||${table.pureName}`; + const appFolder = await saveDbToApp(conid, database, targetApplication); - removeLocalStorage(keySpecific); - if (useForAllDatabases) removeLocalStorage(keyCommon); - - const description = { + await apiCall('apps/save-dictionary-description', { + appFolder, + schemaName: table.schemaName, + pureName: table.pureName, columns: parseDelimitedColumnList(expression), expression, delimiter, - }; - - if (useForAllDatabases) { - setLocalStorage(keyCommon, description); - } else { - setLocalStorage(keySpecific, description); - } + }); } diff --git a/packages/web/src/utility/metadataLoaders.ts b/packages/web/src/utility/metadataLoaders.ts index 3fcf6fa6..c6edbef8 100644 --- a/packages/web/src/utility/metadataLoaders.ts +++ b/packages/web/src/utility/metadataLoaders.ts @@ -103,6 +103,30 @@ const archiveFilesLoader = ({ folder }) => ({ reloadTrigger: `archive-files-changed-${folder}`, }); +const appFoldersLoader = () => ({ + url: 'apps/folders', + params: {}, + reloadTrigger: `app-folders-changed`, +}); + +const appFilesLoader = ({ folder }) => ({ + url: 'apps/files', + params: { folder }, + reloadTrigger: `app-files-changed-${folder}`, +}); + +// const dbAppsLoader = ({ conid, database }) => ({ +// url: 'apps/get-apps-for-db', +// params: { conid, database }, +// reloadTrigger: `db-apps-changed-${conid}-${database}`, +// }); + +const usedAppsLoader = ({ conid, database }) => ({ + url: 'apps/get-used-apps', + params: { }, + reloadTrigger: `used-apps-changed`, +}); + const serverStatusLoader = () => ({ url: 'server-connections/server-status', params: {}, @@ -401,6 +425,36 @@ export function useArchiveFolders(args = {}) { return useCore(archiveFoldersLoader, args); } +export function getAppFiles(args) { + return getCore(appFilesLoader, args); +} +export function useAppFiles(args) { + return useCore(appFilesLoader, args); +} + +export function getAppFolders(args = {}) { + return getCore(appFoldersLoader, args); +} +export function useAppFolders(args = {}) { + return useCore(appFoldersLoader, args); +} + + + +export function getUsedApps(args = {}) { + return getCore(usedAppsLoader, args); +} +export function useUsedApps(args = {}) { + return useCore(usedAppsLoader, args); +} + +// export function getDbApps(args = {}) { +// return getCore(dbAppsLoader, args); +// } +// export function useDbApps(args = {}) { +// return useCore(dbAppsLoader, args); +// } + export function getInstalledPlugins(args = {}) { return getCore(installedPluginsLoader, args) || []; } diff --git a/packages/web/src/widgets/AppFilesList.svelte b/packages/web/src/widgets/AppFilesList.svelte new file mode 100644 index 00000000..f010027f --- /dev/null +++ b/packages/web/src/widgets/AppFilesList.svelte @@ -0,0 +1,101 @@ + + + + + + + + + + + + + + + ({ + fileName: file.name, + folderName: folder, + fileType: file.type, + fileLabel: file.label, + }))} + groupFunc={data => APP_LABELS[data.fileType] || 'App config'} + module={appFileAppObject} + {filter} + /> + diff --git a/packages/web/src/widgets/AppFolderList.svelte b/packages/web/src/widgets/AppFolderList.svelte new file mode 100644 index 00000000..84aa0871 --- /dev/null +++ b/packages/web/src/widgets/AppFolderList.svelte @@ -0,0 +1,39 @@ + + + + + + runCommand('new.application')} title="Create new application"> + + + + + + + + + diff --git a/packages/web/src/widgets/AppWidget.svelte b/packages/web/src/widgets/AppWidget.svelte new file mode 100644 index 00000000..13074843 --- /dev/null +++ b/packages/web/src/widgets/AppWidget.svelte @@ -0,0 +1,19 @@ + + + + + + + + + + + diff --git a/packages/web/src/widgets/WidgetContainer.svelte b/packages/web/src/widgets/WidgetContainer.svelte index 14802429..c53900b5 100644 --- a/packages/web/src/widgets/WidgetContainer.svelte +++ b/packages/web/src/widgets/WidgetContainer.svelte @@ -6,6 +6,7 @@ import PluginsWidget from './PluginsWidget.svelte'; import CellDataWidget from './CellDataWidget.svelte'; import HistoryWidget from './HistoryWidget.svelte'; + import AppWidget from './AppWidget.svelte';