vertica-nodejs
Version:
Vertica client - pure javascript & libpq with the same API
772 lines (666 loc) • 23.9 kB
JavaScript
// Copyright (c) 2022-2024 Open Text.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
var dns = require('dns')
var EventEmitter = require('events').EventEmitter
var util = require('util')
var utils = require('./utils')
var pgPass = require('pgpass')
var TypeOverrides = require('./type-overrides')
var ConnectionParameters = require('./connection-parameters')
var Query = require('./query')
var defaults = require('./defaults')
var Connection = require('./connection')
class Client extends EventEmitter {
constructor(config) {
super()
this.connectionParameters = new ConnectionParameters(config)
this.user = this.connectionParameters.user
this.database = this.connectionParameters.database
this.port = this.connectionParameters.port
this.host = this.connectionParameters.host
this.backup_server_node = this.connectionParameters.backup_server_node
// "hiding" the password so it doesn't show up in stack traces
// or if the client is console.logged
Object.defineProperty(this, 'password', {
configurable: true,
enumerable: false,
writable: true,
value: this.connectionParameters.password,
})
Object.defineProperty(this, 'oauth_access_token', {
configurable: true,
enumerable: false,
writable: true,
value: this.connectionParameters.oauth_access_token,
})
this.protocol_version = this.connectionParameters.protocol_version;
var c = config || {}
this._Promise = c.Promise || global.Promise
this._types = new TypeOverrides(c.types)
this._ending = false
this._connecting = false
this._connected = false
this._connectionError = false
this._queryable = true
this.connection =
c.connection ||
new Connection({
stream: c.stream,
tls_config: this.connectionParameters.tls_config,
tls_mode: this.connectionParameters.tls_mode,
tls_trusted_certs: this.connectionParameters.tls_trusted_certs,
tls_host: this.connectionParameters.host,
keepAlive: c.keepAlive || false,
keepAliveInitialDelayMillis: c.keepAliveInitialDelayMillis || 0,
encoding: this.connectionParameters.client_encoding || 'utf8',
client_label: this.connectionParameters.client_label,
})
this.queryQueue = []
this.processID = null
this.secretKey = null
this.tls_config = this.connectionParameters.tls_config
this.tls_mode = this.connectionParameters.tls_mode || 'disable'
this.tls_trusted_certs = this.connectionParameters.tls_trusted_certs
this._connectionTimeoutMillis = c.connectionTimeoutMillis || 0
this.workload = this.connectionParameters.workload
delete this.connectionParameters.tls_config
delete this.connectionParameters.tls_mode
delete this.connectionParameters.tls_trusted_certs
}
_errorAllQueries(err) {
const enqueueError = (query) => {
process.nextTick(() => {
query.handleError(err, this.connection)
})
}
if (this.activeQuery) {
enqueueError(this.activeQuery)
this.activeQuery = null
}
this.queryQueue.forEach(enqueueError)
this.queryQueue.length = 0
}
_shuffleAddresses(addresses) {
// Use Durstenfeld shuffle because it is not biased
for (var i = addresses.length - 1; i > 0; i--) {
var j = Math.floor(Math.random() * (i + 1))
var temp = addresses[i]
addresses[i] = addresses[j]
addresses[j] = temp
}
}
_resolveHost(node) {
return new this._Promise((resolve, reject) => {
dns.lookup(node.host, { all: true }, (err, addresses) => {
if (err) {
reject(err)
return
}
var resolvedAddresses = addresses
.filter((addr) => addr.family === 4 || addr.family === 6)
.map((addr) => addr.address)
this._shuffleAddresses(addresses)
resolve(resolvedAddresses.map((addr) => { return { host: addr, port: node.port } }))
})
})
}
// Round robin connections iterate through each node in host + backup_server_nodes
// For each node, resolve the host to a list of addresses, shuffle the addresses, then try each address
async _roundRobinConnect(nodes, addresses, error) {
if (addresses.length > 0) {
// There are resolved addresses we haven't tried yet, so try the next address
await this._connectToNextAddress(nodes, addresses)
} else if (nodes.length > 0) {
// There are no more resolved addresses for the current node, so resolve the host for the next node
var node = nodes.shift()
await this._resolveHost(node)
.then((async (shuffled_addresses) => {
if (shuffled_addresses.length > 0) {
await this._connectToNextAddress(nodes, shuffled_addresses)
} else {
var err = new Error("Could not resolve host " + node.host)
await this._roundRobinConnect(nodes, addresses, err)
}
}).bind(this))
.catch((async (err) => {
await this._roundRobinConnect(nodes, addresses, err)
}).bind(this))
} else {
if (!error) {
error = new Error("Fatal error: Node list was empty")
}
// No more nodes to try, so handle connection error
if (this._connecting && !this._connectionError) {
if (this._connectionCallback) {
this._connectionCallback(error)
} else {
this._handleErrorEvent(error)
}
} else if (!this._connectionError) {
this._handleErrorEvent(error)
}
}
}
async _connectToNextAddress(nodes, addresses) {
var self = this
var con = this.connection
this.connectionTimeoutHandle
if (this._connectionTimeoutMillis > 0) {
this.connectionTimeoutHandle = setTimeout(() => {
con._ending = true
con.stream.destroy(new Error('timeout expired'))
}, this._connectionTimeoutMillis)
}
// Dequeue first address from resolved addresses and try connecting to it
var address = addresses.shift()
if (address.host && address.host.indexOf('/') === 0) {
con.connect(address.host + '/.s.PGSQL.' + address.port)
} else {
con.connect(address.port, address.host)
}
// once connection is established send startup message
con.on('connect', function () {
// SSLRequest Message
if (self.tls_mode !== 'disable' || self.tls_config !== undefined) {
con.requestSsl()
} else {
con.startup(self.getStartupConf())
}
})
con.on('sslconnect', function () {
con.startup(self.getStartupConf())
})
this._attachListeners(con)
con.once('end', async () => {
const error = this._ending ? new Error('Connection terminated') : new Error('Connection terminated unexpectedly')
clearTimeout(this.connectionTimeoutHandle)
this._errorAllQueries(error)
if (!this._ending) {
// if the connection is ended without us calling .end()
// on this client then we have an unexpected disconnection
// treat this as an error unless we've already emitted an error
// during connection.
await this._roundRobinConnect(nodes, addresses, error)
}
process.nextTick(() => {
this.emit('end')
})
})
}
async _connect(callback) {
this._connectionCallback = callback
if (this._connecting || this._connected) {
const err = new Error('Client has already been connected. You cannot reuse a client.')
process.nextTick(() => {
callback(err)
})
return
}
this._connecting = true
var nodes = this.backup_server_node
// Add host and port to start of queue of nodes to try connecting to
nodes.unshift({ host: this.host, port: this.port })
await this._roundRobinConnect(nodes, [], undefined)
}
async connect(callback) {
if (callback) {
return this._connect(callback)
}
return new this._Promise((resolve, reject) => {
this._connect((error) => {
if (error) {
reject(error)
} else {
resolve()
}
})
})
}
_attachListeners(con) {
// password request handling
con.on('authenticationCleartextPassword', this._handleAuthCleartextPassword.bind(this))
// password request handling
con.on('authenticationMD5Password', this._handleAuthMD5Password.bind(this))
con.on('authenticationSHA512Password', this._handleAuthSHA512Password.bind(this))
con.on('authenticationOAuthPassword', this._handleOAuthPassword.bind(this))
con.on('backendKeyData', this._handleBackendKeyData.bind(this))
con.on('error', this._handleErrorEvent.bind(this))
con.on('errorMessage', this._handleErrorMessage.bind(this))
con.on('readyForQuery', this._handleReadyForQuery.bind(this))
con.on('notice', this._handleNotice.bind(this))
con.on('rowDescription', this._handleRowDescription.bind(this))
con.on('dataRow', this._handleDataRow.bind(this))
con.on('portalSuspended', this._handlePortalSuspended.bind(this))
con.on('emptyQuery', this._handleEmptyQuery.bind(this))
con.on('commandComplete', this._handleCommandComplete.bind(this))
con.on('parseComplete', this._handleParseComplete.bind(this))
con.on('parameterDescription', this._handleParameterDescription.bind(this))
con.on('parameterStatus', this._handleParameterStatus.bind(this))
con.on('bindComplete', this._handleBindComplete.bind(this))
con.on('copyInResponse', this._handleCopyInResponse.bind(this))
con.on('copyDoneResponse', this._handleCopyDoneResponse.bind(this))
con.on('loadFile', this._handleLoadFile.bind(this))
con.on('writeFile', this._handleWriteFile.bind(this))
con.on('verifyFiles', this._handleVerifyFiles.bind(this))
con.on('endOfBatchResponse', this._handleEndOfBatchResponse.bind(this))
}
// TODO(bmc): deprecate pgpass "built in" integration since this.password can be a function
// it can be supplied by the user if required - this is a breaking change!
_checkPgPass(cb) {
const con = this.connection
if (typeof this.password === 'function') {
this._Promise
.resolve()
.then(() => this.password())
.then((pass) => {
if (pass !== undefined) {
if (typeof pass !== 'string') {
con.emit('error', new TypeError('Password must be a string'))
return
}
this.connectionParameters.password = this.password = pass
} else {
this.connectionParameters.password = this.password = null
}
cb()
})
.catch((err) => {
con.emit('error', err)
})
} else if (this.password !== null) {
cb()
} else {
pgPass(this.connectionParameters, (pass) => {
if (undefined !== pass) {
this.connectionParameters.password = this.password = pass
}
cb()
})
}
}
_handleParameterStatus(msg) {
const min_supported_version = (3 << 16 | 5) // 3.5
const max_supported_version = this.connectionParameters.protocol_version // requested protocol version
switch(msg.parameterName) {
// right now we only care about the protocol_version
// if we want to have the parameterStatus message update any other connection properties, add them here
case 'protocol_version':
// until we allow past 3.0 this won't matter because we are only supporting one protocol version
// with this client right now
if (parseInt(msg.parameterValue) < min_supported_version
|| parseInt(msg.parameterValue) > max_supported_version) {
// error
throw new Error("Unsupported Protocol Version returned by Server. Connection Disallowed.");
}
this.protocol_version = parseInt(msg.parameterValue) // effective protocol version
break;
default:
// do nothing
}
}
_handleBindComplete(msg) {
const activeQuery = this.activeQuery
activeQuery.handleBindComplete(this.connection)
}
_handleAuthCleartextPassword(msg) {
this._checkPgPass(() => {
this.connection.password(this.password)
})
}
_handleAuthMD5Password(msg) {
this._checkPgPass(() => {
const hashedPassword = utils.postgresMd5PasswordHash(this.user, this.password, msg.salt)
this.connection.password(hashedPassword)
})
}
_handleAuthSHA512Password(msg) {
this._checkPgPass(() => {
const hashedPassword = utils.postgresSha512PasswordHash(this.password, msg.salt, msg.userSalt)
this.connection.password(hashedPassword)
})
}
_handleOAuthPassword(msg) {
this.connection.password(this.oauth_access_token)
}
_handleBackendKeyData(msg) {
this.processID = msg.processID
this.secretKey = msg.secretKey
}
_handleReadyForQuery(msg) {
if (this._connecting) {
this._connecting = false
this._connected = true
clearTimeout(this.connectionTimeoutHandle)
// process possible callback argument to Client#connect
if (this._connectionCallback) {
this._connectionCallback(null, this)
// remove callback for proper error handling
// after the connect event
this._connectionCallback = null
}
this.emit('connect')
}
const { activeQuery } = this
this.activeQuery = null
this.readyForQuery = true
if (activeQuery) {
activeQuery.handleReadyForQuery(this.connection)
}
this._pulseQueryQueue()
}
// if we receieve an error event or error message
// during the connection process we handle it here
_handleErrorWhileConnecting(err) {
if (this._connectionError) {
// TODO(bmc): this is swallowing errors - we shouldn't do this
return
}
this._connectionError = true
clearTimeout(this.connectionTimeoutHandle)
if (this._connectionCallback) {
return this._connectionCallback(err)
}
this.emit('error', err)
}
// if we're connected and we receive an error event from the connection
// this means the socket is dead - do a hard abort of all queries and emit
// the socket error on the client as well
_handleErrorEvent(err) {
if (this._connecting) {
return this._handleErrorWhileConnecting(err)
}
this._queryable = false
this._errorAllQueries(err)
this.emit('error', err)
}
_handleErrorMessage(msg) {
if (this._connecting) {
return this._handleErrorWhileConnecting(msg)
}
const activeQuery = this.activeQuery
if (!activeQuery) {
this._handleErrorEvent(msg)
return
}
this.activeQuery = null
activeQuery.handleError(msg, this.connection)
}
_handleRowDescription(msg) {
// delegate rowDescription to active query
this.activeQuery.handleRowDescription(msg)
}
_handleDataRow(msg) {
// delegate dataRow to active query
this.activeQuery.handleDataRow(msg)
}
_handlePortalSuspended(msg) {
// [VERTICA specific] PortalSuspended replaced CommandComplete to indicate completion of the source SQL command
this.activeQuery.handlePortalSuspended(msg, this.connection)
}
_handleParameterDescription(msg) {
// delegate parameterDescription to active query
this.activeQuery.handleParameterDescription(msg, this.connection)
}
_handleEmptyQuery(msg) {
// delegate emptyQuery to active query
this.activeQuery.handleEmptyQuery(this.connection)
}
_handleCommandComplete(msg) {
// delegate commandComplete to active query
this.activeQuery.handleCommandComplete(msg, this.connection)
}
_handleParseComplete(msg) {
// if a prepared statement has a name and properly parses
// we track that its already been executed so we don't parse
// it again on the same client
if (this.activeQuery.name) {
this.connection.parsedStatements[this.activeQuery.name] = this.activeQuery.text
}
}
_handleCopyInResponse(msg) {
this.activeQuery.handleCopyInResponse(this.connection)
}
_handleCopyDoneResponse(msg) {
this.activeQuery._handleCopyDoneResponse(msg, this.connection)
}
_handleLoadFile(msg) {
this.activeQuery.handleLoadFile(msg, this.connection)
}
_handleWriteFile(msg) {
this.activeQuery.handleWriteFile(msg, this.connection)
}
_handleVerifyFiles(msg) {
this.activeQuery.handleVerifyFiles(msg, this.connection, this.protocol_version)
}
_handleEndOfBatchResponse() {
this.activeQuery.handleEndOfBatchResponse(this.connection)
}
_handleNotice(msg) {
this.emit('notice', msg)
}
getStartupConf() {
var params = this.connectionParameters
var data = {
user: params.user,
database: params.database,
protocol_version: params.protocol_version.toString(),
client_type: params.client_type,
client_version: params.client_version,
client_os: params.client_os,
client_os_user_name: params.client_os_user_name,
client_os_hostname: params.client_os_hostname,
client_pid: params.client_pid,
binary_data_protocol: '0', // Defaults to text format '0'
protocol_compat: 'VER',
}
if (params.replication) {
data.replication = '' + params.replication
}
if (params.statement_timeout) {
data.statement_timeout = String(parseInt(params.statement_timeout, 10))
}
if (params.idle_in_transaction_session_timeout) {
data.idle_in_transaction_session_timeout = String(parseInt(params.idle_in_transaction_session_timeout, 10))
}
if (params.options) {
data.options = params.options
}
if (params.client_label) {
data.client_label = params.client_label
}
if (params.workload) {
data.workload = params.workload
}
if (params.oauth_access_token) {
data.auth_category = 'OAuth'
} else if (params.password) {
data.auth_category = 'User'
}
return data
}
cancel(client, query) {
if (client.activeQuery === query) {
var con = this.connection
if (this.host && this.host.indexOf('/') === 0) {
con.connect(this.host + '/.s.PGSQL.' + this.port)
} else {
con.connect(this.port, this.host)
}
// once connection is established send cancel message
con.on('connect', function () {
con.cancel(client.processID, client.secretKey)
})
} else if (client.queryQueue.indexOf(query) !== -1) {
client.queryQueue.splice(client.queryQueue.indexOf(query), 1)
}
}
setTypeParser(oid, format, parseFn) {
return this._types.setTypeParser(oid, format, parseFn)
}
getTypeParser(oid, format) {
return this._types.getTypeParser(oid, format)
}
// Ported from PostgreSQL 9.2.4 source code in src/interfaces/libpq/fe-exec.c
escapeIdentifier(str) {
return '"' + str.replace(/"/g, '""') + '"'
}
// Ported from PostgreSQL 9.2.4 source code in src/interfaces/libpq/fe-exec.c
escapeLiteral(str) {
var hasBackslash = false
var escaped = "'"
for (var i = 0; i < str.length; i++) {
var c = str[i]
if (c === "'") {
escaped += c + c
} else if (c === '\\') {
escaped += c + c
hasBackslash = true
} else {
escaped += c
}
}
escaped += "'"
if (hasBackslash === true) {
escaped = ' E' + escaped
}
return escaped
}
_pulseQueryQueue() {
if (this.readyForQuery === true) {
this.activeQuery = this.queryQueue.shift()
if (this.activeQuery) {
this.readyForQuery = false
this.hasExecuted = true
const queryError = this.activeQuery.submit(this.connection)
if (queryError) {
process.nextTick(() => {
this.activeQuery.handleError(queryError, this.connection, true)
this.readyForQuery = true
this._pulseQueryQueue()
})
}
} else if (this.hasExecuted) {
this.activeQuery = null
this.emit('drain')
}
}
}
// todo - refactor to improve readibility. Move out logic for identifying parameter types to helper function if possible
query(config, values, callback) {
// can take in strings, config object or query object
let query
let result
let readTimeout
let readTimeoutTimer
let queryCallback
if (config === null || config === undefined) {
throw new TypeError('Client was passed a null or undefined query')
}
if (typeof config.submit === 'function') {
readTimeout = config.query_timeout || this.connectionParameters.query_timeout
result = query = config
if (typeof values === 'function') {
query.callback = query.callback || values
}
} else { // config is a string
readTimeout = this.connectionParameters.query_timeout
query = new Query(config, values, callback)
if (!query.callback) {
result = new this._Promise((resolve, reject) => {
query.callback = (err, res) => (err ? reject(err) : resolve(res))
})
}
}
if (readTimeout) {
queryCallback = query.callback
readTimeoutTimer = setTimeout(() => {
var error = new Error('Query read timeout')
process.nextTick(() => {
query.handleError(error, this.connection)
})
queryCallback(error)
// we already returned an error,
// just do nothing if query completes
query.callback = () => {}
// Remove from queue
var index = this.queryQueue.indexOf(query)
if (index > -1) {
this.queryQueue.splice(index, 1)
}
this._pulseQueryQueue()
}, readTimeout)
query.callback = (err, res) => {
clearTimeout(readTimeoutTimer)
queryCallback(err, res)
}
}
const binary = this.connectionParameters.binary || defaults.binary
if (binary && !query.binary) {
query.binary = true
}
if (query._result && !query._result._types) {
query._result._types = this._types
}
if (!this._queryable) {
process.nextTick(() => {
query.handleError(new Error('Client has encountered a connection error and is not queryable'), this.connection)
})
return result
}
if (this._ending) {
process.nextTick(() => {
query.handleError(new Error('Client was closed and is not queryable'), this.connection)
})
return result
}
this.queryQueue.push(query)
this._pulseQueryQueue()
return result
}
ref() {
this.connection.ref()
}
unref() {
this.connection.unref()
}
end(cb) {
this._ending = true
// if we have never connected, then end is a noop, callback immediately
if (!this._connecting && !this.connection._connecting) {
if (cb) {
cb()
} else {
return this._Promise.resolve()
}
}
if (this.activeQuery || !this._queryable) {
// if we have an active query we need to force a disconnect
// on the socket - otherwise a hung query could block end forever
this.connection.stream.destroy()
} else {
this.connection.end()
}
if (cb) {
this.connection.once('end', cb)
} else {
return new this._Promise((resolve) => {
this.connection.once('end', resolve)
})
}
}
}
// expose a Query constructor
Client.Query = Query
module.exports = Client