2021-09-21 13:48:28 +00:00
import { Knex , knex } from 'knex' ;
2022-03-10 06:59:48 +00:00
import {
2022-01-24 13:59:21 +00:00
ConnectionTestResult ,
QueryError ,
QueryResult ,
QueryService ,
2025-05-27 11:23:22 +00:00
cacheConnectionWithConfiguration ,
generateSourceOptionsHash ,
2022-03-10 06:59:48 +00:00
getCachedConnection ,
2026-03-04 14:02:05 +00:00
User ,
App ,
2022-01-24 13:59:21 +00:00
} from '@tooljet-plugins/common' ;
2022-03-10 06:59:48 +00:00
import { SourceOptions , QueryOptions } from './types' ;
2024-10-28 17:56:26 +00:00
import { isEmpty } from '@tooljet-plugins/common' ;
2026-03-04 14:02:05 +00:00
import { Client } from 'ssh2' ;
import net from 'net' ;
interface SSHTunnel {
client : Client ;
server : net.Server ;
localPort : number ;
}
2024-10-28 17:56:26 +00:00
2025-02-25 06:52:50 +00:00
const recognizedBooleans = {
true : true ,
false : false ,
} ;
function interpretValue ( value : string ) : string | boolean | number {
return recognizedBooleans [ value . toLowerCase ( ) ] ? ? ( ! isNaN ( Number . parseInt ( value ) ) ? Number . parseInt ( value ) : value ) ;
}
2026-03-04 14:02:05 +00:00
function createSSHTunnel ( sourceOptions : SourceOptions ) : Promise < SSHTunnel > {
return new Promise ( ( resolve , reject ) = > {
const sshClient = new Client ( ) ;
sshClient . on ( 'ready' , ( ) = > {
const server = net . createServer ( socket = > {
sshClient . forwardOut (
socket . remoteAddress || '127.0.0.1' ,
socket . remotePort || 0 ,
sourceOptions . host ,
Number ( sourceOptions . port ) ,
( err , stream ) = > {
if ( err ) {
socket . destroy ( ) ;
return ;
}
socket . pipe ( stream ) ;
stream . pipe ( socket ) ;
}
) ;
} ) ;
server . on ( 'error' , err = > {
sshClient . end ( ) ;
reject ( err ) ;
} ) ;
server . listen ( 0 , '127.0.0.1' , ( ) = > {
const { port } = server . address ( ) as net . AddressInfo ;
resolve ( { client : sshClient , server , localPort : port } ) ;
} ) ;
} ) ;
sshClient . on ( 'error' , reject ) ;
sshClient . connect ( {
host : sourceOptions.ssh_host ,
port : sourceOptions.ssh_port || 22 ,
username : sourceOptions.ssh_username ,
readyTimeout : 20000 ,
keepaliveInterval : 10000 ,
. . . ( sourceOptions . ssh_auth_type === 'password'
? { password : sourceOptions.ssh_password }
: {
privateKey : sourceOptions.ssh_private_key ,
. . . ( sourceOptions . ssh_passphrase
? { passphrase : sourceOptions.ssh_passphrase }
: { } ) ,
} ) ,
} ) ;
} ) ;
}
2021-07-17 07:11:03 +00:00
export default class MssqlQueryService implements QueryService {
2022-01-17 07:08:17 +00:00
private static _instance : MssqlQueryService ;
2024-11-15 09:41:50 +00:00
private STATEMENT_TIMEOUT ;
2022-01-17 07:08:17 +00:00
constructor ( ) {
2024-11-15 09:41:50 +00:00
this . STATEMENT_TIMEOUT =
process . env ? . PLUGINS_SQL_DB_STATEMENT_TIMEOUT && ! isNaN ( Number ( process . env ? . PLUGINS_SQL_DB_STATEMENT_TIMEOUT ) )
? Number ( process . env . PLUGINS_SQL_DB_STATEMENT_TIMEOUT )
: 120000 ;
2025-02-25 06:52:50 +00:00
if ( ! MssqlQueryService . _instance ) {
MssqlQueryService . _instance = this ;
2026-03-04 14:02:05 +00:00
process . on ( 'uncaughtException' , ( err : any ) = > {
if ( err ? . code === 'ESOCKET' ) return ;
throw err ;
} ) ;
2022-01-17 07:08:17 +00:00
}
return MssqlQueryService . _instance ;
}
2025-02-25 06:52:50 +00:00
sanitizeOptions ( options : string [ ] [ ] ) {
const _connectionOptions = ( options || [ ] )
. filter ( ( o ) = > o . every ( ( e ) = > ! ! e ) )
. map ( ( [ key , value ] ) = > [ key , interpretValue ( value ) ] ) ;
2024-10-28 17:56:26 +00:00
2025-02-25 06:52:50 +00:00
return Object . fromEntries ( _connectionOptions ) ;
2024-10-28 17:56:26 +00:00
}
2021-09-21 13:48:28 +00:00
async run (
2022-01-24 13:59:21 +00:00
sourceOptions : SourceOptions ,
queryOptions : QueryOptions ,
2021-09-21 13:48:28 +00:00
dataSourceId : string ,
dataSourceUpdatedAt : string
) : Promise < QueryResult > {
2026-03-16 16:34:01 +00:00
let knexInstance : Knex | undefined ;
let checkCache : boolean ;
// Dynamic connection parameters
if ( sourceOptions . allow_dynamic_connection_parameters ) {
if ( sourceOptions . connection_type === 'manual' ) {
sourceOptions . host = queryOptions [ 'host' ] ? queryOptions [ 'host' ] : sourceOptions . host ;
sourceOptions . database = queryOptions [ 'database' ] ? queryOptions [ 'database' ] : sourceOptions . database ;
} else if ( sourceOptions . connection_type === 'string' ) {
if ( queryOptions [ 'host' ] ) sourceOptions . host = queryOptions [ 'host' ] ;
if ( queryOptions [ 'database' ] ) sourceOptions . database = queryOptions [ 'database' ] ;
}
}
checkCache = sourceOptions . allow_dynamic_connection_parameters ? false : true ;
2021-07-17 07:59:28 +00:00
try {
2026-03-16 16:34:01 +00:00
knexInstance = await this . getConnection ( sourceOptions , { } , checkCache , dataSourceId , dataSourceUpdatedAt ) ;
2024-10-28 17:56:26 +00:00
switch ( queryOptions . mode ) {
case 'sql' :
return await this . handleRawQuery ( knexInstance , queryOptions ) ;
case 'gui' :
return await this . handleGuiQuery ( knexInstance , queryOptions ) ;
default :
throw new Error ( "Invalid query mode. Must be either 'sql' or 'gui'." ) ;
2022-05-24 06:16:45 +00:00
}
2021-07-17 07:59:28 +00:00
} catch ( err ) {
2026-03-04 14:02:05 +00:00
const errorMessage = err instanceof Error ? err ? . message : 'An unknown error occurred' ;
2024-11-20 18:53:41 +00:00
const errorDetails : any = { } ;
if ( err && err instanceof Error ) {
const msSqlError = err as any ;
const { code , severity , state , number , lineNumber , serverName , class : errorClass } = msSqlError ;
errorDetails . code = code || null ;
errorDetails . severity = severity || null ;
errorDetails . state = state || null ;
errorDetails . number = number || null ;
errorDetails . lineNumber = lineNumber || null ;
errorDetails . serverName = serverName || null ;
errorDetails . class = errorClass || null ;
}
throw new QueryError ( 'Query could not be completed' , errorMessage , errorDetails ) ;
2024-10-28 17:56:26 +00:00
}
}
private async handleGuiQuery ( knexInstance : Knex , queryOptions : QueryOptions ) : Promise < any > {
if ( queryOptions . operation !== 'bulk_update_pkey' ) {
return { rows : [ ] } ;
2021-07-17 07:59:28 +00:00
}
2024-10-28 17:56:26 +00:00
const query = this . buildBulkUpdateQuery ( queryOptions ) ;
return await this . executeQuery ( knexInstance , query ) ;
}
private async handleRawQuery ( knexInstance : Knex , queryOptions : QueryOptions ) : Promise < QueryResult > {
const { query , query_params } = queryOptions ;
const queryParams = query_params || [ ] ;
const sanitizedQueryParams : Record < string , any > = Object . fromEntries ( queryParams . filter ( ( [ key ] ) = > ! isEmpty ( key ) ) ) ;
const result = await this . executeQuery ( knexInstance , query , sanitizedQueryParams ) ;
2021-09-21 13:48:28 +00:00
return { status : 'ok' , data : result } ;
2021-07-17 07:59:28 +00:00
}
2024-10-28 17:56:26 +00:00
private async executeQuery ( knexInstance : Knex , query : string , sanitizedQueryParams : Record < string , any > = { } ) {
if ( isEmpty ( query ) ) throw new Error ( 'Query is empty' ) ;
2024-11-15 09:41:50 +00:00
const result = await knexInstance . raw ( query , sanitizedQueryParams ) . timeout ( this . STATEMENT_TIMEOUT ) ;
2024-10-28 17:56:26 +00:00
return result ;
}
2022-01-24 13:59:21 +00:00
async testConnection ( sourceOptions : SourceOptions ) : Promise < ConnectionTestResult > {
2026-03-04 14:02:05 +00:00
let knexInstance ;
try {
knexInstance = await this . getConnection ( sourceOptions , { } , false ) ;
await knexInstance . raw ( 'select @@version;' ) . timeout ( this . STATEMENT_TIMEOUT ) ;
try { await knexInstance . destroy ( ) ; } catch ( _ ) { } ;
return {
status : 'ok' ,
} ;
} catch ( err : any ) {
let message = 'Connection test failed' ;
let details : any = { } ;
2021-07-18 06:58:05 +00:00
2026-03-04 14:02:05 +00:00
if ( err ? . code || err ? . number || err ? . serverName ) {
message = err . message ;
details = {
code : err.code ? ? null ,
severity : err.severity ? ? null ,
state : err.state ? ? null ,
number : err . number ? ? null ,
lineNumber : err.lineNumber ? ? null ,
serverName : err.serverName ? ? null ,
class : err . class ? ? null ,
} ;
}
else if ( err ? . code === 'ESOCKET' || err ? . code === 'ECONNREFUSED' || err ? . code === 'ETIMEDOUT' ) {
message = ` Network error: ${ err . message } ` ;
}
else if ( err ? . code === 'ELOGIN' ) {
message = ` Authentication failed: ${ err . message } ` ;
}
else if ( err ? . message ? . includes ( 'SSH' ) ) {
message = ` SSH connection failed: ${ err . message } ` ;
}
else if ( err ? . name === 'KnexTimeoutError' ) {
message = 'Database connection timeout. Please check host/port/firewall' ;
}
else if ( err ? . message ) {
message = err . message ;
}
throw new QueryError ( 'Connection test failed' , message , details ) ;
} finally {
if ( knexInstance ) {
try { await knexInstance . destroy ( ) ; } catch ( _ ) { }
}
}
2021-07-18 06:58:05 +00:00
}
2026-03-04 14:02:05 +00:00
private parseConnectionString ( connectionString : string ) : Partial < SourceOptions > {
const parsed : Partial < SourceOptions > = { } ;
if ( ! connectionString ) return parsed ;
const trimmed = connectionString . trim ( ) ;
const withoutScheme = /^sqlserver:\/\//i . test ( trimmed )
? trimmed . replace ( /^sqlserver:\/\//i , '' )
: trimmed ;
const looksLikeHybrid = withoutScheme . includes ( ';' ) &&
! /^[a-z ]+=/i . test ( withoutScheme . split ( ';' ) [ 0 ] ) ;
if ( looksLikeHybrid ) {
const firstSemi = withoutScheme . indexOf ( ';' ) ;
const hostSegment = withoutScheme . slice ( 0 , firstSemi ) ;
const rest = withoutScheme . slice ( firstSemi + 1 ) ;
const hostMatch = hostSegment . match ( /^([^:\\,]+)(?::(\d+))?(?:\\([^,]*))?(?:,(\d+))?/ ) ;
if ( hostMatch ) {
if ( hostMatch [ 1 ] ) parsed . host = hostMatch [ 1 ] . trim ( ) ;
if ( hostMatch [ 2 ] ) parsed . port = ( parseInt ( hostMatch [ 2 ] , 10 ) ) ;
if ( hostMatch [ 3 ] ) parsed . instanceName = hostMatch [ 3 ] . trim ( ) ;
if ( hostMatch [ 4 ] ) parsed . port = ( parseInt ( hostMatch [ 4 ] , 10 ) ) ;
}
rest . split ( ';' ) . forEach ( pair = > {
if ( ! pair . includes ( '=' ) ) return ;
const [ key , . . . valueParts ] = pair . split ( '=' ) ;
const value = valueParts . join ( '=' ) . trim ( ) ;
const lowerKey = key . trim ( ) . toLowerCase ( ) ;
if ( lowerKey === 'database' || lowerKey === 'initial catalog' ) {
parsed . database = value ;
} else if ( lowerKey === 'user id' || lowerKey === 'uid' || lowerKey === 'user' ) {
parsed . username = value ;
} else if ( lowerKey === 'password' || lowerKey === 'pwd' ) {
parsed . password = value ;
} else if ( lowerKey === 'encrypt' ) {
parsed . azure = [ 'true' , '1' , 'yes' ] . includes ( value . toLowerCase ( ) ) as any ;
} else if ( lowerKey === 'port' ) {
parsed . port = ( parseInt ( value , 10 ) ) ;
} else if ( lowerKey === 'instance' || lowerKey === 'instance name' ) {
parsed . instanceName = value ;
}
} ) ;
}
return parsed ;
}
2024-10-28 17:56:26 +00:00
async buildConnection ( sourceOptions : SourceOptions ) : Promise < Knex > {
2026-03-26 12:08:09 +00:00
const finalOptions : SourceOptions = sourceOptions ;
2026-03-16 16:34:01 +00:00
// SSL config
const shouldUseSSL = finalOptions . ssl_enabled === true ;
let sslObject : any = null ;
if ( shouldUseSSL ) {
if ( finalOptions . ssl_certificate === 'ca_certificate' ) {
sslObject = {
rejectUnauthorized : true ,
ca : finalOptions.ca_cert ,
key : finalOptions.client_key ,
cert : finalOptions.client_cert ,
} ;
} else if ( finalOptions . ssl_certificate === 'self_signed' ) {
sslObject = {
rejectUnauthorized : false ,
ca : finalOptions.root_cert ,
key : finalOptions.client_key ,
cert : finalOptions.client_cert ,
} ;
} else {
sslObject = { rejectUnauthorized : false } ;
}
}
2026-03-04 14:02:05 +00:00
let tunnel : SSHTunnel | null = null ;
let host : string ;
let port : number ;
if ( sourceOptions . ssh_enabled == 'enabled' ) {
tunnel = await createSSHTunnel ( sourceOptions ) ;
host = '127.0.0.1' ;
port = tunnel . localPort ;
} else {
host = finalOptions . host ;
port = + finalOptions . port ;
}
2026-04-02 15:35:27 +00:00
// Service Principal (Azure AD) authentication
const isServicePrincipal = finalOptions . auth_type === 'service_principal' ;
2021-07-17 07:11:03 +00:00
const config : Knex.Config = {
client : 'mssql' ,
connection : {
2026-04-02 15:35:27 +00:00
. . . ( isServicePrincipal
? {
// Knex mssql dialect builds the tedious auth block from these FLAT fields.
// It ignores any nested 'authentication' object entirely.
server : host ,
type : 'azure-active-directory-service-principal-secret' ,
tenantId : finalOptions.sp_tenant_id ,
clientId : finalOptions.sp_client_id ,
clientSecret : finalOptions.sp_client_secret ,
}
: {
host : host ,
user : finalOptions.username ,
password : finalOptions.password ,
} ) ,
2026-03-04 14:02:05 +00:00
database : finalOptions.database ,
port : port ,
2022-03-09 06:57:09 +00:00
options : {
2026-04-02 15:35:27 +00:00
encrypt : isServicePrincipal ? true : ( ( finalOptions . azure ? ? false ) || shouldUseSSL ) ,
2026-03-04 14:02:05 +00:00
instanceName : finalOptions.instanceName ,
2026-04-02 15:35:27 +00:00
trustServerCertificate : ! isServicePrincipal && shouldUseSSL && finalOptions . ssl_certificate === 'none' ,
2026-03-23 19:30:19 +00:00
requestTimeout : this.STATEMENT_TIMEOUT ,
2026-04-02 15:35:27 +00:00
. . . ( shouldUseSSL && ! isServicePrincipal ? { cryptoCredentialsDetails : sslObject } : { } ) ,
2026-03-04 14:02:05 +00:00
. . . ( finalOptions . connection_options && this . sanitizeOptions ( finalOptions . connection_options ) ) ,
} ,
} ,
pool : {
min : 0 ,
afterCreate : ( conn : any , done : ( err : Error | null , conn : any ) = > void ) = > {
conn . on ( 'error' , ( _err : Error ) = > { } ) ;
done ( null , conn ) ;
2022-03-14 09:35:02 +00:00
} ,
} ,
2021-07-17 07:11:03 +00:00
} ;
2021-08-03 05:07:35 +00:00
2026-03-04 14:02:05 +00:00
const knexInstance = knex ( config ) ;
if ( tunnel ) {
const originalDestroy = knexInstance . destroy . bind ( knexInstance ) ;
Object . defineProperty ( knexInstance , 'destroy' , {
value : async function ( ) {
try {
await originalDestroy ( ) ;
} finally {
tunnel . server . close ( ) ;
tunnel . client . end ( ) ;
}
} ,
} ) ;
}
return knexInstance ;
2021-07-17 07:11:03 +00:00
}
2021-08-03 05:07:35 +00:00
2021-09-21 13:48:28 +00:00
async getConnection (
2022-01-24 13:59:21 +00:00
sourceOptions : SourceOptions ,
2021-09-21 13:48:28 +00:00
options : any ,
checkCache : boolean ,
dataSourceId? : string ,
dataSourceUpdatedAt? : string
2024-10-28 17:56:26 +00:00
) : Promise < Knex > {
2021-09-21 13:48:28 +00:00
if ( checkCache ) {
2025-05-27 11:23:22 +00:00
const optionsHash = generateSourceOptionsHash ( sourceOptions ) ;
const enhancedCacheKey = ` ${ dataSourceId } _ ${ optionsHash } ` ;
let connection = await getCachedConnection ( enhancedCacheKey , dataSourceUpdatedAt ) ;
2021-08-03 05:07:35 +00:00
2021-09-21 13:48:28 +00:00
if ( connection ) {
2021-08-03 05:07:35 +00:00
return connection ;
} else {
connection = await this . buildConnection ( sourceOptions ) ;
2025-05-27 11:23:22 +00:00
cacheConnectionWithConfiguration ( dataSourceId , enhancedCacheKey , connection ) ;
2021-08-03 05:07:35 +00:00
return connection ;
}
} else {
return await this . buildConnection ( sourceOptions ) ;
}
}
2022-05-24 06:16:45 +00:00
buildBulkUpdateQuery ( queryOptions : QueryOptions ) : string {
let queryText = '' ;
const { table , primary_key_column , records } = queryOptions ;
for ( const record of records ) {
const primaryKeyValue =
typeof record [ primary_key_column ] === 'string' ? ` ' ${ record [ primary_key_column ] } ' ` : record [ primary_key_column ] ;
queryText = ` ${ queryText } UPDATE ${ table } SET ` ;
for ( const key of Object . keys ( record ) ) {
if ( key !== primary_key_column ) {
queryText = ` ${ queryText } ${ key } = ' ${ record [ key ] } ', ` ;
}
}
queryText = queryText . slice ( 0 , - 1 ) ;
queryText = ` ${ queryText } WHERE ${ primary_key_column } = ${ primaryKeyValue } ; ` ;
}
return queryText . trim ( ) ;
}
2026-03-04 14:02:05 +00:00
async listTables (
2026-03-19 16:14:00 +00:00
sourceOptions : SourceOptions ,
queryOptions ? : { search? : string ; page? : number ; limit? : number }
2026-03-04 14:02:05 +00:00
) : Promise < QueryResult > {
let knexInstance ;
try {
knexInstance = await this . buildConnection ( sourceOptions ) ;
2026-03-19 16:14:00 +00:00
const search = queryOptions ? . search || '' ;
const searchPattern = ` % ${ search } % ` ;
const db = sourceOptions . database ;
if ( queryOptions ? . limit ) {
const limit = queryOptions . limit ;
const page = queryOptions . page || 1 ;
const offset = ( page - 1 ) * limit ;
const [ dataResult , countResult ] = await Promise . all ( [
knexInstance
. raw (
` SELECT TABLE_NAME FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_TYPE = 'BASE TABLE' AND TABLE_CATALOG = ? AND TABLE_NAME LIKE ? ORDER BY TABLE_NAME OFFSET ? ROWS FETCH NEXT ? ROWS ONLY ` ,
[ db , searchPattern , offset , limit ]
)
. timeout ( this . STATEMENT_TIMEOUT ) ,
knexInstance
. raw (
` SELECT COUNT(*) AS total FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_TYPE = 'BASE TABLE' AND TABLE_CATALOG = ? AND TABLE_NAME LIKE ? ` ,
[ db , searchPattern ]
)
. timeout ( this . STATEMENT_TIMEOUT ) ,
] ) ;
const rows = dataResult . map ( ( row : any ) = > ( { label : row.TABLE_NAME , value : row.TABLE_NAME } ) ) ;
const totalCount = parseInt ( countResult ? . [ 0 ] ? . total ? ? '0' , 10 ) ;
return { status : 'ok' , data : { rows , totalCount } } ;
}
2026-03-04 14:02:05 +00:00
const result = await knexInstance
2026-03-19 16:14:00 +00:00
. raw (
` SELECT TABLE_NAME FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_TYPE = 'BASE TABLE' AND TABLE_CATALOG = ? AND TABLE_NAME LIKE ? ORDER BY TABLE_NAME ` ,
[ db , searchPattern ]
)
2026-03-04 14:02:05 +00:00
. timeout ( this . STATEMENT_TIMEOUT ) ;
const tables = result . map ( ( row : any ) = > ( {
label : row.TABLE_NAME ,
value : row.TABLE_NAME ,
} ) ) ;
2026-03-19 16:14:00 +00:00
return { status : 'ok' , data : tables } ;
2026-03-04 14:02:05 +00:00
} catch ( err ) {
const errorMessage = err instanceof Error ? err ? . message : 'An unknown error occurred' ;
throw new QueryError ( 'Could not fetch tables' , errorMessage , { } ) ;
} finally {
if ( knexInstance ) {
await knexInstance . destroy ( ) ;
}
}
}
async invokeMethod (
methodName : string ,
context : { user? : User ; app? : App } ,
sourceOptions : SourceOptions ,
args? : any
) : Promise < any > {
try {
if ( methodName === 'getTables' ) {
2026-03-19 16:14:00 +00:00
const isPaginated = ! ! args ? . limit ;
const result = await this . listTables ( sourceOptions , {
search : args?.search ,
page : args?.page ,
limit : args?.limit ,
} ) ;
const payload = ( result as any ) ? . data ? ? [ ] ;
if ( isPaginated ) {
const rows = ( payload as any ) ? . rows ? ? [ ] ;
const totalCount = ( payload as any ) ? . totalCount ? ? 0 ;
return { items : rows , totalCount } ;
}
return { status : 'ok' , data : Array.isArray ( payload ) ? payload : [ ] } ;
2026-03-04 14:02:05 +00:00
}
throw new QueryError (
'Method not found' ,
` Method ${ methodName } is not supported for MSSQL plugin ` ,
{
availableMethods : [ 'getTables' ] ,
}
) ;
} catch ( err ) {
if ( err instanceof QueryError ) {
throw err ;
}
const errorMessage = err instanceof Error ? err ? . message : 'An unknown error occurred' ;
const errorDetails : any = { } ;
if ( err && err instanceof Error ) {
const msSqlError = err as any ;
const { code , severity , state , number , lineNumber , serverName , class : errorClass } = msSqlError ;
errorDetails . code = code || null ;
errorDetails . severity = severity || null ;
errorDetails . state = state || null ;
errorDetails . number = number || null ;
errorDetails . lineNumber = lineNumber || null ;
errorDetails . serverName = serverName || null ;
errorDetails . class = errorClass || null ;
}
throw new QueryError ( 'Method invocation failed' , errorMessage , errorDetails ) ;
}
}
2021-07-17 07:11:03 +00:00
}