diff --git a/.yarn/cache/@types-mysql-npm-2.15.24-a96562dcc7-6ff9e444be.zip b/.yarn/cache/@types-mysql-npm-2.15.24-a96562dcc7-6ff9e444be.zip new file mode 100644 index 00000000..ed41af99 Binary files /dev/null and b/.yarn/cache/@types-mysql-npm-2.15.24-a96562dcc7-6ff9e444be.zip differ diff --git a/.yarn/cache/bignumber.js-npm-9.0.0-ce190bcd7c-51f37890bc.zip b/.yarn/cache/bignumber.js-npm-9.0.0-ce190bcd7c-51f37890bc.zip new file mode 100644 index 00000000..bad025ba Binary files /dev/null and b/.yarn/cache/bignumber.js-npm-9.0.0-ce190bcd7c-51f37890bc.zip differ diff --git a/.yarn/cache/mysql-npm-2.18.1-8fdb56201f-430dec8525.zip b/.yarn/cache/mysql-npm-2.18.1-8fdb56201f-430dec8525.zip new file mode 100644 index 00000000..c63c53f1 Binary files /dev/null and b/.yarn/cache/mysql-npm-2.18.1-8fdb56201f-430dec8525.zip differ diff --git a/.yarn/cache/readable-stream-npm-2.3.7-77b22a9818-e4920cf754.zip b/.yarn/cache/readable-stream-npm-2.3.7-77b22a9818-e4920cf754.zip new file mode 100644 index 00000000..eb8e6e00 Binary files /dev/null and b/.yarn/cache/readable-stream-npm-2.3.7-77b22a9818-e4920cf754.zip differ diff --git a/.yarn/cache/sqlstring-npm-2.3.1-2d4ffafe98-de4299cf9b.zip b/.yarn/cache/sqlstring-npm-2.3.1-2d4ffafe98-de4299cf9b.zip new file mode 100644 index 00000000..a83813c6 Binary files /dev/null and b/.yarn/cache/sqlstring-npm-2.3.1-2d4ffafe98-de4299cf9b.zip differ diff --git a/package.json b/package.json index d63423f2..0eb78516 100644 --- a/package.json +++ b/package.json @@ -41,6 +41,7 @@ "@types/caller": "^1.0.0", "@types/convert-source-map": "^2.0.1", "@types/jest": "^29.5.4", + "@types/mysql": "^2.15.24", "@types/node": "^20.5.0", "@types/pg": "^8.10.9", "@types/sqlite3": "^3.1.11", @@ -74,6 +75,7 @@ "test/jest", "test/mocha", "test/vitest", + "test/mysql", "test/postgres", "test/sqlite" ] diff --git a/src/hooks/mysql.ts b/src/hooks/mysql.ts new file mode 100644 index 00000000..0025a90e --- /dev/null +++ b/src/hooks/mysql.ts @@ -0,0 +1,66 @@ +import assert from "node:assert"; + +import type mysql from "mysql"; + +import { recording } from "../recorder"; +import { getTime } from "../util/getTime"; + +export default function mysqlHook(mod: typeof mysql) { + // mysql.Connection type is an interface, create a dummy connection + // object to access its prototype. + const connection = mod.createConnection({}); + const prototype: unknown = Object.getPrototypeOf(connection); + assert(prototype != undefined && typeof prototype === "object" && "query" in prototype); + prototype.query = createQueryProxy(prototype.query as mysql.QueryFunction); + + return mod; +} + +mysqlHook.applicable = function (id: string) { + return id === "mysql"; +}; + +function hasStringSqlProperty(o: unknown): o is { sql: string } { + return o !== null && typeof o === "object" && "sql" in o && typeof o.sql === "string"; +} + +function createQueryProxy(query: mysql.QueryFunction) { + return new Proxy(query, { + apply(target, thisArg, argArray: Parameters) { + // We have to cover these Connection.query call variants: + // - query(sql, callback?) + // - query(sql, values, callback?) + // - query(options, callback?) + // - query(options, values, callback?), + // where + // - sql: string + // - options: {sql: string, ...} + // - callback: (error, results, fields) => void + + // Pool.query and PoolNamespace.query methods use Connection.query + // https://github.com/mysqljs/mysql/blob/dc9c152a87ec51a1f647447268917243d2eab1fd/lib/Pool.js#L214 + // https://github.com/mysqljs/mysql/blob/dc9c152a87ec51a1f647447268917243d2eab1fd/lib/PoolNamespace.js#L111 + + const sql: string = hasStringSqlProperty(argArray[0]) ? argArray[0].sql : argArray[0]; + + const call = recording.sqlQuery("mysql", sql); + const start = getTime(); + + const originalCallback = + typeof argArray[argArray.length - 1] === "function" + ? (argArray.pop() as mysql.queryCallback) + : undefined; + + const newCallback: mysql.queryCallback = (err, results, fields) => { + if (err) recording.functionException(call.id, err, getTime() - start); + else recording.functionReturn(call.id, undefined, getTime() - start); + + originalCallback?.call(this, err, results, fields); + }; + + argArray.push(newCallback); + + return Reflect.apply(target, thisArg, argArray); + }, + }); +} diff --git a/src/requireHook.ts b/src/requireHook.ts index d240d980..dbd7c0fa 100644 --- a/src/requireHook.ts +++ b/src/requireHook.ts @@ -1,4 +1,5 @@ import httpHook from "./hooks/http"; +import mysqlHook from "./hooks/mysql"; import pgHook from "./hooks/pg"; import sqliteHook from "./hooks/sqlite"; @@ -8,7 +9,7 @@ interface Hook { applicable(id: string): boolean; } -const hooks: Hook[] = [httpHook, pgHook, sqliteHook]; +const hooks: Hook[] = [httpHook, mysqlHook, pgHook, sqliteHook]; export default function requireHook( original: NodeJS.Require, diff --git a/test/__snapshots__/mysql.test.ts.snap b/test/__snapshots__/mysql.test.ts.snap new file mode 100644 index 00000000..dd67b88c --- /dev/null +++ b/test/__snapshots__/mysql.test.ts.snap @@ -0,0 +1,176 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`mapping MySQL tests 1`] = ` +{ + "classMap": [ + { + "children": [ + { + "children": [ + { + "location": "./index.js:17", + "name": "main", + "static": true, + "type": "function", + }, + ], + "name": "index", + "type": "class", + }, + ], + "name": "index", + "type": "package", + }, + ], + "eventUpdates": { + "3": { + "elapsed": 31.337, + "event": "return", + "id": 3, + "parent_id": 1, + "return_value": { + "class": "Promise", + "object_id": 1, + "value": "Promise { undefined }", + }, + "thread_id": 0, + }, + }, + "events": [ + { + "defined_class": "", + "event": "call", + "id": 1, + "lineno": 17, + "method_id": "main", + "parameters": [], + "path": "./index.js", + "static": true, + "thread_id": 0, + }, + { + "event": "call", + "id": 2, + "sql_query": { + "database_type": "mysql", + "sql": "SELECT 'Connection.query'", + }, + "thread_id": 0, + }, + { + "elapsed": 31.337, + "event": "return", + "id": 3, + "parent_id": 1, + "return_value": { + "class": "Promise", + "object_id": 1, + "value": "Promise { }", + }, + "thread_id": 0, + }, + { + "elapsed": 31.337, + "event": "return", + "exceptions": [ + { + "class": "Error", + "message": "connect ECONNREFUSED 127.0.0.1:3306", + "object_id": 2, + }, + ], + "id": 4, + "parent_id": 2, + "thread_id": 0, + }, + { + "event": "call", + "id": 5, + "sql_query": { + "database_type": "mysql", + "sql": "SELECT 'Connection.query with values', ?, ?", + }, + "thread_id": 0, + }, + { + "elapsed": 31.337, + "event": "return", + "exceptions": [ + { + "class": "Error", + "message": "connect ECONNREFUSED 127.0.0.1:3306", + "object_id": 3, + }, + ], + "id": 6, + "parent_id": 5, + "thread_id": 0, + }, + { + "event": "call", + "id": 7, + "sql_query": { + "database_type": "mysql", + "sql": "SELECT 'Connection.query with options'", + }, + "thread_id": 0, + }, + { + "elapsed": 31.337, + "event": "return", + "exceptions": [ + { + "class": "Error", + "message": "connect ECONNREFUSED 127.0.0.1:3306", + "object_id": 4, + }, + ], + "id": 8, + "parent_id": 7, + "thread_id": 0, + }, + { + "event": "call", + "id": 9, + "sql_query": { + "database_type": "mysql", + "sql": "SELECT 'Connection.query without a callback", + }, + "thread_id": 0, + }, + { + "elapsed": 31.337, + "event": "return", + "exceptions": [ + { + "class": "Error", + "message": "connect ECONNREFUSED 127.0.0.1:3306", + "object_id": 5, + }, + ], + "id": 10, + "parent_id": 9, + "thread_id": 0, + }, + ], + "metadata": { + "app": "mysql-appmap-node-test", + "client": { + "name": "appmap-node", + "url": "https://github.com/getappmap/appmap-node", + "version": "test node-appmap version", + }, + "language": { + "engine": "Node.js", + "name": "javascript", + "version": "test node version", + }, + "name": "test process recording", + "recorder": { + "name": "process", + "type": "process", + }, + }, + "version": "1.12", +} +`; diff --git a/test/mysql.test.ts b/test/mysql.test.ts new file mode 100644 index 00000000..c12e2e36 --- /dev/null +++ b/test/mysql.test.ts @@ -0,0 +1,6 @@ +import { integrationTest, readAppmap, runAppmapNode } from "./helpers"; + +integrationTest("mapping MySQL tests", () => { + expect(runAppmapNode("index.js").status).toBe(0); + expect(readAppmap()).toMatchSnapshot(); +}); diff --git a/test/mysql/index.js b/test/mysql/index.js new file mode 100644 index 00000000..40294c4b --- /dev/null +++ b/test/mysql/index.js @@ -0,0 +1,39 @@ +/* eslint-disable @typescript-eslint/no-var-requires */ +const mysql = require("mysql"); + +const promisify = async (method, thisArg, ...args) => { + return new Promise((resolve) => { + method.call(thisArg, ...args, (error, result) => { + // Since we don't have a MySQL server, we will + // receive the error in every call. We intentionally + // don't reject here. + resolve(result); + }); + }); +}; + +const newConnection = () => mysql.createConnection({ host: "127.0.0.1" }); + +async function main() { + for (const args of [ + ["SELECT 'Connection.query'"], + ["SELECT 'Connection.query with values', ?, ?", [1, "ABC"]], + [{ sql: "SELECT 'Connection.query with options'" }], + ]) { + // Create new connection for each query because we don't have + // a MySQL server. We get a fatal error after calling the query + // method second time in the same connection and we don't want to + // have different exception events for first query (connect ECONNREFUSED 127.0.0.1:3306) + // and the remaining queries (Cannot enqueue Query after fatal error) + // in the appmap. + const conn = newConnection(); + await promisify(conn.query, conn, ...args); + } + + // We cannot test commands being called without a completion callback with promisify + // because promisify already provides the completion callback to resolve the promise. + // We test this case with no completion callback here without promisifying it. + newConnection().query("SELECT 'Connection.query without a callback"); +} + +main(); diff --git a/test/mysql/package.json b/test/mysql/package.json new file mode 100644 index 00000000..d1b647ca --- /dev/null +++ b/test/mysql/package.json @@ -0,0 +1,8 @@ +{ + "name": "mysql-appmap-node-test", + "packageManager": "yarn@3.6.3", + "private": true, + "dependencies": { + "mysql": "^2.18.1" + } +} diff --git a/yarn.lock b/yarn.lock index 5224a963..2ace6435 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2039,6 +2039,15 @@ __metadata: languageName: node linkType: hard +"@types/mysql@npm:^2.15.24": + version: 2.15.24 + resolution: "@types/mysql@npm:2.15.24" + dependencies: + "@types/node": "*" + checksum: 6ff9e444beaf911373cbe3fb19e84090617e0f41bf57e79e630c01c56b26099a90d0588418ed697c032aa7a8e00662ff53332f8d15df2d1ac03b1270a870027b + languageName: node + linkType: hard + "@types/node@npm:*, @types/node@npm:^20.5.0": version: 20.5.0 resolution: "@types/node@npm:20.5.0" @@ -2534,6 +2543,7 @@ __metadata: "@types/caller": ^1.0.0 "@types/convert-source-map": ^2.0.1 "@types/jest": ^29.5.4 + "@types/mysql": ^2.15.24 "@types/node": ^20.5.0 "@types/pg": ^8.10.9 "@types/sqlite3": ^3.1.11 @@ -2779,6 +2789,13 @@ __metadata: languageName: node linkType: hard +"bignumber.js@npm:9.0.0": + version: 9.0.0 + resolution: "bignumber.js@npm:9.0.0" + checksum: 51f37890bca58bded63720add832b1c4898cf5b8ad95b5d4d9c3e763c461163d0355d11d91b740b0216b02e4e8cbb02455b28ee32140b775d96a39bbd817fdf6 + languageName: node + linkType: hard + "bin-links@npm:^4.0.1": version: 4.0.2 resolution: "bin-links@npm:4.0.2" @@ -6832,6 +6849,26 @@ __metadata: languageName: node linkType: hard +"mysql-appmap-node-test@workspace:test/mysql": + version: 0.0.0-use.local + resolution: "mysql-appmap-node-test@workspace:test/mysql" + dependencies: + mysql: ^2.18.1 + languageName: unknown + linkType: soft + +"mysql@npm:^2.18.1": + version: 2.18.1 + resolution: "mysql@npm:2.18.1" + dependencies: + bignumber.js: 9.0.0 + readable-stream: 2.3.7 + safe-buffer: 5.1.2 + sqlstring: 2.3.1 + checksum: 430dec8525e849bbb53f78ffc7aa85b2d1535f49f96ae06064089219cfabfdb9b4051e1fabcbacc0e5b85174ae4c762b071b99d4fb4d062e98f90e723b1def0a + languageName: node + linkType: hard + "nanoid@npm:3.3.3": version: 3.3.3 resolution: "nanoid@npm:3.3.3" @@ -8200,6 +8237,21 @@ __metadata: languageName: node linkType: hard +"readable-stream@npm:2.3.7": + version: 2.3.7 + resolution: "readable-stream@npm:2.3.7" + dependencies: + core-util-is: ~1.0.0 + inherits: ~2.0.3 + isarray: ~1.0.0 + process-nextick-args: ~2.0.0 + safe-buffer: ~5.1.1 + string_decoder: ~1.1.1 + util-deprecate: ~1.0.1 + checksum: e4920cf7549a60f8aaf694d483a0e61b2a878b969d224f89b3bc788b8d920075132c4b55a7494ee944c7b6a9a0eada28a7f6220d80b0312ece70bbf08eeca755 + languageName: node + linkType: hard + "readable-stream@npm:^2.0.0, readable-stream@npm:^2.0.2, readable-stream@npm:~2.3.6": version: 2.3.8 resolution: "readable-stream@npm:2.3.8" @@ -8422,6 +8474,13 @@ __metadata: languageName: node linkType: hard +"safe-buffer@npm:5.1.2, safe-buffer@npm:~5.1.0, safe-buffer@npm:~5.1.1": + version: 5.1.2 + resolution: "safe-buffer@npm:5.1.2" + checksum: f2f1f7943ca44a594893a852894055cf619c1fbcb611237fc39e461ae751187e7baf4dc391a72125e0ac4fb2d8c5c0b3c71529622e6a58f46b960211e704903c + languageName: node + linkType: hard + "safe-buffer@npm:5.2.1, safe-buffer@npm:^5.1.0, safe-buffer@npm:~5.2.0": version: 5.2.1 resolution: "safe-buffer@npm:5.2.1" @@ -8429,13 +8488,6 @@ __metadata: languageName: node linkType: hard -"safe-buffer@npm:~5.1.0, safe-buffer@npm:~5.1.1": - version: 5.1.2 - resolution: "safe-buffer@npm:5.1.2" - checksum: f2f1f7943ca44a594893a852894055cf619c1fbcb611237fc39e461ae751187e7baf4dc391a72125e0ac4fb2d8c5c0b3c71529622e6a58f46b960211e704903c - languageName: node - linkType: hard - "safer-buffer@npm:>= 2.1.2 < 3, safer-buffer@npm:>= 2.1.2 < 3.0.0": version: 2.1.2 resolution: "safer-buffer@npm:2.1.2" @@ -8840,6 +8892,13 @@ __metadata: languageName: node linkType: hard +"sqlstring@npm:2.3.1": + version: 2.3.1 + resolution: "sqlstring@npm:2.3.1" + checksum: de4299cf9bd0f49abae5b4eddde42c7ae7c447e035498ec50b3264610d6f0efbe433eeed2d20d48b6362bf46fd96c85cf6db240e994dbe6d5c3f9dac6d7ffd31 + languageName: node + linkType: hard + "ssri@npm:^10.0.0, ssri@npm:^10.0.5": version: 10.0.5 resolution: "ssri@npm:10.0.5"