ubjs/packages/ub/dbConnections.js

/*
 * Created by v.orel on 22.12.2016.
 */
const binding = process.binding('ub_dbConnection')
const bindingDatabases = binding.databases
const QueryString = require('querystring')
const databases = {}
const dbIndexSymbol = Symbol('dbIndex')
const chDblQuote = 34
const chQuote = 39
const chColon = 58
const chLRoundBrac = 40
const chRRoundBrac = 41
const chLBrac = 91
const chZero = 48
const chNine = 57
const chPlus = 43
const chMinus = 45
const chQuestionMark = 63
const chPoint = 46
const chN = 110
const chU = 117
const chL = 108
const chEGr = 69
const chELw = 101
const chSpace = 32
const chLF = 10
const chCR = 13
/**
 * @typedef {Object} parseSQLResult
 * @property {string} parsedSql
 * @property {Array} parsedParams
 */

/**
 * Class for database access. Databases are defined in config file
 */
class TubDatabase_ {
  /**
   * @private
   * @param {number} index
   */
  constructor (index) {
    /**
     * @private
     * @property dbIndexSymbol
     */
    Object.defineProperty(this, dbIndexSymbol, {value: index})
  }
  /**
   * Is database in transaction
   * @returns {boolean}
   */
  get inTransaction () {
    return binding.inTransaction(this[dbIndexSymbol])
  }
  /**
   * Start transaction. If transaction is already started return false
   * @returns {boolean}
   */
  startTransaction () {
    return binding.startTransaction(this[dbIndexSymbol])
  }
  /**
   * Commit transaction. If transaction is not started return false
   * @returns {boolean}
   */
  commit () {
    return binding.commit(this[dbIndexSymbol])
  }
  /**
   * Rollback transaction. If transaction is not started return false
   * @returns {boolean}
   */
  rollback () {
    return binding.rollback(this[dbIndexSymbol])
  }
  /**
   * Run select sql and return result
   * @param {string} sql
   * @param {Object} params
   * @returns {string}
   */
  run (sql, params) {
    const {parsedSql, parsedParams} = this.parseSQL(sql, params)
    return binding.run(this[dbIndexSymbol], parsedSql, parsedParams)
  }
  /**
   * Execute sql
   * @param {string} sql
   * @param {Object} params
   * @returns {boolean}
   */
  exec (sql, params) {
    const {parsedSql, parsedParams} = this.parseSQL(sql, params)
    return binding.exec(this[dbIndexSymbol], parsedSql, parsedParams)
  }
  /**
   * Generate ID for entity
   * @param {string} entity
   * @returns {number}
   */
  genID (entity) {
    return binding.genID(entity)
  }

  /**
   * @private
   * @param {string} sql
   * @param {Object} params
   * @returns {parseSQLResult}
   */
  parseSQL (sql, params) {
    const parsedParams = []
    const paramPositions = []
    let unnamedParamsCount = 0
    params = params || {}
    for (let i = 0, ch = sql.charCodeAt(0), L = sql.length; i < L; ch = sql.charCodeAt(++i)) {
      if (ch === chDblQuote) {
        while ((i < L) && (sql.charCodeAt(++i) !== chDblQuote)) {}
      } else if (ch === chQuote) {
        while ((i < L) && (sql.charCodeAt(++i) !== chQuote)) {}
      } else if (ch === chColon) {
        // while ((i< L) && (sql.charCodeAt(++i) !== chQuote)) {}
        if ((ch = sql.charCodeAt(++i)) === chColon) {
        // MSSQL ALTER AUTHORIZATION ON database::testdb
        } else if (ch === chLRoundBrac) {
          // syn inline :(value):
          let inlineParamValue, paramStart, paramEnd
          ch = sql.charCodeAt(++i)
          paramStart = i
          if ((ch === chQuote) || (ch === chDblQuote)) {
            const quote = ch
            let curPosition = i + 1
            inlineParamValue = []
            while (i < L) {
              ch = sql.charCodeAt(++i)
              if (ch === quote) {
                inlineParamValue.push(sql.slice(curPosition, i))
                if ((ch = sql.charCodeAt(++i)) === quote) {
                  // allow double quotes inside string
                  curPosition = i
                } else {
                  break
                }
              }
            }
            inlineParamValue = inlineParamValue.join('')
          } else if ((ch === chPlus) || (ch === chMinus) || ((ch >= chZero) && (ch <= chNine))) {
            while (((ch = sql.charCodeAt(++i)) >= chZero) && (ch <= chNine)) {}
            if (ch === chPoint) {
              while (((ch = sql.charCodeAt(++i)) >= chZero) && (ch <= chNine)) {}
            }
            if ((ch === chEGr) || (ch === chELw)) {
              ch = sql.charCodeAt(++i)
              if ((ch === chPlus) || (ch === chMinus)) {
                ch = sql.charCodeAt(++i)
              }
              while (((ch = sql.charCodeAt(++i)) >= chZero) && (ch <= chNine)) {}
            }
            inlineParamValue = Number.parseFloat(sql.slice(paramStart, paramEnd))
          } else if (ch === chLBrac) {
            let arraySearchPosition = paramStart
            while (i < L) {
              i = sql.indexOf(']):', arraySearchPosition)
              i++
              try {
                inlineParamValue = JSON.parse(sql.slice(paramStart, i))
              } catch (e) {
                arraySearchPosition = i
              }
              if (inlineParamValue !== undefined) {
                if (inlineParamValue.length === 0) {
                  throw new Error('Empty array binding')
                }
                const requiredType = typeof inlineParamValue[0]
                if ((requiredType !== 'number') && (requiredType !== 'string')) {
                  throw new Error('Only String or Int64 array binding allowed')
                }
                for (let element of inlineParamValue) {
                  if (typeof element !== requiredType) {
                    throw new Error(`Array binding ${requiredType} type required`)
                  }
                }
                break
              }
            }
          } else if (ch === chN) {
            if (((ch = sql.charCodeAt(++i)) === chU) && ((ch = sql.charCodeAt(++i)) === chL) && ((ch = sql.charCodeAt(++i)) === chL)) {
              ch = sql.charCodeAt(++i)
              inlineParamValue = null
            }
          }
          paramEnd = i
          while ((ch = sql.charCodeAt(i)) <= chSpace) {
            i++
          }

          if ((ch !== chRRoundBrac) || ((ch = sql.charCodeAt(++i)) !== chColon)) {
            throw new Error('Error parsing SQL')
          }
          if (inlineParamValue === undefined) {
            throw new Error('Error parsing inline parameter')
          }
          parsedParams.push(inlineParamValue)
          paramPositions.push({paramStart: paramStart - 2, paramEnd: paramEnd + 2})
        } else {
          // UB :paramName: - replace by ? and add a named param to AOutParams
          const paramStart = i
          while ((ch = sql.charCodeAt(++i)) !== chColon) {}
          const paramEnd = i
          const paramName = sql.slice(paramStart, paramEnd)
          const paramValue = params[paramName]
          if (paramValue === undefined) {
            throw new Error(`Param ${paramName} not found`)
          } else {
            parsedParams.push(paramValue)
          }
          paramPositions.push({paramStart: paramStart - 1, paramEnd: paramEnd + 1})
        }
      } else if (ch === chQuestionMark) {
        const unnamedParamValue = params[unnamedParamsCount++]
        if (unnamedParamValue === undefined) {
          throw new Error(`Param ${unnamedParamsCount - 1} not found`)
        } else {
          parsedParams.push(unnamedParamValue)
        }
      } else if (ch === chMinus) {
        if ((ch = sql.charCodeAt(++i)) === chMinus) {
        // comments
          while ((i < L) && ((ch = sql.charCodeAt(++i)) !== chLF) && (ch !== chCR)) {}
        }
      }
    }
    let startPos = 0
    let sqlParts = []
    for (let curParam = 0, L = paramPositions.length; curParam < L; curParam++) {
      sqlParts.push(sql.slice(startPos, paramPositions[curParam].paramStart))
      sqlParts.push('?')
      startPos = paramPositions[curParam].paramEnd
    }
    if (sqlParts.length === 0) {
      return {parsedSql: sql, parsedParams: parsedParams}
    } else {
      sqlParts.push(sql.slice(startPos, sql.length))
      return {parsedSql: sqlParts.join(''), parsedParams: parsedParams}
    }

  }
}

for (let index in bindingDatabases) {
  Object.defineProperty(databases, bindingDatabases[index], {value: new TubDatabase_(Number.parseInt(index)), enumerable: true})
}
/**
 * Databases of application
 * @memberOf App
 * @property databases_
 * @type {Object<string,TubDatabase_>}
 */
App.databases_ = databases

/**
 * Default database of application
 * @memberOf App
 * @property defaultDatabase_
 * @type {TubDatabase_}
 */
App.defaultDatabase_ = databases[binding.defaultDb]

/**
 * Run sql on server side
 * Allowed from localIP
 * Connection name is in `connection` uri parameter (or default connection if not set)
 * If HTTP method is GET then allowed inline parameters only
 *   sql is in `sql` uri parameter
 * If Http method is not GET then
 *   sql is in request body
 *   parameters is uri parameters except `connection`
 * @param {THTTPRequest} req
 * @param {THTTPResponse} resp
 * @private
 */
function runSQL_ (req, resp) {
  if (App.localIPs.indexOf(Session.callerIP) === -1) {
    throw new Error(`Only local execution allowed. Caller remoteIP="${Session.callerIP}"`)
  }

  let database,
    sql,
    sqlParams

  if (Object.keys(App.databases_).length === 0) {
    throw new Error('Application dose not support connections')
  }

  const parameters = QueryString.parse(req.parameters, null, null)
  const connectionName = parameters.connection || parameters.CONNECTION
  if (connectionName) {
    database = App.databases_[connectionName]
  } else {
    database = App.defaultDatabase_
  }

  if (!database) { throw new Error('Unknown connection name') }

  if (req.method === 'GET') {
    sql = parameters.sql
    sqlParams = null
  } else {
    sql = req.read()
    delete parameters.connection
    sqlParams = parameters
  }

  if (!sql) { throw new Error('Empty SQL statement passed') }

  let upperStmt = sql.toUpperCase()
  if (upperStmt.startsWith('SELECT') || upperStmt.startsWith('PRAGMA')) {
    let result = database.run(sql, sqlParams)
    resp.writeEnd(result)
  } else {
    database.exec(sql, sqlParams)
    resp.writeEnd('[]')
  }
  resp.statusCode = 200
}

App.registerEndpoint('runSQL_', runSQL_, true)