diff --git a/.github/workflows/ci-coverage.yml b/.github/workflows/ci-coverage.yml index 832574fc82..6bbb02b4e9 100644 --- a/.github/workflows/ci-coverage.yml +++ b/.github/workflows/ci-coverage.yml @@ -3,7 +3,7 @@ name: CI - Coverage on: pull_request: push: - branches: [ master ] + branches: [master] workflow_dispatch: @@ -20,14 +20,16 @@ jobs: fail-fast: false matrix: node-version: [20.x] - mysql-version: ["mysql:5.7", "mysql:8.0.33"] + mysql-version: ['mysql:5.7', 'mysql:8.0.33'] use-compression: [0, 1] use-tls: [0, 1] - mysql_connection_url_key: [""] + static-parser: [0, 1] + mysql_connection_url_key: [''] env: MYSQL_CONNECTION_URL: ${{ secrets[matrix.mysql_connection_url_key] }} + STATIC_PARSER: ${{ matrix.static-parser }} - name: Coverage ${{ matrix.node-version }} - DB ${{ matrix.mysql-version }}${{ matrix.mysql_connection_url_key }} - SSL=${{matrix.use-tls}} Compression=${{matrix.use-compression}} + name: Coverage ${{ matrix.node-version }} - DB ${{ matrix.mysql-version }}${{ matrix.mysql_connection_url_key }} - SSL=${{matrix.use-tls}} Compression=${{matrix.use-compression}} Static Parser=${{matrix.static-parser}} steps: - uses: actions/checkout@v4 @@ -62,5 +64,5 @@ jobs: uses: codecov/codecov-action@v4 with: token: ${{ secrets.CODECOV_TOKEN }} - flags: compression-${{ matrix.use-compression }},tls-${{ matrix.use-tls }} - name: codecov-umbrella-${{ matrix.node-version }}-${{ matrix.mysql-version }}-compression-${{ matrix.use-compression }}-tls-${{ matrix.use-tls }} + flags: compression-${{ matrix.use-compression }},tls-${{ matrix.use-tls }},static-parser-${{ matrix.static-parser }} + name: codecov-umbrella-${{ matrix.node-version }}-${{ matrix.mysql-version }}-compression-${{ matrix.use-compression }}-tls-${{ matrix.use-tls }}-static-parser-${{ matrix.static-parser }} diff --git a/.github/workflows/ci-linux.yml b/.github/workflows/ci-linux.yml index bf010d9b06..84e5334a93 100644 --- a/.github/workflows/ci-linux.yml +++ b/.github/workflows/ci-linux.yml @@ -22,12 +22,15 @@ jobs: mysql-version: ['mysql:8.0.33'] use-compression: [0, 1] use-tls: [0, 1] + static-parser: [0, 1] mysql_connection_url_key: [''] # TODO - add mariadb to the matrix. currently few tests are broken due to mariadb incompatibilities + env: MYSQL_CONNECTION_URL: ${{ secrets[matrix.mysql_connection_url_key] }} + STATIC_PARSER: ${{ matrix.static-parser }} - name: Node.js ${{ matrix.node-version }} - DB ${{ matrix.mysql-version }}${{ matrix.mysql_connection_url_key }} - SSL=${{matrix.use-tls}} Compression=${{matrix.use-compression}} + name: Node.js ${{ matrix.node-version }} - DB ${{ matrix.mysql-version }}${{ matrix.mysql_connection_url_key }} - SSL=${{matrix.use-tls}} Compression=${{matrix.use-compression}} Static Parser=${{matrix.static-parser}} steps: - uses: actions/checkout@v4 @@ -57,7 +60,7 @@ jobs: - name: Run tests run: FILTER=${{matrix.filter}} MYSQL_USE_TLS=${{ matrix.use-tls }} MYSQL_USE_COMPRESSION=${{ matrix.use-compression }} npm run test - timeout-minutes: 5 + timeout-minutes: 10 tests-linux-bun: runs-on: ubuntu-latest @@ -68,8 +71,12 @@ jobs: mysql-version: ['mysql:8.0.33'] use-compression: [0, 1] use-tls: [0, 1] + static-parser: [0, 1] + + env: + STATIC_PARSER: ${{ matrix.static-parser }} - name: Bun ${{ matrix.bun-version }} - DB ${{ matrix.mysql-version }} - SSL=${{matrix.use-tls}} Compression=${{matrix.use-compression}} + name: Bun ${{ matrix.bun-version }} - DB ${{ matrix.mysql-version }} - SSL=${{matrix.use-tls}} Compression=${{matrix.use-compression}} Static Parser=${{matrix.static-parser}} steps: - uses: actions/checkout@v4 @@ -108,7 +115,7 @@ jobs: MYSQL_USE_TLS: ${{ matrix.use-tls }} FILTER: test-select-1|test-select-ssl run: bun run test:bun - timeout-minutes: 1 + timeout-minutes: 10 tests-linux-deno-v1: runs-on: ubuntu-latest @@ -118,6 +125,7 @@ jobs: deno-version: [v1.x] mysql-version: ['mysql:8.0.33'] use-compression: [0, 1] + static-parser: [0, 1] # TODO: investigate error when using SSL (1) # # errno: -4094 @@ -125,7 +133,10 @@ jobs: # syscall: "read" use-tls: [0] - name: Deno ${{ matrix.deno-version }} - DB ${{ matrix.mysql-version }} - SSL=${{matrix.use-tls}} Compression=${{matrix.use-compression}} + env: + STATIC_PARSER: ${{ matrix.static-parser }} + + name: Deno ${{ matrix.deno-version }} - DB ${{ matrix.mysql-version }} - SSL=${{matrix.use-tls}} Compression=${{matrix.use-compression}} Static Parser=${{matrix.static-parser}} steps: - uses: actions/checkout@v4 @@ -162,7 +173,7 @@ jobs: MYSQL_USE_COMPRESSION: ${{ matrix.use-compression }} MYSQL_USE_TLS: ${{ matrix.use-tls }} run: deno task test:deno -- --denoCjs='.js,.cjs' - timeout-minutes: 5 + timeout-minutes: 10 tests-linux-deno-v2: runs-on: ubuntu-latest @@ -172,6 +183,7 @@ jobs: deno-version: [v2.x, canary] mysql-version: ['mysql:8.0.33'] use-compression: [0, 1] + static-parser: [0, 1] # TODO: investigate error when using SSL (1) # # errno: -4094 @@ -179,7 +191,10 @@ jobs: # syscall: "read" use-tls: [0] - name: Deno ${{ matrix.deno-version }} - DB ${{ matrix.mysql-version }} - SSL=${{matrix.use-tls}} Compression=${{matrix.use-compression}} + env: + STATIC_PARSER: ${{ matrix.static-parser }} + + name: Deno ${{ matrix.deno-version }} - DB ${{ matrix.mysql-version }} - SSL=${{matrix.use-tls}} Compression=${{matrix.use-compression}} Static Parser=${{matrix.static-parser}} steps: - uses: actions/checkout@v4 @@ -216,4 +231,4 @@ jobs: MYSQL_USE_COMPRESSION: ${{ matrix.use-compression }} MYSQL_USE_TLS: ${{ matrix.use-tls }} run: deno task test:deno - timeout-minutes: 5 + timeout-minutes: 10 diff --git a/.nycrc b/.nycrc index bee4aca099..fc97b4a23b 100644 --- a/.nycrc +++ b/.nycrc @@ -3,10 +3,10 @@ "include": ["index.js", "promise.js", "lib/**/*.js"], "exclude": ["mysqldata/**", "node_modules/**", "test/**"], "reporter": ["text", "lcov", "cobertura"], - "statements": 86, - "branches": 84, + "statements": 80, + "branches": 80, "functions": 77, - "lines": 86, + "lines": 80, "checkCoverage": true, "clean": true } diff --git a/lib/commands/execute.js b/lib/commands/execute.js index 8ab7304b5b..7d34ef9b90 100644 --- a/lib/commands/execute.js +++ b/lib/commands/execute.js @@ -5,6 +5,7 @@ const Query = require('./query.js'); const Packets = require('../packets/index.js'); const getBinaryParser = require('../parsers/binary_parser.js'); +const getStaticBinaryParser = require('../parsers/static_binary_parser.js'); class Execute extends Command { constructor(options, callback) { @@ -25,12 +26,16 @@ class Execute extends Command { this._executeOptions = options; this._resultIndex = 0; this._localStream = null; - this._unpipeStream = function() {}; + this._unpipeStream = function () {}; this._streamFactory = options.infileStreamFactory; this._connection = null; } buildParserFromFields(fields, connection) { + if (this.options.disableEval) { + return getStaticBinaryParser(fields, this.options, connection.config); + } + return getBinaryParser(fields, this.options, connection.config); } @@ -42,7 +47,7 @@ class Execute extends Command { this.statement.id, this.parameters, connection.config.charsetNumber, - connection.config.timezone + connection.config.timezone, ); //For reasons why this try-catch is here, please see // https://github.com/sidorares/node-mysql2/pull/689 @@ -68,7 +73,7 @@ class Execute extends Command { // this.statement.columns[this._receivedFieldsCount] : new Packets.ColumnDefinition(packet); const field = new Packets.ColumnDefinition( packet, - connection.clientEncoding + connection.clientEncoding, ); this._receivedFieldsCount++; this._fields[this._resultIndex].push(field); @@ -87,7 +92,7 @@ class Execute extends Command { } this._rowParser = new (this.buildParserFromFields( this._fields[this._resultIndex], - connection + connection, ))(); return Execute.prototype.row; } diff --git a/lib/commands/query.js b/lib/commands/query.js index e8cd215bb0..5b7028df46 100644 --- a/lib/commands/query.js +++ b/lib/commands/query.js @@ -8,6 +8,7 @@ const Readable = require('stream').Readable; const Command = require('./command.js'); const Packets = require('../packets/index.js'); const getTextParser = require('../parsers/text_parser.js'); +const staticParser = require('../parsers/static_text_parser.js'); const ServerStatus = require('../constants/server_status.js'); const EmptyPacket = new Packets.Packet(0, Buffer.allocUnsafe(4), 0, 4); @@ -30,7 +31,7 @@ class Query extends Command { this._receivedFieldsCount = 0; this._resultIndex = 0; this._localStream = null; - this._unpipeStream = function () { }; + this._unpipeStream = function () {}; this._streamFactory = options.infileStreamFactory; this._connection = null; } @@ -55,7 +56,7 @@ class Query extends Command { const cmdPacket = new Packets.Query( this.sql, - connection.config.charsetNumber + connection.config.charsetNumber, ); connection.writePacket(cmdPacket.toPacket(1)); return Query.prototype.resultsetHeader; @@ -120,7 +121,7 @@ class Query extends Command { if (connection.config.debug) { // eslint-disable-next-line console.log( - ` Resultset header received, expecting ${rs.fieldCount} column definition packets` + ` Resultset header received, expecting ${rs.fieldCount} column definition packets`, ); } if (this._fieldCount === 0) { @@ -140,7 +141,7 @@ class Query extends Command { this._localStream = this._streamFactory(path); } else { this._localStreamError = new Error( - `As a result of LOCAL INFILE command server wants to read ${path} file, but as of v2.0 you must provide streamFactory option returning ReadStream.` + `As a result of LOCAL INFILE command server wants to read ${path} file, but as of v2.0 you must provide streamFactory option returning ReadStream.`, ); connection.writePacket(EmptyPacket); return this.infileOk; @@ -159,14 +160,14 @@ class Query extends Command { const dataWithHeader = Buffer.allocUnsafe(data.length + 4); data.copy(dataWithHeader, 4); connection.writePacket( - new Packets.Packet(0, dataWithHeader, 0, dataWithHeader.length) + new Packets.Packet(0, dataWithHeader, 0, dataWithHeader.length), ); }; const onEnd = () => { connection.removeListener('error', onConnectionError); connection.writePacket(EmptyPacket); }; - const onError = err => { + const onError = (err) => { this._localStreamError = err; connection.removeListener('error', onConnectionError); connection.writePacket(EmptyPacket); @@ -196,7 +197,7 @@ class Query extends Command { if (this._fields[this._resultIndex].length !== this._fieldCount) { const field = new Packets.ColumnDefinition( packet, - connection.clientEncoding + connection.clientEncoding, ); this._fields[this._resultIndex].push(field); if (connection.config.debug) { @@ -212,7 +213,15 @@ class Query extends Command { if (this._receivedFieldsCount === this._fieldCount) { const fields = this._fields[this._resultIndex]; this.emit('fields', fields); - this._rowParser = new (getTextParser(fields, this.options, connection.config))(fields); + if (this.options.disableEval) { + this._rowParser = staticParser(fields, this.options, connection.config); + } else { + this._rowParser = new (getTextParser( + fields, + this.options, + connection.config, + ))(fields); + } return Query.prototype.fieldsEOF; } return Query.prototype.readField; @@ -242,7 +251,7 @@ class Query extends Command { row = this._rowParser.next( packet, this._fields[this._resultIndex], - this.options + this.options, ); } catch (err) { this._localStreamError = err; @@ -274,13 +283,13 @@ class Query extends Command { } stream.emit('result', row, resultSetIndex); // replicate old emitter }); - this.on('error', err => { + this.on('error', (err) => { stream.emit('error', err); // Pass on any errors }); this.on('end', () => { stream.push(null); // pushing null, indicating EOF }); - this.on('fields', fields => { + this.on('fields', (fields) => { stream.emit('fields', fields); // replicate old emitter }); stream.on('end', () => { @@ -292,10 +301,7 @@ class Query extends Command { _setTimeout() { if (this.timeout) { const timeoutHandler = this._handleTimeoutError.bind(this); - this.queryTimeout = Timers.setTimeout( - timeoutHandler, - this.timeout - ); + this.queryTimeout = Timers.setTimeout(timeoutHandler, this.timeout); } } diff --git a/lib/connection_config.js b/lib/connection_config.js index eeb8a8d916..58b70b087b 100644 --- a/lib/connection_config.js +++ b/lib/connection_config.js @@ -10,7 +10,7 @@ const { URL } = require('url'); const ClientConstants = require('./constants/client'); const Charsets = require('./constants/charsets'); -const { version } = require('../package.json') +const { version } = require('../package.json'); let SSLProfiles = null; const validOptions = { @@ -59,6 +59,7 @@ const validOptions = { typeCast: 1, uri: 1, user: 1, + disableEval: 1, // These options are used for Pool connectionLimit: 1, maxIdle: 1, @@ -66,7 +67,7 @@ const validOptions = { Promise: 1, queueLimit: 1, waitForConnections: 1, - jsonStrings: 1 + jsonStrings: 1, }; class ConnectionConfig { @@ -87,14 +88,17 @@ class ConnectionConfig { // REVIEW: Should this be emitted somehow? // eslint-disable-next-line no-console console.error( - `Ignoring invalid configuration option passed to Connection: ${key}. This is currently a warning, but in future versions of MySQL2, an error will be thrown if you pass an invalid configuration option to a Connection` + `Ignoring invalid configuration option passed to Connection: ${key}. This is currently a warning, but in future versions of MySQL2, an error will be thrown if you pass an invalid configuration option to a Connection`, ); } } this.isServer = options.isServer; this.stream = options.stream; this.host = options.host || 'localhost'; - this.port = (typeof options.port === 'string' ? parseInt(options.port, 10) : options.port)|| 3306; + this.port = + (typeof options.port === 'string' + ? parseInt(options.port, 10) + : options.port) || 3306; this.localAddress = options.localAddress; this.socketPath = options.socketPath; this.user = options.user || undefined; @@ -128,7 +132,7 @@ class ConnectionConfig { // https://github.com/mysqljs/mysql#user-content-connection-options // eslint-disable-next-line no-console console.error( - `Ignoring invalid timezone passed to Connection: ${options.timezone}. This is currently a warning, but in future versions of MySQL2, an error will be thrown if you pass an invalid configuration option to a Connection` + `Ignoring invalid timezone passed to Connection: ${options.timezone}. This is currently a warning, but in future versions of MySQL2, an error will be thrown if you pass an invalid configuration option to a Connection`, ); // SqlStrings falls back to UTC on invalid timezone this.timezone = 'Z'; @@ -147,6 +151,7 @@ class ConnectionConfig { this.nestTables = options.nestTables === undefined ? undefined : options.nestTables; this.typeCast = options.typeCast === undefined ? true : options.typeCast; + this.disableEval = Boolean(options.disableEval); if (this.timezone[0] === ' ') { // "+" is a url encoded char for space so it // gets translated to space when giving a @@ -156,7 +161,7 @@ class ConnectionConfig { if (this.ssl) { if (typeof this.ssl !== 'object') { throw new TypeError( - `SSL profile must be an object, instead it's a ${typeof this.ssl}` + `SSL profile must be an object, instead it's a ${typeof this.ssl}`, ); } // Default rejectUnauthorized to true @@ -171,15 +176,18 @@ class ConnectionConfig { this.authSwitchHandler = options.authSwitchHandler; this.clientFlags = ConnectionConfig.mergeFlags( ConnectionConfig.getDefaultFlags(options), - options.flags || '' + options.flags || '', ); // Default connection attributes // https://dev.mysql.com/doc/refman/8.0/en/performance-schema-connection-attribute-tables.html - const defaultConnectAttributes = { + const defaultConnectAttributes = { _client_name: 'Node-MySQL-2', - _client_version: version + _client_version: version, + }; + this.connectAttributes = { + ...defaultConnectAttributes, + ...(options.connectAttributes || {}), }; - this.connectAttributes = { ...defaultConnectAttributes, ...(options.connectAttributes || {})}; this.maxPreparedStatements = options.maxPreparedStatements || 16000; this.jsonStrings = options.jsonStrings || false; } @@ -229,7 +237,7 @@ class ConnectionConfig { 'MULTI_RESULTS', 'TRANSACTIONS', 'SESSION_TRACK', - 'CONNECT_ATTRS' + 'CONNECT_ATTRS', ]; if (options && options.multipleStatements) { defaultFlags.push('MULTI_STATEMENTS'); diff --git a/lib/helpers.js b/lib/helpers.js index 55a52bb461..d476bf891d 100644 --- a/lib/helpers.js +++ b/lib/helpers.js @@ -74,14 +74,13 @@ const privateObjectProps = new Set([ exports.privateObjectProps = privateObjectProps; -const fieldEscape = (field) => { +const fieldEscape = (field, isEval = true) => { if (privateObjectProps.has(field)) { throw new Error( `The field name (${field}) can't be the same as an object's private property.`, ); } - return srcEscape(field); + return isEval ? srcEscape(field) : field; }; - exports.fieldEscape = fieldEscape; diff --git a/lib/parsers/static_binary_parser.js b/lib/parsers/static_binary_parser.js new file mode 100644 index 0000000000..cc782b85ed --- /dev/null +++ b/lib/parsers/static_binary_parser.js @@ -0,0 +1,211 @@ +'use strict'; + +const FieldFlags = require('../constants/field_flags.js'); +const Charsets = require('../constants/charsets.js'); +const Types = require('../constants/types.js'); +const helpers = require('../helpers'); + +const typeNames = []; +for (const t in Types) { + typeNames[Types[t]] = t; +} + +function getBinaryParser(fields, _options, config) { + function readCode(field, config, options, fieldNum, packet) { + const supportBigNumbers = Boolean( + options.supportBigNumbers || config.supportBigNumbers, + ); + const bigNumberStrings = Boolean( + options.bigNumberStrings || config.bigNumberStrings, + ); + const timezone = options.timezone || config.timezone; + const dateStrings = options.dateStrings || config.dateStrings; + const unsigned = field.flags & FieldFlags.UNSIGNED; + + switch (field.columnType) { + case Types.TINY: + return unsigned ? packet.readInt8() : packet.readSInt8(); + case Types.SHORT: + return unsigned ? packet.readInt16() : packet.readSInt16(); + case Types.LONG: + case Types.INT24: // in binary protocol int24 is encoded in 4 bytes int32 + return unsigned ? packet.readInt32() : packet.readSInt32(); + case Types.YEAR: + return packet.readInt16(); + case Types.FLOAT: + return packet.readFloat(); + case Types.DOUBLE: + return packet.readDouble(); + case Types.NULL: + return null; + case Types.DATE: + case Types.DATETIME: + case Types.TIMESTAMP: + case Types.NEWDATE: + return helpers.typeMatch(field.columnType, dateStrings, Types) + ? packet.readDateTimeString( + parseInt(field.decimals, 10), + null, + field.columnType, + ) + : packet.readDateTime(timezone); + case Types.TIME: + return packet.readTimeString(); + case Types.DECIMAL: + case Types.NEWDECIMAL: + return config.decimalNumbers + ? packet.parseLengthCodedFloat() + : packet.readLengthCodedString('ascii'); + case Types.GEOMETRY: + return packet.parseGeometryValue(); + case Types.VECTOR: + return packet.parseVector(); + case Types.JSON: + // Since for JSON columns mysql always returns charset 63 (BINARY), + // we have to handle it according to JSON specs and use "utf8", + // see https://github.com/sidorares/node-mysql2/issues/409 + return config.jsonStrings + ? packet.readLengthCodedString('utf8') + : JSON.parse(packet.readLengthCodedString('utf8')); + case Types.LONGLONG: + if (!supportBigNumbers) + return unsigned + ? packet.readInt64JSNumber() + : packet.readSInt64JSNumber(); + return bigNumberStrings + ? unsigned + ? packet.readInt64String() + : packet.readSInt64String() + : unsigned + ? packet.readInt64() + : packet.readSInt64(); + default: + return field.characterSet === Charsets.BINARY + ? packet.readLengthCodedBuffer() + : packet.readLengthCodedString(fields[fieldNum].encoding); + } + } + + return class BinaryRow { + constructor() {} + + next(packet, fields, options) { + packet.readInt8(); // status byte + + const nullBitmapLength = Math.floor((fields.length + 7 + 2) / 8); + const nullBitmaskBytes = new Array(nullBitmapLength); + + for (let i = 0; i < nullBitmapLength; i++) { + nullBitmaskBytes[i] = packet.readInt8(); + } + + const result = options.rowsAsArray ? new Array(fields.length) : {}; + let currentFieldNullBit = 4; + let nullByteIndex = 0; + + for (let i = 0; i < fields.length; i++) { + const field = fields[i]; + const typeCast = + options.typeCast !== undefined ? options.typeCast : config.typeCast; + + let value; + if (nullBitmaskBytes[nullByteIndex] & currentFieldNullBit) { + value = null; + } else if (options.typeCast === false) { + value = packet.readLengthCodedBuffer(); + } else { + const next = () => readCode(field, config, options, i, packet); + value = + typeof typeCast === 'function' + ? typeCast( + { + type: typeNames[field.columnType], + length: field.columnLength, + db: field.schema, + table: field.table, + name: field.name, + string: function (encoding = field.encoding) { + if ( + field.columnType === Types.JSON && + encoding === field.encoding + ) { + // Since for JSON columns mysql always returns charset 63 (BINARY), + // we have to handle it according to JSON specs and use "utf8", + // see https://github.com/sidorares/node-mysql2/issues/1661 + console.warn( + `typeCast: JSON column "${field.name}" is interpreted as BINARY by default, recommended to manually set utf8 encoding: \`field.string("utf8")\``, + ); + } + + if ( + [ + Types.DATETIME, + Types.NEWDATE, + Types.TIMESTAMP, + Types.DATE, + ].includes(field.columnType) + ) { + return packet.readDateTimeString( + parseInt(field.decimals, 10), + ); + } + + if (field.columnType === Types.TINY) { + const unsigned = field.flags & FieldFlags.UNSIGNED; + + return String( + unsigned ? packet.readInt8() : packet.readSInt8(), + ); + } + + if (field.columnType === Types.TIME) { + return packet.readTimeString(); + } + + return packet.readLengthCodedString(encoding); + }, + buffer: function () { + return packet.readLengthCodedBuffer(); + }, + geometry: function () { + return packet.parseGeometryValue(); + }, + }, + next, + ) + : next(); + } + + if (options.rowsAsArray) { + result[i] = value; + } else if (typeof options.nestTables === 'string') { + const key = helpers.fieldEscape( + field.table + options.nestTables + field.name, + false, + ); + result[key] = value; + } else if (options.nestTables === true) { + const tableName = helpers.fieldEscape(field.table, false); + if (!result[tableName]) { + result[tableName] = {}; + } + const fieldName = helpers.fieldEscape(field.name, false); + result[tableName][fieldName] = value; + } else { + const key = helpers.fieldEscape(field.name, false); + result[key] = value; + } + + currentFieldNullBit *= 2; + if (currentFieldNullBit === 0x100) { + currentFieldNullBit = 1; + nullByteIndex++; + } + } + + return result; + } + }; +} + +module.exports = getBinaryParser; diff --git a/lib/parsers/static_text_parser.js b/lib/parsers/static_text_parser.js new file mode 100644 index 0000000000..69a606e3f5 --- /dev/null +++ b/lib/parsers/static_text_parser.js @@ -0,0 +1,150 @@ +'use strict'; + +const Types = require('../constants/types.js'); +const Charsets = require('../constants/charsets.js'); +const helpers = require('../helpers'); + +const typeNames = []; +for (const t in Types) { + typeNames[Types[t]] = t; +} + +function readField({ packet, type, charset, encoding, config, options }) { + const supportBigNumbers = Boolean( + options.supportBigNumbers || config.supportBigNumbers, + ); + const bigNumberStrings = Boolean( + options.bigNumberStrings || config.bigNumberStrings, + ); + const timezone = options.timezone || config.timezone; + const dateStrings = options.dateStrings || config.dateStrings; + + switch (type) { + case Types.TINY: + case Types.SHORT: + case Types.LONG: + case Types.INT24: + case Types.YEAR: + return packet.parseLengthCodedIntNoBigCheck(); + case Types.LONGLONG: + if (supportBigNumbers && bigNumberStrings) { + return packet.parseLengthCodedIntString(); + } + return packet.parseLengthCodedInt(supportBigNumbers); + case Types.FLOAT: + case Types.DOUBLE: + return packet.parseLengthCodedFloat(); + case Types.NULL: + case Types.DECIMAL: + case Types.NEWDECIMAL: + if (config.decimalNumbers) { + return packet.parseLengthCodedFloat(); + } + return packet.readLengthCodedString('ascii'); + case Types.DATE: + if (helpers.typeMatch(type, dateStrings, Types)) { + return packet.readLengthCodedString('ascii'); + } + return packet.parseDate(timezone); + case Types.DATETIME: + case Types.TIMESTAMP: + if (helpers.typeMatch(type, dateStrings, Types)) { + return packet.readLengthCodedString('ascii'); + } + return packet.parseDateTime(timezone); + case Types.TIME: + return packet.readLengthCodedString('ascii'); + case Types.GEOMETRY: + return packet.parseGeometryValue(); + case Types.JSON: + // Since for JSON columns mysql always returns charset 63 (BINARY), + // we have to handle it according to JSON specs and use "utf8", + // see https://github.com/sidorares/node-mysql2/issues/409 + return config.jsonStrings + ? packet.readLengthCodedString('utf8') + : JSON.parse(packet.readLengthCodedString('utf8')); + default: + if (charset === Charsets.BINARY) { + return packet.readLengthCodedBuffer(); + } + return packet.readLengthCodedString(encoding); + } +} + +function createTypecastField(field, packet) { + return { + type: typeNames[field.columnType], + length: field.columnLength, + db: field.schema, + table: field.table, + name: field.name, + string: function (encoding = field.encoding) { + if (field.columnType === Types.JSON && encoding === field.encoding) { + // Since for JSON columns mysql always returns charset 63 (BINARY), + // we have to handle it according to JSON specs and use "utf8", + // see https://github.com/sidorares/node-mysql2/issues/1661 + console.warn( + `typeCast: JSON column "${field.name}" is interpreted as BINARY by default, recommended to manually set utf8 encoding: \`field.string("utf8")\``, + ); + } + return packet.readLengthCodedString(encoding); + }, + buffer: function () { + return packet.readLengthCodedBuffer(); + }, + geometry: function () { + return packet.parseGeometryValue(); + }, + }; +} + +function getTextParser(_fields, _options, config) { + return { + next(packet, fields, options) { + const result = options.rowsAsArray ? [] : {}; + for (let i = 0; i < fields.length; i++) { + const field = fields[i]; + const typeCast = options.typeCast ? options.typeCast : config.typeCast; + const next = () => + readField({ + packet, + type: field.columnType, + encoding: field.encoding, + charset: field.characterSet, + config, + options, + }); + + let value; + + if (options.typeCast === false) { + value = packet.readLengthCodedBuffer(); + } else if (typeof typeCast === 'function') { + value = typeCast(createTypecastField(field, packet), next); + } else { + value = next(); + } + + if (options.rowsAsArray) { + result.push(value); + } else if (typeof options.nestTables === 'string') { + result[ + `${helpers.fieldEscape(field.table, false)}${options.nestTables}${helpers.fieldEscape(field.name, false)}` + ] = value; + } else if (options.nestTables) { + const tableName = helpers.fieldEscape(field.table, false); + if (!result[tableName]) { + result[tableName] = {}; + } + result[tableName][helpers.fieldEscape(field.name, false)] = value; + } else { + result[helpers.fieldEscape(field.name, false)] = value; + } + } + + return result; + }, + }; +} + +module.exports = getTextParser; diff --git a/package.json b/package.json index 18c11a6248..d076a4adf0 100644 --- a/package.json +++ b/package.json @@ -8,10 +8,11 @@ "scripts": { "lint": "npm run lint:docs && npm run lint:code", "lint:code": "eslint index.js promise.js index.d.ts promise.d.ts \"typings/**/*.ts\" \"lib/**/*.js\" \"test/**/*.{js,cjs,mjs,ts}\" \"benchmarks/**/*.js\"", + "lint:fix": "npm run lint:docs -- --fix &&npm run lint:code -- --fix", "lint:docs": "eslint Contributing.md README.md", "lint:typings": "npx prettier --check ./typings", "lint:tests": "npx prettier --check ./test", - "test": "poku -d --sequential test/esm test/unit test/integration", + "test": "poku -d -r=verbose --sequential test/esm test/unit test/integration", "test:bun": "bun poku -d --sequential test/esm test/unit test/integration", "test:deno": "deno run --allow-read --allow-env --allow-run npm:poku -d --sequential --denoAllow=\"read,env,net,sys\" test/esm test/unit test/integration", "test:tsc-build": "cd \"test/tsc-build\" && npx tsc -p \"tsconfig.json\"", diff --git a/test/common.test.cjs b/test/common.test.cjs index 1fd36df344..387cf997e7 100644 --- a/test/common.test.cjs +++ b/test/common.test.cjs @@ -4,6 +4,9 @@ const fs = require('node:fs'); const path = require('node:path'); const process = require('node:process'); +const disableEval = process.env.STATIC_PARSER === '1'; +exports.disableEval = disableEval; + const config = { host: process.env.MYSQL_HOST || 'localhost', user: process.env.MYSQL_USER || 'root', @@ -11,6 +14,7 @@ const config = { database: process.env.MYSQL_DATABASE || 'test', compress: process.env.MYSQL_USE_COMPRESSION, port: process.env.MYSQL_PORT || 3306, + disableEval, }; if (process.env.MYSQL_USE_TLS === '1') { @@ -109,6 +113,7 @@ exports.createConnection = function (args) { nestTables: args && args.nestTables, ssl: (args && args.ssl) ?? config.ssl, jsonStrings: args && args.jsonStrings, + disableEval, }; const conn = driver.createConnection(params); @@ -139,6 +144,7 @@ exports.getConfig = function (input) { maxIdle: args && args.maxIdle, idleTimeout: args && args.idleTimeout, jsonStrings: args && args.jsonStrings, + disableEval, }; return params; }; diff --git a/test/integration/connection/test-nested-tables-query.test.cjs b/test/integration/connection/test-nested-tables-query.test.cjs index 13b47f06b0..85ca2ebb49 100644 --- a/test/integration/connection/test-nested-tables-query.test.cjs +++ b/test/integration/connection/test-nested-tables-query.test.cjs @@ -119,39 +119,47 @@ connection.execute(options3, (err, _rows) => { }); process.on('exit', () => { - assert.equal(rows1.length, 1); - assert.equal(rows1[0].nested_test.id, 1); - assert.equal(rows1[0].nested_test.title, 'test'); - assert.equal(rows2.length, 1); - assert.equal(rows2[0].nested_test_id, 1); - assert.equal(rows2[0].nested_test_title, 'test'); - - assert.equal(Array.isArray(rows3[0]), true); - assert.equal(rows3[0][0], 1); - assert.equal(rows3[0][1], 'test'); - - assert.equal(rows4.length, 1); - assert.deepEqual(rows4[0], { - nested: { - title: 'test1', + assert.equal(rows1.length, 1, 'First row length'); + assert.equal(rows1[0].nested_test.id, 1, 'First row nested id'); + assert.equal(rows1[0].nested_test.title, 'test', 'First row nested title'); + assert.equal(rows2.length, 1, 'Second row length'); + assert.equal(rows2[0].nested_test_id, 1, 'Second row nested id'); + assert.equal(rows2[0].nested_test_title, 'test', 'Second row nested title'); + + assert.equal(Array.isArray(rows3[0]), true, 'Third row type'); + assert.equal(rows3[0][0], 1, 'Third row value 1'); + assert.equal(rows3[0][1], 'test', 'Third row value 2'); + + assert.equal(rows4.length, 1, 'Fourth row length'); + assert.deepEqual( + rows4[0], + { + nested: { + title: 'test1', + }, + notNested: { + id: 1, + title: 'test', + }, }, - notNested: { - id: 1, - title: 'test', + 'Fourth row value', + ); + assert.equal(rows5.length, 1, 'Fifth row length'); + assert.deepEqual( + rows5[0], + { + nested2: { + title: 'test1', + }, + notNested: { + id: 1, + title: 'test', + }, }, - }); - assert.equal(rows5.length, 1); - assert.deepEqual(rows5[0], { - nested2: { - title: 'test1', - }, - notNested: { - id: 1, - title: 'test', - }, - }); + 'Fifth row value', + ); - assert.deepEqual(rows1, rows1e); - assert.deepEqual(rows2, rows2e); - assert.deepEqual(rows3, rows3e); + assert.deepEqual(rows1, rows1e, 'Compare rows1 with rows1e'); + assert.deepEqual(rows2, rows2e, 'Compare rows2 with rows2e'); + assert.deepEqual(rows3, rows3e, 'Compare rows3 with rows3e'); }); diff --git a/test/integration/connection/test-typecast.test.cjs b/test/integration/connection/test-typecast.test.cjs index a018ab9712..f093a33133 100644 --- a/test/integration/connection/test-typecast.test.cjs +++ b/test/integration/connection/test-typecast.test.cjs @@ -44,7 +44,7 @@ connection.query( }, (err, res) => { assert.ifError(err); - assert(Buffer.isBuffer(res[0].foo)); + assert(Buffer.isBuffer(res[0].foo), 'Check for Buffer'); assert.equal(res[0].foo.toString('utf8'), 'foobar'); }, ); diff --git a/test/integration/regressions/test-#433.test.cjs b/test/integration/regressions/test-#433.test.cjs index dfaba673a0..700571d2dc 100644 --- a/test/integration/regressions/test-#433.test.cjs +++ b/test/integration/regressions/test-#433.test.cjs @@ -64,9 +64,12 @@ connection.query( ); /* eslint quotes: 0 */ -const expectedError = +const expectedErrorMysql = "You have an error in your SQL syntax; check the manual that corresponds to your MySQL server version for the right syntax to use near '`МояТаблица' at line 1"; +const expectedErrorMariaDB = + "You have an error in your SQL syntax; check the manual that corresponds to your MariaDB server version for the right syntax to use near '`МояТаблица' at line 1"; + process.on('exit', () => { testRows.map((tRow, index) => { const cols = testFields; @@ -77,5 +80,9 @@ process.on('exit', () => { assert.equal(aRow[cols[3]], tRow[3]); }); - assert.equal(actualError, expectedError); + if (connection._handshakePacket.serverVersion.match(/MariaDB/)) { + assert.equal(actualError, expectedErrorMariaDB); + } else { + assert.equal(actualError, expectedErrorMysql); + } }); diff --git a/typings/mysql/lib/Connection.d.ts b/typings/mysql/lib/Connection.d.ts index 6af95e1f7d..d55ae7aa59 100644 --- a/typings/mysql/lib/Connection.d.ts +++ b/typings/mysql/lib/Connection.d.ts @@ -323,6 +323,8 @@ export interface ConnectionOptions { waitForConnections?: boolean; + disableEval?: boolean; + authPlugins?: { [key: string]: AuthPlugin; };