diff --git a/integration-tests/__tests__/alter-database.spec.js b/integration-tests/__tests__/alter-database.spec.js index 490de76a9..2a008c9dc 100644 --- a/integration-tests/__tests__/alter-database.spec.js +++ b/integration-tests/__tests__/alter-database.spec.js @@ -76,23 +76,26 @@ describe('Alter database', () => { }) ); - test.each(flatSource(x => x.supportRenameSqlObject))( - 'Rename object - %s - %s', - testWrapper(async (conn, driver, type, object, engine) => { - for (const sql of initSql) await runCommandOnDriver(conn, driver, sql); + const objectsSupportingRename = flatSource(x => x.supportRenameSqlObject); + if (objectsSupportingRename.length > 0) { + test.each(objectsSupportingRename)( + 'Rename object - %s - %s', + testWrapper(async (conn, driver, type, object, engine) => { + for (const sql of initSql) await runCommandOnDriver(conn, driver, sql); - await runCommandOnDriver(conn, driver, object.create1); + await runCommandOnDriver(conn, driver, object.create1); - const structure = extendDatabaseInfo(await driver.analyseFull(conn)); + const structure = extendDatabaseInfo(await driver.analyseFull(conn)); - const dmp = driver.createDumper(); - dmp.renameSqlObject(structure[type][0], 'renamed1'); + const dmp = driver.createDumper(); + dmp.renameSqlObject(structure[type][0], 'renamed1'); - await driver.query(conn, dmp.s); + await driver.query(conn, dmp.s); - const structure2 = await driver.analyseFull(conn); - expect(structure2[type].length).toEqual(1); - expect(structure2[type][0].pureName).toEqual('renamed1'); - }) - ); + const structure2 = await driver.analyseFull(conn); + expect(structure2[type].length).toEqual(1); + expect(structure2[type][0].pureName).toEqual('renamed1'); + }) + ); + } }); diff --git a/integration-tests/__tests__/object-analyse.spec.js b/integration-tests/__tests__/object-analyse.spec.js index 83a44f2af..25055fd04 100644 --- a/integration-tests/__tests__/object-analyse.spec.js +++ b/integration-tests/__tests__/object-analyse.spec.js @@ -23,6 +23,12 @@ function flatSourceTriggers() { return _.flatten(engines.map(engine => (engine.triggers || []).map(trigger => [engine.label, trigger, engine]))); } +function flatSourceSchedulerEvents() { + return _.flatten( + engines.map(engine => (engine.schedulerEvents || []).map(schedulerEvent => [engine.label, schedulerEvent, engine])) + ); +} + const obj1Match = expect.objectContaining({ pureName: 'obj1', }); @@ -172,6 +178,22 @@ describe('Object analyse', () => { expect(createdTrigger2).toEqual(expect.objectContaining(expected)); }) ); -}); -console.log(flatSourceTriggers()); + const schedulerEvents = flatSourceSchedulerEvents(); + if (schedulerEvents.length > 0) { + test.each(schedulerEvents)( + 'Test scheduler events - %s - %s', + testWrapper(async (conn, driver, event) => { + for (const sql of initSql) await runCommandOnDriver(conn, driver, sql); + const { create, drop, objectTypeField, expected } = event; + + await runCommandOnDriver(conn, driver, create); + const structure = await driver.analyseFull(conn); + await runCommandOnDriver(conn, driver, drop); + + const createdEvent = structure[objectTypeField].find(x => x.pureName == expected.pureName); + expect(createdEvent).toEqual(expect.objectContaining(expected)); + }) + ); + } +}); diff --git a/integration-tests/engines.js b/integration-tests/engines.js index 4dcc83504..13985867f 100644 --- a/integration-tests/engines.js +++ b/integration-tests/engines.js @@ -28,6 +28,13 @@ const mysqlEngine = { }, objects: [ views, + { + type: 'schedulerEvents', + create1: 'CREATE EVENT obj1 ON SCHEDULE EVERY 1 HOUR DO BEGIN END', + create2: 'CREATE EVENT obj2 ON SCHEDULE AT CURRENT_TIMESTAMP + INTERVAL 1 DAY DO BEGIN END', + drop1: 'DROP EVENT obj1', + drop2: 'DROP EVENT obj2', + }, { type: 'procedures', create1: 'CREATE PROCEDURE obj1() BEGIN SELECT * FROM t1; END', @@ -36,6 +43,7 @@ const mysqlEngine = { drop2: 'DROP PROCEDURE obj2', }, ], + supportRenameSqlObject: false, dbSnapshotBySeconds: true, dumpFile: 'data/chinook-mysql.sql', dumpChecks: [ @@ -130,6 +138,30 @@ const mysqlEngine = { }, }, ], + schedulerEvents: [ + { + create: 'CREATE EVENT obj1 ON SCHEDULE EVERY 1 HOUR DO BEGIN END', + drop: 'DROP EVENT obj1', + objectTypeField: 'schedulerEvents', + expected: { + pureName: 'obj1', + status: 'ENABLED', + eventType: 'RECURRING', + intervalValue: '1', + intervalField: 'HOUR', + }, + }, + { + create: 'CREATE EVENT obj1 ON SCHEDULE AT CURRENT_TIMESTAMP + INTERVAL 1 DAY DO BEGIN END', + drop: 'DROP EVENT obj1', + objectTypeField: 'schedulerEvents', + expected: { + pureName: 'obj1', + status: 'ENABLED', + eventType: 'ONE TIME', + }, + }, + ], }; const mariaDbEngine = { @@ -601,7 +633,7 @@ const enginesOnCi = [ const enginesOnLocal = [ // all engines, which would be run on local test - // mysqlEngine, + mysqlEngine, // mariaDbEngine, // postgreSqlEngine, // sqlServerEngine, diff --git a/packages/datalib/src/tests/chinookDbInfo.ts b/packages/datalib/src/tests/chinookDbInfo.ts index b36a8cda3..22b89ce48 100644 --- a/packages/datalib/src/tests/chinookDbInfo.ts +++ b/packages/datalib/src/tests/chinookDbInfo.ts @@ -1492,6 +1492,7 @@ export const chinookDbInfo: DatabaseInfo = { collections: [], matviews: [], triggers: [], + schedulerEvents: [], }; // const ARTIST_TABLE: TableInfo = { diff --git a/packages/tools/src/DatabaseAnalyser.ts b/packages/tools/src/DatabaseAnalyser.ts index b0e622616..31c86d93a 100644 --- a/packages/tools/src/DatabaseAnalyser.ts +++ b/packages/tools/src/DatabaseAnalyser.ts @@ -10,7 +10,16 @@ import { extractErrorLogData } from './stringTools'; const logger = getLogger('dbAnalyser'); -const STRUCTURE_FIELDS = ['tables', 'collections', 'views', 'matviews', 'functions', 'procedures', 'triggers']; +const STRUCTURE_FIELDS = [ + 'tables', + 'collections', + 'views', + 'matviews', + 'functions', + 'procedures', + 'triggers', + 'schedulerEvents', +]; const fp_pick = arg => array => _pick(array, arg); @@ -70,7 +79,9 @@ export class DatabaseAnalyser { } async fullAnalysis() { - logger.debug(`Performing full analysis, DB=${dbNameLogCategory(this.dbhan.database)}, engine=${this.driver.engine}`); + logger.debug( + `Performing full analysis, DB=${dbNameLogCategory(this.dbhan.database)}, engine=${this.driver.engine}` + ); const res = this.addEngineField(await this._runAnalysis()); // console.log('FULL ANALYSIS', res); return res; @@ -255,6 +266,7 @@ export class DatabaseAnalyser { ...this.getDeletedObjectsForField(snapshot, 'procedures'), ...this.getDeletedObjectsForField(snapshot, 'functions'), ...this.getDeletedObjectsForField(snapshot, 'triggers'), + ...this.getDeletedObjectsForField(snapshot, 'schedulerEvents'), ]; } @@ -355,6 +367,7 @@ export class DatabaseAnalyser { functions: [], procedures: [], triggers: [], + schedulerEvents: [], }; } diff --git a/packages/tools/src/SqlDumper.ts b/packages/tools/src/SqlDumper.ts index 3ec1572ae..04314dfc2 100644 --- a/packages/tools/src/SqlDumper.ts +++ b/packages/tools/src/SqlDumper.ts @@ -708,6 +708,8 @@ export class SqlDumper implements AlterProcessor { return 'TRIGGER'; case 'matviews': return 'MATERIALIZED VIEW'; + case 'schedulerEvents': + return 'EVENT'; } } @@ -785,10 +787,7 @@ export class SqlDumper implements AlterProcessor { } callableTemplate(func: CallableObjectInfo) { - this.put( - '^call %f(&>&n', - func, - ); + this.put('^call %f(&>&n', func); this.putCollection(',&n', func.parameters || [], param => { this.putRaw(param.parameterMode == 'IN' ? ':' + param.parameterName : param.parameterName); diff --git a/packages/tools/src/diffTools.ts b/packages/tools/src/diffTools.ts index 22cf09e7d..1bb7ee7d5 100644 --- a/packages/tools/src/diffTools.ts +++ b/packages/tools/src/diffTools.ts @@ -155,6 +155,7 @@ export function generateDbPairingId(db: DatabaseInfo): DatabaseInfo { procedures: db.procedures?.map(generateObjectPairingId), functions: db.functions?.map(generateObjectPairingId), triggers: db.triggers?.map(generateObjectPairingId), + schedulerEvents: db.schedulerEvents?.map(generateObjectPairingId), matviews: db.matviews?.map(generateObjectPairingId), }; } @@ -715,7 +716,15 @@ export function createAlterDatabasePlan( ): AlterPlan { const plan = new AlterPlan(wholeOldDb, wholeNewDb, driver.dialect, opts); - for (const objectTypeField of ['tables', 'views', 'procedures', 'matviews', 'functions', 'triggers']) { + for (const objectTypeField of [ + 'tables', + 'views', + 'procedures', + 'matviews', + 'functions', + 'triggers', + 'schedulerEvents', + ]) { for (const oldobj of oldDb[objectTypeField] || []) { const newobj = (newDb[objectTypeField] || []).find(x => x.pairingId == oldobj.pairingId); if (objectTypeField == 'tables') { diff --git a/packages/types/dbinfo.d.ts b/packages/types/dbinfo.d.ts index 66529f00b..6bd700e75 100644 --- a/packages/types/dbinfo.d.ts +++ b/packages/types/dbinfo.d.ts @@ -157,6 +157,18 @@ export interface TriggerInfo extends SqlObjectInfo { eventType?: 'INSERT' | 'UPDATE' | 'DELETE' | 'TRUNCATE'; } +export interface SchedulerEventInfo extends SqlObjectInfo { + definer: string; + eventType: 'RECURRING' | 'ONE TIME'; + onCompletion: 'PRESERVE' | 'NOT PRESERVE'; + status: 'ENABLED' | 'DISABLED'; + lastExecuted?: string; + intervalValue: number; + intervalField: string; + starts: string; + executeAt: string; +} + export interface SchemaInfo { objectId?: string; schemaName: string; @@ -171,6 +183,7 @@ export interface DatabaseInfoObjects { procedures: ProcedureInfo[]; functions: FunctionInfo[]; triggers: TriggerInfo[]; + schedulerEvents: SchedulerEventInfo[]; } export interface DatabaseInfo extends DatabaseInfoObjects { diff --git a/packages/web/src/appobj/DatabaseObjectAppObject.svelte b/packages/web/src/appobj/DatabaseObjectAppObject.svelte index 176d940fd..d801378a6 100644 --- a/packages/web/src/appobj/DatabaseObjectAppObject.svelte +++ b/packages/web/src/appobj/DatabaseObjectAppObject.svelte @@ -42,6 +42,7 @@ functions: 'img function', queries: 'img query-data', triggers: 'icon trigger', + schedulerEvents: 'icon scheduler-event', }; const defaultTabs = { @@ -87,10 +88,12 @@ isDropCollection?: boolean; isRenameCollection?: boolean; isDuplicateCollection?: boolean; + isDisableEvent?: boolean; + isEnableEvent?: boolean; submenu?: DbObjMenuItem[]; } - function createMenusCore(objectTypeField, driver): DbObjMenuItem[] { + function createMenusCore(objectTypeField, driver, data): DbObjMenuItem[] { switch (objectTypeField) { case 'tables': return [ @@ -344,6 +347,7 @@ }, ]; case 'functions': + return [...defaultDatabaseObjectAppObjectActions['functions']]; case 'triggers': return [...defaultDatabaseObjectAppObjectActions['triggers']]; case 'collections': @@ -383,6 +387,28 @@ }, ...(driver?.getScriptTemplates?.('collections') || []), ]; + case 'schedulerEvents': + const menu: DbObjMenuItem[] = [ + ...defaultDatabaseObjectAppObjectActions['schedulerEvents'], + { + divider: true, + }, + , + ]; + + if (data?.status === 'ENABLED') { + menu.push({ + label: 'Disable', + isDisableEvent: true, + }); + } else { + menu.push({ + label: 'Enable', + isEnableEvent: true, + }); + } + + return menu; } } @@ -480,6 +506,36 @@ x => x.schemaName == data.schemaName && x.pureName == data.pureName ); }); + } else if (menu.isDisableEvent) { + const { conid, database, pureName } = data; + const driver = await getDriver(); + const dmp = driver.createDumper(); + dmp.put('^alter ^event %i ^disable', pureName); + + const sql = dmp.s; + + showModal(ConfirmSqlModal, { + sql, + onConfirm: async () => { + saveScriptToDatabase({ conid, database }, sql); + }, + engine: driver.engine, + }); + } else if (menu.isEnableEvent) { + const { conid, database, pureName } = data; + const driver = await getDriver(); + const dmp = driver.createDumper(); + dmp.put('^alter ^event %i ^enable', pureName); + + const sql = dmp.s; + + showModal(ConfirmSqlModal, { + sql, + onConfirm: async () => { + saveScriptToDatabase({ conid, database }, sql); + }, + engine: driver.engine, + }); } else if (menu.isTruncate) { const { conid, database } = data; const driver = await getDriver(); @@ -625,8 +681,8 @@ } } - function createMenus(objectTypeField, driver): ReturnType { - return createMenusCore(objectTypeField, driver).filter(x => { + function createMenus(objectTypeField, driver, data): ReturnType { + return createMenusCore(objectTypeField, driver, data).filter(x => { if (x.scriptTemplate) { return hasPermission(`dbops/sql-template/${x.scriptTemplate}`); } @@ -844,7 +900,7 @@ const driver = findEngineDriver(data, getExtensions()); const { objectTypeField } = data; - return createMenus(objectTypeField, driver) + return createMenus(objectTypeField, driver, data) .filter(x => x) .map(menu => menuItemMapper(menu, data, connection)); } @@ -941,6 +997,15 @@ if (data.objectTypeField === 'triggers') { res.push(`${data.tableName}, ${data.triggerTiming?.toLowerCase() ?? ''} ${data.eventType?.toLowerCase() ?? ''}`); } + + if (data.objectTypeField == 'schedulerEvents') { + if (data.eventType == 'RECURRING') { + res.push(`${data.status}, ${data.eventType}, ${data.intervalValue} ${data.intervalField}`); + } else { + res.push(`${data.status}, ${data.eventType}, ${data.executeAt}`); + } + } + if (data.objectComment) { res.push(data.objectComment); } diff --git a/packages/web/src/appobj/appObjectTools.ts b/packages/web/src/appobj/appObjectTools.ts index 7b3e0bb8c..346c94d23 100644 --- a/packages/web/src/appobj/appObjectTools.ts +++ b/packages/web/src/appobj/appObjectTools.ts @@ -85,4 +85,12 @@ export const defaultDatabaseObjectAppObjectActions = { }, }, ], + schedulerEvents: [ + { + label: 'Show SQL', + tab: 'SqlObjectTab', + defaultActionId: 'showSql', + icon: 'img sql-file', + }, + ], }; diff --git a/packages/web/src/icons/FontIcon.svelte b/packages/web/src/icons/FontIcon.svelte index 77bed6e6c..bc080b50a 100644 --- a/packages/web/src/icons/FontIcon.svelte +++ b/packages/web/src/icons/FontIcon.svelte @@ -65,6 +65,7 @@ 'icon add-column': 'mdi mdi-table-column-plus-after', 'icon parameter': 'mdi mdi-at', 'icon trigger': 'mdi mdi-lightning-bolt', + 'icon scheduler-event': 'mdi mdi-calendar-blank', 'icon window-restore': 'mdi mdi-window-restore', 'icon window-maximize': 'mdi mdi-window-maximize', diff --git a/packages/web/src/utility/applyScriptTemplate.ts b/packages/web/src/utility/applyScriptTemplate.ts index ac2de4b0f..6644e2dac 100644 --- a/packages/web/src/utility/applyScriptTemplate.ts +++ b/packages/web/src/utility/applyScriptTemplate.ts @@ -173,6 +173,13 @@ export function getSupportedScriptTemplates(objectTypeField: string): { label: s scriptTemplate: 'CREATE OBJECT', }, ]; + case 'schedulerEvents': + return [ + { + label: 'CREATE SCHEDULER EVENT', + scriptTemplate: 'CREATE OBJECT', + }, + ]; } return []; diff --git a/packages/web/src/widgets/SqlObjectList.svelte b/packages/web/src/widgets/SqlObjectList.svelte index 7638f6cbd..77d926ff0 100644 --- a/packages/web/src/widgets/SqlObjectList.svelte +++ b/packages/web/src/widgets/SqlObjectList.svelte @@ -2,7 +2,7 @@ function generateObjectList(seed = 0) { const counts = [1000, 1200, 1100, 2100, 720]; const schemas = ['A', 'dev', 'public', 'dbo']; - const types = ['tables', 'views', 'functions', 'procedures', 'matviews', 'triggers']; + const types = ['tables', 'views', 'functions', 'procedures', 'matviews', 'triggers', 'schedulerEvents']; const res = _.range(1, counts[seed % counts.length]).map(i => ({ pureName: `name ${i}`, schemaName: schemas[i % schemas.length], @@ -80,11 +80,12 @@ // $: console.log('OBJECTS', $objects); $: objectList = _.flatten([ - ...['tables', 'collections', 'views', 'matviews', 'procedures', 'functions', 'triggers'].map(objectTypeField => - _.sortBy( - (($objects || {})[objectTypeField] || []).map(obj => ({ ...obj, objectTypeField })), - ['schemaName', 'pureName'] - ) + ...['tables', 'collections', 'views', 'matviews', 'procedures', 'functions', 'triggers', 'schedulerEvents'].map( + objectTypeField => + _.sortBy( + (($objects || {})[objectTypeField] || []).map(obj => ({ ...obj, objectTypeField })), + ['schemaName', 'pureName'] + ) ), ...dbApps.map(app => app.queries.map(query => ({ diff --git a/plugins/dbgate-plugin-mysql/src/backend/Analyser.js b/plugins/dbgate-plugin-mysql/src/backend/Analyser.js index d904b3654..c51bd310a 100644 --- a/plugins/dbgate-plugin-mysql/src/backend/Analyser.js +++ b/plugins/dbgate-plugin-mysql/src/backend/Analyser.js @@ -164,6 +164,9 @@ class Analyser extends DatabaseAnalyser { this.feedback({ analysingMessage: 'Loading triggers' }); const triggers = await this.analyserQuery('triggers'); + this.feedback({ analysingMessage: 'Loading scehduler events' }); + const schedulerEvents = await this.analyserQuery('schedulerEvents'); + const uniqueNames = await this.analyserQuery('uniqueNames', ['tables']); this.feedback({ analysingMessage: 'Finalizing DB structure' }); @@ -249,6 +252,21 @@ class Analyser extends DatabaseAnalyser { tableName: row.tableName, createSql: `CREATE TRIGGER ${row.triggerName} ${row.triggerTiming} ${row.eventType} ON ${row.tableName} FOR EACH ROW ${row.definition}`, })), + schedulerEvents: schedulerEvents.rows.map(row => ({ + contentHash: _.isDate(row.LAST_ALTERED) ? row.LAST_ALTERED.toISOString() : row.LAST_ALTERED, + pureName: row.EVENT_NAME, + createSql: row.CREATE_SQL, + objectId: row.EVENT_NAME, + intervalValue: row.INTERVAL_VALUE, + intervalField: row.INTERVAL_FIELD, + starts: row.STARTS, + status: row.STATUS, + executeAt: row.EXECUTE_AT, + lastExecuted: row.LAST_EXECUTED, + eventType: row.EVENT_TYPE, + definer: row.DEFINER, + objectTypeField: 'schedulerEvents', + })), }; this.feedback({ analysingMessage: null }); return res; @@ -258,6 +276,7 @@ class Analyser extends DatabaseAnalyser { const tableModificationsQueryData = await this.analyserQuery('tableModifications'); const procedureModificationsQueryData = await this.analyserQuery('procedureModifications'); const functionModificationsQueryData = await this.analyserQuery('functionModifications'); + const schedulerEvents = await this.analyserQuery('schedulerEvents'); return { tables: tableModificationsQueryData.rows @@ -285,6 +304,21 @@ class Analyser extends DatabaseAnalyser { objectId: x.Name, pureName: x.Name, })), + schedulerEvents: schedulerEvents.rows.map(row => ({ + contentHash: _.isDate(row.LAST_ALTERED) ? row.LAST_ALTERED.toISOString() : row.LAST_ALTERED, + pureName: row.EVENT_NAME, + createSql: row.CREATE_SQL, + objectId: row.EVENT_NAME, + intervalValue: row.INTERVAL_VALUE, + intervalField: row.INTERVAL_FIELD, + starts: row.STARTS, + status: row.STATUS, + executeAt: row.EXECUTE_AT, + lastExecuted: row.LAST_EXECUTED, + eventType: row.EVENT_TYPE, + definer: row.DEFINER, + objectTypeField: 'schedulerEvents', + })), }; } } diff --git a/plugins/dbgate-plugin-mysql/src/backend/sql/index.js b/plugins/dbgate-plugin-mysql/src/backend/sql/index.js index 1a7eda8d7..ab23eeaca 100644 --- a/plugins/dbgate-plugin-mysql/src/backend/sql/index.js +++ b/plugins/dbgate-plugin-mysql/src/backend/sql/index.js @@ -12,6 +12,7 @@ const uniqueNames = require('./uniqueNames'); const viewTexts = require('./viewTexts'); const parameters = require('./parameters'); const triggers = require('./triggers'); +const schedulerEvents = require('./schedulerEvents.js'); module.exports = { columns, @@ -28,4 +29,5 @@ module.exports = { uniqueNames, viewTexts, triggers, + schedulerEvents, }; diff --git a/plugins/dbgate-plugin-mysql/src/backend/sql/schedulerEvents.js b/plugins/dbgate-plugin-mysql/src/backend/sql/schedulerEvents.js new file mode 100644 index 000000000..9a62c0835 --- /dev/null +++ b/plugins/dbgate-plugin-mysql/src/backend/sql/schedulerEvents.js @@ -0,0 +1,32 @@ +module.exports = ` +SELECT + EVENT_SCHEMA, + EVENT_NAME, + DEFINER, + EVENT_TYPE, + EXECUTE_AT, + INTERVAL_VALUE, + INTERVAL_FIELD, + CREATED, + LAST_EXECUTED, + LAST_ALTERED, + STARTS, + ENDS, + STATUS, + ON_COMPLETION, + CONCAT( + 'CREATE EVENT \`', EVENT_NAME, '\` ', + CASE WHEN EVENT_TYPE = 'RECURRING' THEN + 'ON SCHEDULE EVERY ' + ELSE 'ON SCHEDULE AT ' + END, + CASE WHEN EVENT_TYPE = 'RECURRING' THEN + CONCAT(INTERVAL_VALUE, ' ', INTERVAL_FIELD) + ELSE DATE_FORMAT(EXECUTE_AT, '%Y-%m-%d %H:%i:%s') + END, + ' DO ', + EVENT_DEFINITION + ) AS CREATE_SQL +FROM INFORMATION_SCHEMA.EVENTS +WHERE EVENT_SCHEMA = '#DATABASE#' AND EVENT_NAME =OBJECT_ID_CONDITION +`;