// SPDX-License-Identifier: LGPL-2.1-or-later // Copyright (c) 2015-2024 MariaDB Corporation Ab 'use strict'; const Parser = require('./parser'); const Errors = require('../misc/errors'); const Parse = require('../misc/parse'); const TextEncoder = require('./encoder/text-encoder'); const { Readable } = require('stream'); const QUOTE = 0x27; /** * Protocol COM_QUERY * see : https://mariadb.com/kb/en/library/com_query/ */ class Query extends Parser { constructor(resolve, reject, connOpts, cmdParam) { super(resolve, reject, connOpts, cmdParam); this.binary = false; } /** * Send COM_QUERY * * @param out output writer * @param opts connection options * @param info connection information */ start(out, opts, info) { if (opts.logger.query) opts.logger.query(`QUERY: ${opts.logParam ? this.displaySql() : this.sql}`); this.onPacketReceive = this.readResponsePacket; if (this.initialValues === undefined) { //shortcut if no parameters out.startPacket(this); out.writeInt8(0x03); if (!this.handleTimeout(out, info)) return; out.writeString(this.sql); out.flush(); this.emit('send_end'); return; } this.encodedSql = out.encodeString(this.sql); if (this.opts.namedPlaceholders) { try { const parsed = Parse.splitQueryPlaceholder( this.encodedSql, info, this.initialValues, this.opts.logParam ? this.displaySql.bind(this) : () => this.sql ); this.paramPositions = parsed.paramPositions; this.values = parsed.values; } catch (err) { this.emit('send_end'); return this.throwError(err, info); } } else { this.paramPositions = Parse.splitQuery(this.encodedSql); this.values = Array.isArray(this.initialValues) ? this.initialValues : [this.initialValues]; if (!this.validateParameters(info)) return; } out.startPacket(this); out.writeInt8(0x03); if (!this.handleTimeout(out, info)) return; this.paramPos = 0; this.sqlPos = 0; //******************************************** // send params //******************************************** const len = this.paramPositions.length / 2; for (this.valueIdx = 0; this.valueIdx < len; ) { out.writeBuffer(this.encodedSql, this.sqlPos, this.paramPositions[this.paramPos++] - this.sqlPos); this.sqlPos = this.paramPositions[this.paramPos++]; const value = this.values[this.valueIdx++]; if (value == null) { out.writeStringAscii('NULL'); continue; } switch (typeof value) { case 'boolean': out.writeStringAscii(value ? 'true' : 'false'); break; case 'bigint': case 'number': out.writeStringAscii(`${value}`); break; case 'string': out.writeStringEscapeQuote(value); break; case 'object': if (typeof value.pipe === 'function' && typeof value.read === 'function') { this.sending = true; //******************************************** // param is stream, // now all params will be written by event //******************************************** this.paramWritten = this._paramWritten.bind(this, out, info); out.writeInt8(QUOTE); //' value.on('data', out.writeBufferEscape.bind(out)); value.on( 'end', function () { out.writeInt8(QUOTE); //' this.paramWritten(); }.bind(this) ); return; } if (Object.prototype.toString.call(value) === '[object Date]') { out.writeStringAscii(TextEncoder.getLocalDate(value)); } else if (Buffer.isBuffer(value)) { out.writeStringAscii("_BINARY '"); out.writeBufferEscape(value); out.writeInt8(QUOTE); } else if (typeof value.toSqlString === 'function') { out.writeStringEscapeQuote(String(value.toSqlString())); } else if (Array.isArray(value)) { if (opts.arrayParenthesis) { out.writeStringAscii('('); } for (let i = 0; i < value.length; i++) { if (i !== 0) out.writeStringAscii(','); if (value[i] == null) { out.writeStringAscii('NULL'); } else TextEncoder.writeParam(out, value[i], opts, info); } if (opts.arrayParenthesis) { out.writeStringAscii(')'); } } else { if ( value.type != null && [ 'Point', 'LineString', 'Polygon', 'MultiPoint', 'MultiLineString', 'MultiPolygon', 'GeometryCollection' ].includes(value.type) ) { //GeoJSON format. let prefix = (info.isMariaDB() && info.hasMinVersion(10, 1, 4)) || (!info.isMariaDB() && info.hasMinVersion(5, 7, 6)) ? 'ST_' : ''; switch (value.type) { case 'Point': out.writeStringAscii( prefix + "PointFromText('POINT(" + TextEncoder.geoPointToString(value.coordinates) + ")')" ); break; case 'LineString': out.writeStringAscii( prefix + "LineFromText('LINESTRING(" + TextEncoder.geoArrayPointToString(value.coordinates) + ")')" ); break; case 'Polygon': out.writeStringAscii( prefix + "PolygonFromText('POLYGON(" + TextEncoder.geoMultiArrayPointToString(value.coordinates) + ")')" ); break; case 'MultiPoint': out.writeStringAscii( prefix + "MULTIPOINTFROMTEXT('MULTIPOINT(" + TextEncoder.geoArrayPointToString(value.coordinates) + ")')" ); break; case 'MultiLineString': out.writeStringAscii( prefix + "MLineFromText('MULTILINESTRING(" + TextEncoder.geoMultiArrayPointToString(value.coordinates) + ")')" ); break; case 'MultiPolygon': out.writeStringAscii( prefix + "MPolyFromText('MULTIPOLYGON(" + TextEncoder.geoMultiPolygonToString(value.coordinates) + ")')" ); break; case 'GeometryCollection': out.writeStringAscii( prefix + "GeomCollFromText('GEOMETRYCOLLECTION(" + TextEncoder.geometricCollectionToString(value.geometries) + ")')" ); break; } } else if (String === value.constructor) { out.writeStringEscapeQuote(value); break; } else { if (opts.permitSetMultiParamEntries) { let first = true; for (let key in value) { const val = value[key]; if (typeof val === 'function') continue; if (first) { first = false; } else { out.writeStringAscii(','); } out.writeString('`' + key + '`'); if (val == null) { out.writeStringAscii('=NULL'); } else { out.writeStringAscii('='); TextEncoder.writeParam(out, val, opts, info); } } if (first) out.writeStringEscapeQuote(JSON.stringify(value)); } else { out.writeStringEscapeQuote(JSON.stringify(value)); } } } break; } } out.writeBuffer(this.encodedSql, this.sqlPos, this.encodedSql.length - this.sqlPos); out.flush(); this.emit('send_end'); } /** * If timeout is set, prepend query with SET STATEMENT max_statement_time=xx FOR, or throw an error * @param out buffer * @param info server information * @returns {boolean} false if an error has been thrown */ handleTimeout(out, info) { if (this.opts.timeout) { if (info.isMariaDB()) { if (info.hasMinVersion(10, 1, 2)) { out.writeString(`SET STATEMENT max_statement_time=${this.opts.timeout / 1000} FOR `); return true; } else { this.sendCancelled( `Cannot use timeout for xpand/MariaDB server before 10.1.2. timeout value: ${this.opts.timeout}`, Errors.ER_TIMEOUT_NOT_SUPPORTED, info ); return false; } } else { //not available for MySQL // max_execution time exist, but only for select, and as hint this.sendCancelled( `Cannot use timeout for MySQL server. timeout value: ${this.opts.timeout}`, Errors.ER_TIMEOUT_NOT_SUPPORTED, info ); return false; } } return true; } /** * Validate that parameters exists and are defined. * * @param info connection info * @returns {boolean} return false if any error occur. */ validateParameters(info) { //validate parameter size. if (this.paramPositions.length / 2 > this.values.length) { this.sendCancelled( `Parameter at position ${this.values.length + 1} is not set`, Errors.ER_MISSING_PARAMETER, info ); return false; } return true; } _paramWritten(out, info) { while (true) { if (this.valueIdx === this.paramPositions.length / 2) { //******************************************** // all parameters are written. // flush packet //******************************************** out.writeBuffer(this.encodedSql, this.sqlPos, this.encodedSql.length - this.sqlPos); out.flush(); this.sending = false; this.emit('send_end'); return; } else { const value = this.values[this.valueIdx++]; out.writeBuffer(this.encodedSql, this.sqlPos, this.paramPositions[this.paramPos++] - this.sqlPos); this.sqlPos = this.paramPositions[this.paramPos++]; if (value == null) { out.writeStringAscii('NULL'); continue; } if (typeof value === 'object' && typeof value.pipe === 'function' && typeof value.read === 'function') { //******************************************** // param is stream, //******************************************** out.writeInt8(QUOTE); value.once( 'end', function () { out.writeInt8(QUOTE); this._paramWritten(out, info); }.bind(this) ); value.on('data', out.writeBufferEscape.bind(out)); return; } //******************************************** // param isn't stream. directly write in buffer //******************************************** TextEncoder.writeParam(out, value, this.opts, info); } } } _stream(socket, options) { this.socket = socket; options = options || {}; options.objectMode = true; options.read = () => { this.socket.resume(); }; this.inStream = new Readable(options); this.on('fields', function (meta) { this.inStream.emit('fields', meta); }); this.on('error', function (err) { this.inStream.emit('error', err); }); this.on('close', function (err) { this.inStream.emit('error', err); }); this.on('end', function (err) { if (err) this.inStream.emit('error', err); this.socket.resume(); this.inStream.push(null); }); this.inStream.close = function () { this.handleNewRows = () => {}; this.socket.resume(); }.bind(this); this.handleNewRows = function (row) { if (!this.inStream.push(row)) { this.socket.pause(); } }; return this.inStream; } } module.exports = Query;