diff --git a/integration-tests/firebird.conf b/integration-tests/firebird.conf new file mode 100644 index 000000000..4ebebf759 --- /dev/null +++ b/integration-tests/firebird.conf @@ -0,0 +1,45 @@ +# Custom Firebird Configuration + +# Wire encryption settings +# Options: Enabled, Required, Disabled +WireCrypt = Disabled + +# Authentication settings +# Add Legacy_Auth to support older clients +AuthServer = Legacy_Auth + +# User manager plugin +UserManager = Legacy_UserManager + +# Default character set +DefaultCharSet = UTF8 + +# Buffer settings for better performance +DefaultDbCachePages = 2048 +TempCacheLimit = 512M + +# Connection settings +ConnectionTimeout = 180 +DatabaseGrowthIncrement = 128M + +# TCP Protocol settings +TcpRemoteBufferSize = 8192 +TcpNoNagle = 1 + +# Security settings +RemoteServiceName = gds_db +RemoteServicePort = 3050 +RemoteAuxPort = 0 +RemotePipeName = firebird + +# Lock settings +LockMemSize = 1M +LockHashSlots = 8191 +LockAcquireSpins = 0 + +# Log settings +FileSystemCacheThreshold = 65536 +FileSystemCacheSize = 0 + +# Compatibility settings for older clients +CompatiblityDialect = 3 diff --git a/plugins/dbgate-plugin-firebird/package.json b/plugins/dbgate-plugin-firebird/package.json new file mode 100644 index 000000000..5af25c58e --- /dev/null +++ b/plugins/dbgate-plugin-firebird/package.json @@ -0,0 +1,46 @@ +{ + "name": "dbgate-plugin-firebird", + "main": "dist/backend.js", + "version": "6.0.0-alpha.1", + "license": "GPL-3.0", + "description": "firebirdQL connector plugin for DbGate", + "homepage": "https://dbgate.org", + "repository": { + "type": "git", + "url": "https://github.com/dbgate/dbgate" + }, + "author": "Jan Prochazka", + "keywords": [ + "dbgate", + "firebird", + "dbgatebuiltin" + ], + "files": [ + "dist", + "icon.svg" + ], + "scripts": { + "build:frontend": "webpack --config webpack-frontend.config", + "build:frontend:watch": "webpack --watch --config webpack-frontend.config", + "build:backend": "webpack --config webpack-backend.config.js", + "build": "yarn build:frontend && yarn build:backend", + "plugin": "yarn build && yarn pack && dbgate-plugin dbgate-plugin-firebird", + "copydist": "yarn build && yarn pack && dbgate-copydist ../dist/dbgate-plugin-firebird", + "plugout": "dbgate-plugout dbgate-plugin-firebird", + "prepublishOnly": "yarn build" + }, + "devDependencies": { + "dbgate-plugin-tools": "^1.0.7", + "webpack": "^5.91.0", + "webpack-cli": "^5.1.4" + }, + "dependencies": { + "wkx": "^0.5.0", + "pg-copy-streams": "^6.0.6", + "node-firebird": "^1.1.9", + "dbgate-query-splitter": "^4.11.3", + "dbgate-tools": "^6.0.0-alpha.1", + "lodash": "^4.17.21", + "pg": "^8.11.5" + } +} diff --git a/plugins/dbgate-plugin-firebird/prettier.config.js b/plugins/dbgate-plugin-firebird/prettier.config.js new file mode 100644 index 000000000..c05d71875 --- /dev/null +++ b/plugins/dbgate-plugin-firebird/prettier.config.js @@ -0,0 +1,9 @@ +module.exports = { + trailingComma: 'es5', + tabWidth: 2, + semi: true, + singleQuote: true, + arrowParen: 'avoid', + arrowParens: 'avoid', + printWidth: 120, +}; diff --git a/plugins/dbgate-plugin-firebird/src/backend/Analyser.js b/plugins/dbgate-plugin-firebird/src/backend/Analyser.js new file mode 100644 index 000000000..a88fe4f76 --- /dev/null +++ b/plugins/dbgate-plugin-firebird/src/backend/Analyser.js @@ -0,0 +1,51 @@ +const _ = require('lodash'); +const sql = require('./sql'); +const { getDataTypeString } = require('./helpers'); + +const { DatabaseAnalyser } = require('dbgate-tools'); + +class Analyser extends DatabaseAnalyser { + constructor(dbhan, driver, version) { + super(dbhan, driver, version); + } + + async _runAnalysis() { + const tablesResult = await this.driver.query(this.dbhan, sql.tables); + const columnsResult = await this.driver.query(this.dbhan, sql.columns); + + const columns = columnsResult.rows.map(i => ({ + tableName: i.TABLENAME, + columnName: i.COLUMNNAME, + notNull: i.NOTNULL, + isPrimaryKey: i.ISPRIMARYKEY, + dataType: getDataTypeString(i), + precision: i.NUMBERPRECISION, + scale: i.SCALE, + length: i.LENGTH, + defaultValue: i.DEFAULTVALUE, + columnComment: i.COLUMNCOMMENT, + isUnsigned: i.ISUNSIGNED, + pureName: i.PURENAME, + schemaName: i.SCHEMANAME, + })); + const tables = tablesResult.rows.map(i => ({ + pureName: i.PURENAME, + objectId: i.OBJECTID, + schemaName: i.SCHEMANAME, + objectComment: i.OBJECTCOMMENT, + })); + + return { + tables: tables.map(table => ({ + ...table, + columns: columns.filter(column => column.tableName === table.pureName), + })), + }; + } + + async _getFastSnapshot() { + return this._runAnalysis(); + } +} + +module.exports = Analyser; diff --git a/plugins/dbgate-plugin-firebird/src/backend/driver.js b/plugins/dbgate-plugin-firebird/src/backend/driver.js new file mode 100644 index 000000000..5c04830b0 --- /dev/null +++ b/plugins/dbgate-plugin-firebird/src/backend/driver.js @@ -0,0 +1,146 @@ +const _ = require('lodash'); +const driverBase = require('../frontend/driver'); +const Analyser = require('./Analyser'); +const Firebird = require('node-firebird'); +const { getLogger, extractErrorLogData, createBulkInsertStreamBase } = require('dbgate-tools'); +const sql = require('./sql'); + +const logger = getLogger('firebird'); + +/** @type {import('dbgate-types').EngineDriver} */ +const driver = { + ...driverBase, + analyserClass: Analyser, + async connect({ port, user, password, server, databaseFile }) { + const options = { + host: server, + port, + database: databaseFile, + user, + password, + }; + + /**@type {Firebird.Database} */ + const db = await new Promise((resolve, reject) => { + Firebird.attach(options, (err, db) => { + if (err) { + reject(err); + return; + } + resolve(db); + }); + }); + + return { + client: db, + }; + }, + + async query(dbhan, sql) { + const res = await new Promise((resolve, reject) => { + dbhan.client.query(sql, (err, result) => { + if (err) { + reject(err); + return; + } + + resolve(result); + }); + }); + const columns = res[0] ? Object.keys(res[0]).map(i => ({ columnName: i })) : []; + + return { + rows: res, + columns, + }; + }, + + async script(dbhan, sql) { + throw new Error('Not implemented'); + }, + + async stream(dbhan, sql, options) { + try { + await new Promise((resolve, reject) => { + let hasSentColumns = false; + dbhan.client.sequentially( + sql, + [], + (row, index) => { + if (!hasSentColumns) { + hasSentColumns = true; + const columns = Object.keys(row).map(i => ({ columnName: i })); + options.recordset(columns); + } + + options.row(row); + }, + err => { + if (err) { + reject(err); + return; + } + + resolve(); + } + ); + }); + + options.done(); + } catch (err) { + logger.error(extractErrorLogData(err), 'Stream error'); + options.info({ + message: err.message, + line: err.line, + // procedure: procName, + time: new Date(), + severity: 'error', + }); + options.done(); + } + }, + + async readQuery(dbhan, sql, structure) { + throw new Error('Not implemented'); + }, + + async writeTable(dbhan, name, options) { + return createBulkInsertStream(this, stream, dbhan, name, options); + }, + + async getVersion(dbhan) { + const res = await this.query(dbhan, sql.version); + const version = res.rows?.[0]?.VERSION; + + return { + version, + versionText: `Firebird ${version}`, + }; + }, + + async listDatabases(dbhan) { + return [ + { + name: 'default', + }, + ]; + }, + + async createDatabase(dbhan, name) {}, + + async dropDatabase(dbhan, name) {}, + + async close(dbhan) { + return new Promise((resolve, reject) => { + dbhan.client.detach(err => { + if (err) { + reject(err); + return; + } + resolve(); + }); + }); + }, +}; + +module.exports = driver; diff --git a/plugins/dbgate-plugin-firebird/src/backend/helpers.js b/plugins/dbgate-plugin-firebird/src/backend/helpers.js new file mode 100644 index 000000000..a86d7f2b1 --- /dev/null +++ b/plugins/dbgate-plugin-firebird/src/backend/helpers.js @@ -0,0 +1,54 @@ +function getDataTypeString(column) { + if (!column) { + return null; + } + const { DATATYPECODE, SCALE, LENGTH, NUMBERPRECISION } = column; + + switch (DATATYPECODE) { + case 7: + return 'SMALLINT'; + + case 8: + return 'INTEGER'; + + case 9: + return 'BIGINT'; + + case 10: + return 'FLOAT'; + + case 11: + return 'DOUBLE PRECISION'; + + case 12: + return 'DATE'; + + case 13: + return 'TIME'; + + case 14: + return `CHAR(${LENGTH})`; + + case 16: + return `DECIMAL(${NUMBERPRECISION}, ${SCALE})`; + + case 27: + return 'DOUBLE PRECISION'; + + case 35: + return 'BLOB'; + + case 37: + return `VARCHAR(${LENGTH})`; + + case 261: + return 'CSTRING'; + + default: + return `UNKNOWN (${DATATYPECODE})`; + } +} + +module.exports = { + getDataTypeString, +}; diff --git a/plugins/dbgate-plugin-firebird/src/backend/index.js b/plugins/dbgate-plugin-firebird/src/backend/index.js new file mode 100644 index 000000000..bee3b7706 --- /dev/null +++ b/plugins/dbgate-plugin-firebird/src/backend/index.js @@ -0,0 +1,7 @@ +const driver = require('./driver'); + +module.exports = { + packageName: 'dbgate-plugin-firebird', + drivers: [driver], + initialize(dbgateEnv) {}, +}; diff --git a/plugins/dbgate-plugin-firebird/src/backend/sql/columns.js b/plugins/dbgate-plugin-firebird/src/backend/sql/columns.js new file mode 100644 index 000000000..8096ad9b8 --- /dev/null +++ b/plugins/dbgate-plugin-firebird/src/backend/sql/columns.js @@ -0,0 +1,43 @@ +module.exports = ` +SELECT DISTINCT + CAST(TRIM(rf.rdb$relation_name) AS VARCHAR(255)) AS tableName, + CAST(TRIM(rf.rdb$field_name) AS VARCHAR(255)) AS columnName, + CASE rf.rdb$null_flag WHEN 1 THEN FALSE ELSE TRUE END AS notNull, + CASE + WHEN EXISTS ( + SELECT 1 + FROM rdb$relation_constraints rc + JOIN rdb$index_segments idx ON rc.rdb$index_name = idx.rdb$index_name + WHERE rc.rdb$relation_name = rf.rdb$relation_name + AND idx.rdb$field_name = rf.rdb$field_name + AND rc.rdb$constraint_type = 'PRIMARY KEY' + ) THEN TRUE + ELSE FALSE + END AS isPrimaryKey, + f.rdb$field_type AS dataTypeCode, + f.rdb$field_precision AS numberprecision, + f.rdb$field_scale AS scale, + f.rdb$field_length AS length, + CAST(TRIM(rf.rdb$default_value) AS VARCHAR(255)) AS defaultValue, + CAST(TRIM(rf.rdb$description) AS VARCHAR(255)) AS columnComment, + CASE + WHEN f.rdb$field_type IN (8, 9, 16) AND f.rdb$field_scale < 0 THEN TRUE + ELSE FALSE + END AS isUnsigned, + CAST(TRIM(rf.rdb$field_name) AS VARCHAR(255)) AS pureName, + CAST(TRIM(r.rdb$owner_name) AS VARCHAR(255)) AS schemaName +FROM + rdb$relation_fields rf +JOIN + rdb$relations r ON rf.rdb$relation_name = r.rdb$relation_name +LEFT JOIN + rdb$fields f ON rf.rdb$field_source = f.rdb$field_name +LEFT JOIN + rdb$character_sets cs ON f.rdb$character_set_id = cs.rdb$character_set_id +LEFT JOIN + rdb$collations co ON f.rdb$collation_id = co.rdb$collation_id +WHERE + r.rdb$system_flag = 0 +ORDER BY + tableName, rf.rdb$field_position; +`; diff --git a/plugins/dbgate-plugin-firebird/src/backend/sql/index.js b/plugins/dbgate-plugin-firebird/src/backend/sql/index.js new file mode 100644 index 000000000..5b56564ce --- /dev/null +++ b/plugins/dbgate-plugin-firebird/src/backend/sql/index.js @@ -0,0 +1,9 @@ +const version = require('./version'); +const tables = require('./tables'); +const columns = require('./columns'); + +module.exports = { + version, + columns, + tables, +}; diff --git a/plugins/dbgate-plugin-firebird/src/backend/sql/tables.js b/plugins/dbgate-plugin-firebird/src/backend/sql/tables.js new file mode 100644 index 000000000..2cd18e888 --- /dev/null +++ b/plugins/dbgate-plugin-firebird/src/backend/sql/tables.js @@ -0,0 +1,9 @@ +module.exports = ` +SELECT + TRIM(RDB$RELATION_NAME) AS pureName, + RDB$DESCRIPTION AS objectComment, + RDB$FORMAT AS objectTypeField +FROM RDB$RELATIONS +WHERE RDB$SYSTEM_FLAG = 0 -- only user-defined tables +ORDER BY pureName; +`; diff --git a/plugins/dbgate-plugin-firebird/src/backend/sql/version.js b/plugins/dbgate-plugin-firebird/src/backend/sql/version.js new file mode 100644 index 000000000..ef95fb89a --- /dev/null +++ b/plugins/dbgate-plugin-firebird/src/backend/sql/version.js @@ -0,0 +1 @@ +module.exports = `SELECT rdb$get_context('SYSTEM', 'ENGINE_VERSION') as version from rdb$database;`; diff --git a/plugins/dbgate-plugin-firebird/src/frontend/Dumper.js b/plugins/dbgate-plugin-firebird/src/frontend/Dumper.js new file mode 100644 index 000000000..f0df62e8f --- /dev/null +++ b/plugins/dbgate-plugin-firebird/src/frontend/Dumper.js @@ -0,0 +1,5 @@ +const { SqlDumper } = global.DBGATE_PACKAGES['dbgate-tools']; + +class Dumper extends SqlDumper {} + +module.exports = Dumper; diff --git a/plugins/dbgate-plugin-firebird/src/frontend/driver.js b/plugins/dbgate-plugin-firebird/src/frontend/driver.js new file mode 100644 index 000000000..fa0ef8d1f --- /dev/null +++ b/plugins/dbgate-plugin-firebird/src/frontend/driver.js @@ -0,0 +1,85 @@ +const { driverBase } = global.DBGATE_PACKAGES['dbgate-tools']; +const Dumper = require('./Dumper'); + +/** @type {import('dbgate-types').SqlDialect} */ +const dialect = { + rangeSelect: true, + ilike: true, + defaultSchemaName: 'public', + multipleSchema: true, + stringEscapeChar: "'", + fallbackDataType: 'varchar', + anonymousPrimaryKey: false, + enableConstraintsPerTable: true, + stringAgg: true, + + createColumn: true, + dropColumn: true, + changeColumn: true, + createIndex: true, + dropIndex: true, + createForeignKey: true, + dropForeignKey: true, + createPrimaryKey: true, + dropPrimaryKey: true, + createUnique: true, + dropUnique: true, + createCheck: true, + dropCheck: true, + allowMultipleValuesInsert: true, + renameSqlObject: true, + filteredIndexes: true, +}; + +const firebirdSplitterOptions = { + stringsBegins: ["'", '"'], + stringsEnds: { + "'": "'", + '"': '"', + }, + stringEscapes: { + "'": "'", // Single quote is escaped by another single quote + '"': '"', // Double quote is escaped by another double quote + }, + allowSemicolon: true, + allowCustomDelimiter: false, + allowCustomSqlTerminator: false, + allowGoDelimiter: false, + allowSlashDelimiter: false, + allowDollarDollarString: false, + noSplit: false, + doubleDashComments: true, + multilineComments: true, + javaScriptComments: false, + skipSeparatorBeginEnd: false, + ignoreComments: false, + preventSingleLineSplit: false, + adaptiveGoSplit: false, + returnRichInfo: false, + splitByLines: false, + splitByEmptyLine: false, + copyFromStdin: false, + queryParameterStyle: ':', // Firebird uses colon-prefixed parameters (:param_name) +}; + +/** @type {import('dbgate-types').EngineDriver} */ +const firebirdDriverBase = { + ...driverBase, + defaultPort: 3050, + showConnectionField: field => ['port', 'user', 'password', 'server', 'databaseFile'].includes(field), + getQuerySplitterOptions: () => firebirdSplitterOptions, + // beforeConnectionSave: connection => { + // const { databaseFile } = connection; + // return { + // singleDatabase: true, + // defaultDatabase: databaseFile, + // }; + // }, + engine: 'firebird@dbgate-plugin-firebird', + title: 'Firebird', + supportsTransactions: true, + dumperClass: Dumper, + dialect, +}; + +module.exports = firebirdDriverBase; diff --git a/plugins/dbgate-plugin-firebird/src/frontend/index.js b/plugins/dbgate-plugin-firebird/src/frontend/index.js new file mode 100644 index 000000000..43f80c143 --- /dev/null +++ b/plugins/dbgate-plugin-firebird/src/frontend/index.js @@ -0,0 +1,6 @@ +import driver from './driver'; + +export default { + packageName: 'dbgate-plugin-firebird', + drivers: [driver], +}; diff --git a/plugins/dbgate-plugin-firebird/webpack-backend.config.js b/plugins/dbgate-plugin-firebird/webpack-backend.config.js new file mode 100644 index 000000000..06d31f8c4 --- /dev/null +++ b/plugins/dbgate-plugin-firebird/webpack-backend.config.js @@ -0,0 +1,46 @@ +var webpack = require('webpack'); +var path = require('path'); + +const packageJson = require('./package.json'); +const buildPluginExternals = require('../../common/buildPluginExternals'); +const externals = buildPluginExternals(packageJson); + +var config = { + context: __dirname + '/src/backend', + + entry: { + app: './index.js', + }, + target: 'node', + output: { + path: path.resolve(__dirname, 'dist'), + filename: 'backend.js', + libraryTarget: 'commonjs2', + }, + + // uncomment for disable minimalization + // optimization: { + // minimize: false, + // }, + + plugins: [ + new webpack.IgnorePlugin({ + checkResource(resource) { + const lazyImports = ['pg-native', 'uws']; + if (!lazyImports.includes(resource)) { + return false; + } + try { + require.resolve(resource); + } catch (err) { + return true; + } + return false; + }, + }), + ], + + externals, +}; + +module.exports = config; diff --git a/plugins/dbgate-plugin-firebird/webpack-frontend.config.js b/plugins/dbgate-plugin-firebird/webpack-frontend.config.js new file mode 100644 index 000000000..cbc4a0a5a --- /dev/null +++ b/plugins/dbgate-plugin-firebird/webpack-frontend.config.js @@ -0,0 +1,30 @@ +var webpack = require('webpack'); +var path = require('path'); + +var config = { + context: __dirname + '/src/frontend', + + entry: { + app: './index.js', + }, + target: 'web', + output: { + path: path.resolve(__dirname, 'dist'), + filename: 'frontend.js', + libraryTarget: 'var', + library: 'plugin', + }, + + plugins: [ + new webpack.DefinePlugin({ + 'global.DBGATE_PACKAGES': 'window.DBGATE_PACKAGES', + }), + ], + + // uncomment for disable minimalization + // optimization: { + // minimize: false, + // }, +}; + +module.exports = config; diff --git a/yarn.lock b/yarn.lock index 92632a452..b7614b904 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3310,6 +3310,11 @@ better-sqlite3@11.8.1: bindings "^1.5.0" prebuild-install "^7.1.1" +big-integer@^1.6.51: + version "1.6.52" + resolved "https://registry.yarnpkg.com/big-integer/-/big-integer-1.6.52.tgz#60a887f3047614a8e1bffe5d7173490a97dc8c85" + integrity sha512-QxD8cf2eVqJOOz63z6JIN9BzvVs/dlySa5HGSBH5xtR8dPteIRQnBxxKqkNTiT6jbDTF6jAfrd4oMcND9RGbQg== + big.js@^5.2.2: version "5.2.2" resolved "https://registry.yarnpkg.com/big.js/-/big.js-5.2.2.tgz#65f0af382f578bcdc742bd9c281e9cb2d7768328" @@ -7873,6 +7878,11 @@ long@*, long@^5.2.1, long@~5.2.3: resolved "https://registry.yarnpkg.com/long/-/long-5.2.3.tgz#a3ba97f3877cf1d778eccbcb048525ebb77499e1" integrity sha512-lcHwpNoggQTObv5apGNCTdJrO69eHOZMi4BNC+rTLER8iHAqGrUVeLh/irVIM7zTw2bOXA8T6uNPeujwOLg/2Q== +long@^5.2.3: + version "5.3.2" + resolved "https://registry.yarnpkg.com/long/-/long-5.3.2.tgz#1d84463095999262d7d7b7f8bfd4a8cc55167f83" + integrity sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA== + lru-cache@^10.2.0: version "10.4.3" resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-10.4.3.tgz#410fc8a17b70e598013df257c2446b7f3383f119" @@ -8437,6 +8447,14 @@ node-cron@^2.0.3: opencollective-postinstall "^2.0.0" tz-offset "0.0.1" +node-firebird@^1.1.9: + version "1.1.9" + resolved "https://registry.yarnpkg.com/node-firebird/-/node-firebird-1.1.9.tgz#0e6815b4e209812a4c85b71227e40e268bedeb8b" + integrity sha512-6Ol+Koide1WbfUp4BJ1dSA4wm091jAgCwwSoihxO/RRdcfR+dMVDE9jd2Z2ixjk7q/vSNJUYORXv7jmRfvwdrw== + dependencies: + big-integer "^1.6.51" + long "^5.2.3" + node-gyp@^7.1.0: version "7.1.2" resolved "https://registry.yarnpkg.com/node-gyp/-/node-gyp-7.1.2.tgz#21a810aebb187120251c3bcec979af1587b188ae"