Skip to content

Commit

Permalink
feat: Support for MySQL
Browse files Browse the repository at this point in the history
  • Loading branch information
zermelo-wisen authored and dividedmind committed Nov 28, 2023
1 parent 0af4023 commit 6edcfd8
Show file tree
Hide file tree
Showing 13 changed files with 365 additions and 8 deletions.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -74,6 +75,7 @@
"test/jest",
"test/mocha",
"test/vitest",
"test/mysql",
"test/postgres",
"test/sqlite"
]
Expand Down
66 changes: 66 additions & 0 deletions src/hooks/mysql.ts
Original file line number Diff line number Diff line change
@@ -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<typeof query>) {
// 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);
},
});
}
3 changes: 2 additions & 1 deletion src/requireHook.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import httpHook from "./hooks/http";
import mysqlHook from "./hooks/mysql";
import pgHook from "./hooks/pg";
import sqliteHook from "./hooks/sqlite";

Expand All @@ -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,
Expand Down
176 changes: 176 additions & 0 deletions test/__snapshots__/mysql.test.ts.snap
Original file line number Diff line number Diff line change
@@ -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 { <pending> }",
},
"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",
}
`;
6 changes: 6 additions & 0 deletions test/mysql.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import { integrationTest, readAppmap, runAppmapNode } from "./helpers";

integrationTest("mapping MySQL tests", () => {
expect(runAppmapNode("index.js").status).toBe(0);
expect(readAppmap()).toMatchSnapshot();
});
39 changes: 39 additions & 0 deletions test/mysql/index.js
Original file line number Diff line number Diff line change
@@ -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();
8 changes: 8 additions & 0 deletions test/mysql/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
{
"name": "mysql-appmap-node-test",
"packageManager": "yarn@3.6.3",
"private": true,
"dependencies": {
"mysql": "^2.18.1"
}
}
Loading

0 comments on commit 6edcfd8

Please sign in to comment.