UNPKG

vertica-nodejs

Version:

Vertica client - pure javascript & libpq with the same API

772 lines (666 loc) 23.9 kB
// 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. 'use strict' 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