UNPKG

msnodesqlv8

Version:

Microsoft Driver for Node.js SQL Server compatible with all versions of Node.

536 lines (485 loc) 17.7 kB
/** * Created by Stephen on 28/06/2017. */ // the main work horse that manages a query from start to finish by interacting with the c++ 'use strict' const { BasePromises } = require('./base-promises') const { logger } = require('./logger') class DriverRead { constructor (cppDriver, queue, version) { this.native = cppDriver this.workQueue = queue this.useUTC = true this.version = version } setUseUTC (utc) { this.useUTC = utc } // invokeObject.begin(queryId, query, params, onInvoke) getQuery (notify, query, params, invokeObject, callback) { const q = new Query(this.native, this.version, this.useUTC, notify, this.workQueue, query, params, invokeObject, callback) notify.setQueryWorker(q) return q } } class Query extends BasePromises { constructor (native, version, useUTC, notify, queue, query, params, queryHandler, callback) { super() this.version = version this.native = native this.useUTC = useUTC this.notify = notify this.queue = queue this.query = query this.params = params this.queryHandler = queryHandler this.callback = callback this.meta = null this.rows = [] this.outputParams = [] this.rowCount = 0 this.queryId = notify.getQueryId() this.queryRowIndex = 0 this.batchRowIndex = 0 this.batchData = null this.running = true this.paused = false this.done = false this.infoFromNextResult = false this.rowBatchSize = 50 /* ignored for prepared statements */ this.currentQueryResult = null this.cancelled = false this.timeoutTriggered = false this.context = '' // Setup timeout handling based on platform and driver version this.setupTimeoutHandling() } isInfo (err) { return err?.sqlstate && err.sqlstate.length >= 2 && err.sqlstate.substring(0, 2) === '01' } /* route non-critical info messages to its own event to prevent streams based readers from halting */ routeStatementError (errorsAndInfo, callback, notify) { if (!Array.isArray(errorsAndInfo)) { errorsAndInfo = [errorsAndInfo] } let i = 0 const onlyErrors = errorsAndInfo.reduce((agg, latest) => { if (!this.isInfo(latest)) { agg.push(latest) } return agg }, []) const errorCount = onlyErrors.length errorsAndInfo.forEach(err => { const info = this.isInfo(err) if (callback && !info) { const more = i < errorsAndInfo.length - 1 callback(err, null, more) } else { const ev = info ? 'info' : 'error' if (notify) { const more = i < errorCount - 1 if (notify.listenerCount(ev) > 0) { notify.emit(ev, err, more) } } else { throw new Error(err) } } ++i }) } async nativeGetRows (queryId, rowBatchSize) { logger.debugLazy(() => `queue op to native::fetchRows ${this.queryId}`, this.context) return this.op(cb => this.native.fetchRows(this.queryId, this.notify.getHandle(), { asArrays: true, batchSize: rowBatchSize, asObjects: false }, cb)) } close () { if (!this.running) return // Already closed this.running = false // Clear any active timeout to prevent late-firing timeouts this.notify.clearTimeout() // Don't call nextOp here - it should be called after statement is freed this.queue.nextOp() } emitDone () { setImmediate(() => { if (this.done) return this.done = true logger.debugLazy(() => `emit done on query ${this.queryId}`, this.context) this.notify.emit('done', this.queryId) }) } dispatchInfoReturnErrors (e) { const infoMessages = [] const errorMessages = [] if (e && Array.isArray(e)) { e.forEach(errorOrInfo => { if (this.isInfo(errorOrInfo)) { infoMessages.push(errorOrInfo) } else { errorMessages.push(errorOrInfo) } }) } if (errorMessages.length > 0) { return errorMessages } else if (infoMessages.length > 0) { this.routeStatementError(infoMessages, this.callback, this.notify, false) } return [] } async nativeNextResult (queryId) { return new Promise((resolve, reject) => { this.infoFromNextResult = false logger.debugLazy(() => `native::nextResultSet ${this.queryId}`, this.context) this.native.nextResultSet(this.queryId, this.notify.getHandle(), (e, res) => { setImmediate(() => { // may contain info messages e.g. raised by PRINT statements - do not want to reject these const errorMessages = e ? this.dispatchInfoReturnErrors(e) : [] if (errorMessages.length > 0) { reject(errorMessages) } else { this.infoFromNextResult = e != null && Array.isArray(e) && e.length > 0 resolve(res) } }) }) }) } async beginQuery (queryId) { return new Promise((resolve, reject) => { logger.debugLazy(() => `call query handler begin ${this.queryId}`, this.context) this.queryHandler.begin(queryId, this.query, this.params, (e, queryResult, procOutputOrMore) => { if (queryResult) { this.notify.setHandle(queryResult.handle) this.context = `Reader: [${JSON.stringify(this.notify.getHandle())} (${this.queryId})]` } setImmediate(() => { if (e && queryResult.endOfResults && queryResult.endOfRows) { // Error with no more results - statement needs to be freed before rejecting logger.debugLazy(() => 'beginQuery error, no more results, ending', this.context) // Call end to ensure statement is freed before rejecting if (!Array.isArray(e)) { e = [e] } const msgs = this.dispatchInfoReturnErrors(e) if (queryResult.handle && queryResult.handle.statementId >= 0) { this.queryHandler.end(this.notify, this.outputParams, () => { logger.debugLazy(() => `query handler - error count ${msgs.length}`, this.context) reject(msgs) }, null, false) } else { reject(msgs) } this.queue.nextOp() } else if (e) { // Error but more results available - just pass through resolve({ warning: e, queryResult, procOutput: procOutputOrMore }) } else { resolve({ warning: e, queryResult, procOutput: procOutputOrMore }) } }) }) }) } dispatchRow (driverRow, currentRow) { for (let column = 0; column < driverRow.length; ++column) { let rowColumn = driverRow[column] if (rowColumn && this.useUTC === false) { if (this.meta[column].type === 'date') { rowColumn = new Date(rowColumn.getTime() - rowColumn.getTimezoneOffset() * -60000) } } if (this.callback) { currentRow[column] = rowColumn } this.notify.emit('column', column, rowColumn, false) } } getRow () { this.batchRowIndex++ this.queryRowIndex++ let currentRow if (this.callback) { currentRow = [] this.rows.push(currentRow) } return currentRow } // console.log('fetch ', queryId) dispatchRows (results) { if (!results) { return } if (this.paused) { logger.debugLazy(() => `[${JSON.stringify(this.notify.getHandle())}] dispatchRows called but paused for queryId ${this.queryId}`, this.context) return } const resultRows = results.data if (!resultRows) { return } const numberRows = resultRows.length logger.traceLazy(() => `[${JSON.stringify(this.notify.getHandle())}] dispatchRows processing ${numberRows} rows for queryId ${this.queryId}, batchRowIndex=${this.batchRowIndex}`, this.context) while (!this.paused && this.batchRowIndex < numberRows) { const driverRow = resultRows[this.batchRowIndex] this.notify.emit('row', this.queryRowIndex) const currentRow = this.getRow() this.dispatchRow(driverRow, currentRow) } } rowsCompleted (results, more) { this.queryHandler.end(this.notify, this.outputParams, (err, r, freeMore, op) => { if (this.callback && !this.done && !this.timeoutTriggered) { this.callback(err, r, freeMore, op) } if (!freeMore) { this.emitDone() } }, results, more) } rowsAffected (nextResultSetInfo) { const rowCount = this.currentQueryResult.rowCount const moreResults = !nextResultSetInfo.endOfResults || this.infoFromNextResult this.notify.emit('rowcount', rowCount) const state = { meta: null, rowCount } this.rowsCompleted(state, moreResults) } end (err) { if (this.done) return this.done = true // Clear any active timeout to prevent late-firing timeouts this.notify.clearTimeout() if (!Array.isArray(err)) { err = [err] } if (!this.cancelled) { this.routeStatementError(err, this.callback, this.notify) } this.queryHandler.end(this.notify, this.outputParams, () => { this.queue.nextOp() }, null, false) this.close() } metaRows () { return { meta: this.meta, rows: this.rows } } moveToNextResult (nextResultSetInfo) { setImmediate(() => { if (this.cancelled) { this.running = false logger.debugLazy(() => `[${this.notify.getHandle()}] query ${this.queryId} has been cancelled - ending query ${this.queryId} `, this.context) this.end() return } logger.debugLazy(() => `[${JSON.stringify(this.notify.getHandle())}] query ${this.queryId} moveToNextResult`, this.context) const nextMeta = nextResultSetInfo.meta const nextNullEmptyMeta = nextMeta == null || nextMeta.length === 0 const thisEmptyMeta = this.meta && this.meta.length === 0 if (!this.meta) { this.rowsCompleted(this.metaRows(), !nextResultSetInfo.endOfResults) } else if (this.infoFromNextResult && nextNullEmptyMeta) { this.rowsAffected(nextResultSetInfo) this.nextResult() return } else if (thisEmptyMeta) { // handle the just finished result reading // if there was no metadata, then pass the row count (rows affected) this.rowsAffected(nextResultSetInfo) } else { this.rowsCompleted(this.metaRows(), !nextResultSetInfo.endOfResults) } // reset for the next resultset this.meta = nextResultSetInfo.meta if (!this.meta) { this.nextResult() return } this.rows = [] if (nextResultSetInfo.endOfResults && nextResultSetInfo.endOfRows) { this.close() } else { this.currentQueryResult = nextResultSetInfo // if this is just a set of rows if (this.meta.length > 0) { this.notify.emit('meta', this.meta) // kick off reading next set of rows this.dispatch() } else { this.nextResult() } } }) } dispatch () { if (!this.running) { logger.debugLazy(() => `dispatch called but not running for queryId ${this.queryId}`, this.context) return } if (this.paused) { logger.debugLazy(() => `dispatch called but paused for queryId ${this.queryId}`, this.context) return // will come back at some later stage } logger.traceLazy(() => `dispatch fetching rows for queryId ${this.queryId}, rowBatchSize=${this.rowBatchSize}`, this.context) this.nativeGetRows(this.queryId, this.rowBatchSize).then(d => { logger.traceLazy(() => `dispatch received ${d?.data?.length || 0} rows for queryId ${this.queryId}, endOfRows=${d?.endOfRows} endOfResults=${d?.endOfResults}`, this.context) this.batchRowIndex = 0 this.batchData = d this.dispatchRows(d) if (!d.endOfRows) { this.dispatch() } else if (!d.endOfResults) { this.nextResult() } else { d.meta = [] this.moveToNextResult(d) } }).catch(err => { logger.debugLazy(() => `dispatch error for queryId ${this.queryId}: ${err}`, 'DriverRead.dispatch', this.context) this.end(err) }) } nextResult () { this.infoFromNextResult = false this.nativeNextResult(this.queryId) .then(nextResultSetInfo => { this.moveToNextResult(nextResultSetInfo) }).catch(err => { this.end(err) }) } begin () { this.beginQuery(this.queryId, this.query, this.params).then(res => { this.notify.setHandle(res.queryResult.handle) if (res.warning) { this.routeStatementError(res.warning, this.callback, this.notify) if (!this.cancelled) { res.warning.forEach(err => { if (!this.cancelled) { this.cancelled = err.message.includes('Operation canceled') if (this.cancelled) { logger.debugLazy(() => `statement has been cancelled queryId ${this.queryId}`, this.context) } } }) } } this.outputParams = res.outputParams this.meta = res.queryResult.meta this.rowCount = res.queryResult.rowCount || 0 this.currentQueryResult = res.queryResult if (this.meta.length > 0) { this.notify.emit('meta', this.meta) this.dispatch() } else { this.nextResult() } }).catch(err => { logger.debugLazy(() => `dispatch error for queryId ${this.queryId}: ${err}`, this.context) // Don't call end() here - it's already handled in beginQuery for binding errors this.close() this.routeStatementError(err, this.callback, this.notify) const handle = this.notify.getHandle() if (handle && handle.statementId < 0) { this.notify.emit('done') this.notify.emit('free') } }) this.notify.emit('submitted', this.query, this.params) } pause () { if (this.paused) return logger.debugLazy(() => `DriverRead.pause() called for queryId ${this.queryId}`, this.context) this.paused = true this.queue.park(this.notify.getOperation()) logger.debugLazy(() => `DriverRead.pause() parked operation for queryId ${this.queryId}`, this.context) } resume () { if (!this.paused) return logger.debugLazy(() => `DriverRead.resume() called for queryId ${this.queryId}`, this.context) this.queue.resume(this.notify.getOperation()) this.paused = false this.dispatchRows(this.batchData) if (this.paused) { // dispatchRows paused again mid-batch, don't fetch more logger.debugLazy(() => `DriverRead.resume() re-paused during dispatchRows for queryId ${this.queryId}`, this.context) } else if (this.batchData && this.batchData.endOfRows && this.batchRowIndex >= (this.batchData.data ? this.batchData.data.length : 0)) { // all rows from this batch were dispatched and the native result set is exhausted // do not call nativeGetRows again or the ODBC driver will return a function sequence error logger.debugLazy(() => `DriverRead.resume() endOfRows reached, batch fully dispatched for queryId ${this.queryId}`, this.context) if (!this.batchData.endOfResults) { this.nextResult() } else { this.batchData.meta = [] this.moveToNextResult(this.batchData) } } else { this.dispatch() } logger.debugLazy(() => `DriverRead.resume() resumed operation for queryId ${this.queryId}`, this.context) } setupTimeoutHandling () { const queryObj = this.notify.getQueryObj() const timeoutSecs = queryObj?.query_timeout || 0 if (timeoutSecs > 0) { const isLinux = process.platform === 'linux' const isVersion17 = this.version === 17 // Use JS-based cancel for Linux driver v17 to avoid disconnect blocking issue const useJsCancel = isLinux && isVersion17 if (useJsCancel) { // Convert seconds to milliseconds const timeoutMs = timeoutSecs * 1000 logger.debugLazy(() => `Using JS-based cancel for Linux driver v17, timeout: ${timeoutMs}ms (${timeoutSecs}s)`, this.context) // Set query_timeout to 0 to disable driver timeout queryObj.query_timeout = 0 // Enable polling to allow cancellation queryObj.query_polling = true // Set up timeout immediately to avoid race conditions with fast-executing procedures logger.debugLazy(() => `Setting up immediate timeout: ${timeoutMs}ms`, this.context) // Setup JS timeout that will call cancel this.notify.setupTimeout(timeoutMs, () => { if (this.timeoutTriggered || this.done || this.cancelled) return this.timeoutTriggered = true this.cancelled = true try { this.pause() // Add a small delay to reduce race condition with statement cleanup setImmediate(() => { if (this.done) return this.notify.cancelQuery((e) => { if (this.done) return // Use the driver's error message (typically "Operation canceled") this.end(e) }) }) } catch (e) { if (!this.done) { this.end(e) } } }) } else { logger.debugLazy(() => `Using native driver timeout: ${timeoutSecs} seconds`, this.context) // Let the native driver handle the timeout } } } } exports.DriverRead = DriverRead