diff --git a/src/hooks/__tests__/mongoQuery.test.ts b/src/hooks/__tests__/mongoQuery.test.ts new file mode 100644 index 0000000..09a092f --- /dev/null +++ b/src/hooks/__tests__/mongoQuery.test.ts @@ -0,0 +1,260 @@ +import { + MAX_ARRAY_ELEMENTS, + MAX_DEPTH, + formatCollection, + formatMongoStatement, + shape, +} from "../mongoQuery"; + +class FakeObjectId { + constructor(public readonly hex = "507f1f77bcf86cd799439011") {} +} + +describe(formatCollection, () => { + it("uses shell dot syntax for identifier-like names", () => { + expect(formatCollection("users")).toBe("db.users"); + expect(formatCollection("users.archive")).toBe("db.users.archive"); + expect(formatCollection("_tmp$1")).toBe("db._tmp$1"); + }); + + it("falls back to getCollection for other names", () => { + expect(formatCollection("my-coll")).toBe('db.getCollection("my-coll")'); + expect(formatCollection("with space")).toBe('db.getCollection("with space")'); + expect(formatCollection("")).toBe('db.getCollection("")'); + expect(formatCollection('q"uote')).toBe('db.getCollection("q\\"uote")'); + expect(formatCollection("a..b")).toBe('db.getCollection("a..b")'); + }); +}); + +describe(shape, () => { + it("keeps keys and replaces leaves", () => { + expect(shape({ a: 1, b: "x", c: null, d: undefined, e: true })).toBe( + '{"a": ?, "b": ?, "c": ?, "d": ?, "e": ?}', + ); + }); + + it("keeps key order and nesting", () => { + expect(shape({ z: { y: { x: 1 } }, a: 2 })).toBe('{"z": {"y": {"x": ?}}, "a": ?}'); + }); + + it("keeps operators, since they are keys", () => { + expect(shape({ age: { $gt: 18, $lt: 65 }, $or: [{ a: 1 }, { b: 2 }] })).toBe( + '{"age": {"$gt": ?, "$lt": ?}, "$or": [{"a": ?}, {"b": ?}]}', + ); + }); + + it("collapses arrays to their distinct element shapes", () => { + expect(shape({ $in: [1, 2, 3] })).toBe('{"$in": [?]}'); + expect(shape([{ a: 1 }, { a: 2 }, { b: 3 }, { a: 4 }])).toBe('[{"a": ?}, {"b": ?}]'); + expect(shape([])).toBe("[]"); + expect(shape({})).toBe("{}"); + }); + + it("keeps every element of an ordered array", () => { + expect(shape([{ $unwind: "$a" }, { $unwind: "$b" }], true)).toBe( + '[{"$unwind": ?}, {"$unwind": ?}]', + ); + // only the top level is ordered; nested arrays are still collapsed + expect(shape([{ $match: { a: { $in: [1, 2] } } }], true)).toBe( + '[{"$match": {"a": {"$in": [?]}}}]', + ); + }); + + it("treats class instances and BSON-like values as leaves", () => { + expect( + shape({ + _id: new FakeObjectId(), + at: new Date(), + re: /abc/, + buf: Buffer.from("x"), + fn: () => 1, + big: 10n, + sym: Symbol("s"), + }), + ).toBe('{"_id": ?, "at": ?, "re": ?, "buf": ?, "fn": ?, "big": ?, "sym": ?}'); + }); + + it("treats Maps as documents", () => { + expect( + shape( + new Map([ + ["a", 1], + [2, { b: 3 }], + ]), + ), + ).toBe('{"a": ?, "2": {"b": ?}}'); + }); + + it("handles objects without a prototype", () => { + const doc = Object.create(null) as Record; + doc.a = 1; + expect(shape(doc)).toBe('{"a": ?}'); + }); + + it("escapes keys", () => { + expect(shape({ 'he said "hi"': 1, "a.b": 2, "": 3, "\n": 4 })).toBe( + '{"he said \\"hi\\"": ?, "a.b": ?, "": ?, "\\n": ?}', + ); + }); + + it("does not recurse into cycles", () => { + const doc: Record = { a: 1 }; + doc.self = doc; + const arr: unknown[] = [1]; + arr.push(arr); + doc.arr = arr; + expect(shape(doc)).toBe('{"a": ?, "self": ?, "arr": [?]}'); + }); + + it("allows the same object in two places", () => { + const inner = { x: 1 }; + expect(shape({ a: inner, b: inner })).toBe('{"a": {"x": ?}, "b": {"x": ?}}'); + }); + + it("stops at the depth limit", () => { + let doc: unknown = 1; + for (let i = 0; i < MAX_DEPTH + 5; i++) doc = { n: doc }; + const rendered = shape(doc); + expect(rendered.endsWith('"n": ?' + "}".repeat(MAX_DEPTH + 1))).toBe(true); + expect(rendered.startsWith('{"n": '.repeat(MAX_DEPTH + 1))).toBe(true); + }); + + it("examines at most MAX_ARRAY_ELEMENTS elements", () => { + const arr = new Array(MAX_ARRAY_ELEMENTS).fill({ a: 1 }); + arr.push({ b: 2 }); + expect(shape(arr)).toBe('[{"a": ?}]'); + }); + + it("ignores symbol keys and inherited properties", () => { + const doc = { a: 1, [Symbol("s")]: 2 }; + expect(shape(doc)).toBe('{"a": ?}'); + }); +}); + +describe(formatMongoStatement, () => { + it("renders documents, updates and options", () => { + expect( + formatMongoStatement( + "users", + "updateOne", + ["filter", "update", "options"], + [{ _id: new FakeObjectId() }, { $set: { name: "x" }, $inc: { n: 1 } }, { upsert: true }], + ), + ).toBe( + 'db.users.updateOne({"_id": ?}, {"$set": {"name": ?}, "$inc": {"n": ?}}, {"upsert": ?})', + ); + }); + + it("omits trailing undefined arguments", () => { + expect(formatMongoStatement("users", "find", ["filter", "options"], [])).toBe( + "db.users.find()", + ); + expect(formatMongoStatement("users", "find", ["filter", "options"], [{}, undefined])).toBe( + "db.users.find({})", + ); + expect( + formatMongoStatement("users", "find", ["filter", "options"], [undefined, { limit: 1 }]), + ).toBe('db.users.find(?, {"limit": ?})'); + }); + + it("ignores arguments beyond the declared parameters", () => { + expect(formatMongoStatement("users", "drop", ["options"], [{}, "extra", 1])).toBe( + "db.users.drop({})", + ); + }); + + it("keeps aggregation pipelines in order", () => { + expect( + formatMongoStatement( + "orders", + "aggregate", + ["pipeline", "options"], + [ + [ + { $match: { status: "A" } }, + { $unwind: "$items" }, + { $unwind: "$items.parts" }, + { $group: { _id: "$cust", total: { $sum: "$amount" } } }, + ], + ], + ), + ).toBe( + 'db.orders.aggregate([{"$match": {"status": ?}}, {"$unwind": ?}, {"$unwind": ?}, {"$group": {"_id": ?, "total": {"$sum": ?}}}])', + ); + }); + + it("keeps update pipelines in order", () => { + expect( + formatMongoStatement( + "users", + "updateMany", + ["filter", "update", "options"], + [{}, [{ $set: { a: 1 } }, { $set: { b: 2 } }]], + ), + ).toBe('db.users.updateMany({}, [{"$set": {"a": ?}}, {"$set": {"b": ?}}])'); + }); + + it("collapses lists of documents and operations", () => { + expect( + formatMongoStatement("users", "insertMany", ["docs", "options"], [[{ a: 1 }, { a: 2 }]]), + ).toBe('db.users.insertMany([{"a": ?}])'); + expect( + formatMongoStatement( + "users", + "bulkWrite", + ["operations", "options"], + [ + [ + { insertOne: { document: { a: 1 } } }, + { insertOne: { document: { a: 2 } } }, + { updateOne: { filter: { a: 1 }, update: { $set: { b: 1 } } } }, + ], + ], + ), + ).toBe( + 'db.users.bulkWrite([{"insertOne": {"document": {"a": ?}}}, {"updateOne": {"filter": {"a": ?}, "update": {"$set": {"b": ?}}}}])', + ); + }); + + it("keeps names verbatim", () => { + expect( + formatMongoStatement("users", "distinct", ["key", "filter", "options"], ["email", { a: 1 }]), + ).toBe('db.users.distinct("email", {"a": ?})'); + expect(formatMongoStatement("users", "rename", ["newName", "options"], ["people"])).toBe( + 'db.users.rename("people")', + ); + expect(formatMongoStatement("users", "dropIndex", ["indexName", "options"], ["a_1"])).toBe( + 'db.users.dropIndex("a_1")', + ); + expect( + formatMongoStatement("users", "indexExists", ["indexes", "options"], [["a_1", "b_1"]]), + ).toBe('db.users.indexExists(["a_1", "b_1"])'); + // a name argument that is not a string is rendered as a shape + expect(formatMongoStatement("users", "dropIndex", ["indexName", "options"], [{ a: 1 }])).toBe( + 'db.users.dropIndex({"a": ?})', + ); + expect(formatMongoStatement("users", "distinct", ["key", "filter"], [["a", 1]])).toBe( + "db.users.distinct([?])", + ); + }); + + it("renders index specs as documents", () => { + expect( + formatMongoStatement( + "users", + "createIndex", + ["indexSpec", "options"], + [{ email: 1 }, { unique: true, name: "email_idx" }], + ), + ).toBe('db.users.createIndex({"email": ?}, {"unique": ?, "name": ?})'); + }); + + it("never throws on hostile arguments", () => { + const hostile = { + get a(): number { + throw new Error("boom"); + }, + }; + expect(formatMongoStatement("users", "find", ["filter"], [hostile])).toBe("db.users.find(?)"); + }); +}); diff --git a/src/hooks/mongo.ts b/src/hooks/mongo.ts index 7c2d0ac..2eedc45 100644 --- a/src/hooks/mongo.ts +++ b/src/hooks/mongo.ts @@ -2,11 +2,15 @@ import { inspect } from "node:util"; import type mongodb from "mongodb"; +import { makeExceptionEvent, makeReturnEvent } from "../event"; import { identifier } from "../generate"; import { getActiveRecordings, isActive } from "../recorder"; +import type Recording from "../Recording"; import { FunctionInfo } from "../registry"; import { getTime } from "../util/getTime"; import { setCustomInspect } from "../parameter"; +import type * as AppMap from "../AppMap"; +import { DATABASE_TYPE, formatMongoStatement } from "./mongoQuery"; export default function mongoHook(mod: typeof mongodb) { const collectionMethods: Partial, readonly string[]>> = @@ -82,6 +86,20 @@ function functionInfo(name: string, collection: string, argnames: readonly strin // use custom inspect so IDs are rendered properly const customInspect = (v: unknown) => inspect(v, { customInspect: true }); +// Some Collection methods are implemented on top of others (findOne calls +// find). The inner call still gets its function call event, but only the +// outermost operation gets a query event, so one logical operation is one +// query. The driver makes these inner calls synchronously, before the outer +// method returns its promise, so a depth counter is enough. +let operationDepth = 0; + +// Each recorded Collection method produces two events: a function call +// (mongodb/., with the actual arguments) and, nested under +// it, a sql_query event holding the normalized statement (see ./mongoQuery.ts). +// The query event is what lets Mongo operations appear next to SQL in digests +// and diffs. It is returned as soon as the driver hands back a promise, and +// the return event is fixed up with the real elapsed time (or the rejection) +// when the promise settles. function patchMethod>( obj: typeof mongodb.Collection.prototype, methodName: K, @@ -101,50 +119,72 @@ function patchMethod>( const funInfo = functionInfo(methodName, this.collectionName, argNames); const callback = extractOptionalCallback(args); + const nested = operationDepth > 0; + const statement = nested + ? undefined + : formatMongoStatement(this.collectionName, methodName, argNames, args); + if (callback) { const functionCallArgs = args.map((x) => setCustomInspect(x, customInspect)); const callEvents = recordings.map((recording) => recording.functionCall(funInfo, this, functionCallArgs), ); + const queryEvents = queryCallEvents(recordings, statement); const startTime = getTime(); args.push((err: unknown, res: unknown) => { setCustomInspect(res, customInspect); if (err) - recordings.forEach( - (recording, idx) => - isActive(recording) && - recording.functionException(callEvents[idx].id, err, startTime), - ); + recordings.forEach((recording, idx) => { + if (!isActive(recording)) return; + if (queryEvents) recording.functionException(queryEvents[idx].id, err, startTime); + recording.functionException(callEvents[idx].id, err, startTime); + }); else - recordings.forEach( - (recording, idx) => - isActive(recording) && recording.functionReturn(callEvents[idx].id, res, startTime), - ); + recordings.forEach((recording, idx) => { + if (!isActive(recording)) return; + if (queryEvents) recording.functionReturn(queryEvents[idx].id, undefined, startTime); + recording.functionReturn(callEvents[idx].id, res, startTime); + }); return callback(err, res) as unknown; }); - return Reflect.apply(original, this, args) as ReturnType; + operationDepth++; + try { + return Reflect.apply(original, this, args) as ReturnType; + } finally { + operationDepth--; + } } const callEvents = recordings.map((recording) => recording.functionCall(funInfo, this, args)); + const queryEvents = queryCallEvents(recordings, statement); const startTime = getTime(); + let result: ReturnType; + operationDepth++; try { - const result = Reflect.apply(original, this, args) as ReturnType; - void setCustomInspect(result, customInspect); - - recordings.forEach((recording, idx) => - recording.functionReturn(callEvents[idx].id, result, startTime), - ); - return result; + result = Reflect.apply(original, this, args) as ReturnType; } catch (exn: unknown) { - recordings.map((recording, idx) => - recording.functionException(callEvents[idx].id, exn, startTime), - ); + recordings.forEach((recording, idx) => { + if (queryEvents) recording.functionException(queryEvents[idx].id, exn, startTime); + recording.functionException(callEvents[idx].id, exn, startTime); + }); throw exn; + } finally { + operationDepth--; } + + void setCustomInspect(result, customInspect); + recordings.forEach((recording, idx) => { + if (queryEvents) { + const queryReturn = recording.functionReturn(queryEvents[idx].id, undefined, startTime); + settleQueryReturn(recording, queryReturn, result, startTime); + } + recording.functionReturn(callEvents[idx].id, result, startTime); + }); + return result; }; markPatched(patched); @@ -152,6 +192,44 @@ function patchMethod>( obj[methodName] = patched as typeof original; } +function queryCallEvents( + recordings: Recording[], + statement: string | undefined, +): AppMap.SqlQueryEvent[] | undefined { + if (statement === undefined) return undefined; + return recordings.map((recording) => recording.sqlQuery(DATABASE_TYPE, statement)); +} + +// When the operation is asynchronous, the query return event has already been +// emitted with a near-zero elapsed time. Once the promise settles, replace it +// with the real duration, or with an exception event if the query failed. +// Cursors (find, aggregate, listIndexes, watch) are not promises: their query +// is issued lazily, so the return event stays as emitted. +function settleQueryReturn( + recording: Recording, + returnEvent: AppMap.FunctionReturnEvent, + result: unknown, + startTime: number, +) { + if (!isPromiseLike(result)) return; + const parentId = returnEvent.parent_id; + result.then( + () => + recording.fixup(makeReturnEvent(returnEvent.id, parentId, undefined, getTime() - startTime)), + (reason: unknown) => + recording.fixup(makeExceptionEvent(returnEvent.id, parentId, reason, getTime() - startTime)), + ); +} + +function isPromiseLike(value: unknown): value is PromiseLike { + return ( + typeof value === "object" && + value !== null && + "then" in value && + typeof (value as { then: unknown }).then === "function" + ); +} + function extractOptionalCallback(args: unknown[]): FunctionLike | undefined { if (typeof args.at(-1) === "function") return args.pop() as FunctionLike; } diff --git a/src/hooks/mongoQuery.ts b/src/hooks/mongoQuery.ts new file mode 100644 index 0000000..842859c --- /dev/null +++ b/src/hooks/mongoQuery.ts @@ -0,0 +1,194 @@ +// Renders a MongoDB collection operation as a query statement with a +// normalized argument shape, so that it can be recorded as a sql_query event +// (database_type "mongodb") and compared across recordings the way SQL is. +// +// The same rules are implemented by the Java agent +// (com.appland.appmap.process.hooks.MongoQueryShape). Keep them in sync. +// +// Statement form: +// +// db..(, , ...) +// +// - The collection is written `db.name` when the name is a plain identifier +// path, and `db.getCollection("name")` otherwise. +// - Arguments are rendered in the order of the driver method's parameters. +// Trailing undefined arguments are omitted. +// - Documents (filters, updates, replacements, inserted documents, index +// specs, options) keep their keys, in order, and every leaf value becomes +// `?`. Keys are written as JSON strings. +// - Arrays inside documents, and top-level lists of documents (insertMany, +// bulkWrite, createIndexes), keep only the distinct element shapes, in order +// of first appearance. `{"$in": [1, 2, 3]}` becomes `{"$in": [?]}`, and a +// thousand inserted documents of the same shape become one. +// - Aggregation pipelines (aggregate, watch, and update pipelines) keep every +// stage in order, because stage order and repetition are part of the query. +// - Name-like arguments (a distinct field, an index name, a new collection +// name) are kept verbatim as JSON strings; they identify the query the same +// way a table or column name does. +// - Everything that is not a plain object, Map or array (BSON types such as +// ObjectId, Date, Decimal128 and Binary, class instances, functions, +// primitives, null and undefined) is a leaf and becomes `?`. +// - Nesting deeper than MAX_DEPTH, and cyclic references, become `?`. Only the +// first MAX_ARRAY_ELEMENTS elements of an array are examined. + +export const DATABASE_TYPE = "mongodb"; + +export const MAX_DEPTH = 32; +export const MAX_ARRAY_ELEMENTS = 1000; + +const PLACEHOLDER = "?"; + +type ArgKind = "document" | "pipeline" | "name" | "options"; + +// How each named argument of a Collection method is rendered. Names come from +// the method table in ./mongo.ts. +const ARG_KINDS: Record = { + doc: "document", + docs: "document", + filter: "document", + update: "document", + replacement: "document", + operations: "document", + indexSpec: "document", + indexSpecs: "document", + pipeline: "pipeline", + key: "name", + newName: "name", + indexName: "name", + indexes: "name", + options: "options", +}; + +/** + * Formats a collection method call as a normalized statement. + * Never throws: if the arguments cannot be inspected the statement is + * rendered with a single `?` in place of the argument list. + */ +export function formatMongoStatement( + collection: string, + method: string, + argNames: readonly string[], + args: readonly unknown[], +): string { + const prefix = `${formatCollection(collection)}.${method}`; + try { + return `${prefix}(${formatArgs(argNames, args)})`; + } catch { + return `${prefix}(${PLACEHOLDER})`; + } +} + +const IDENTIFIER_PATH = /^[A-Za-z_$][\w$]*(\.[A-Za-z_$][\w$]*)*$/; + +export function formatCollection(name: string): string { + if (IDENTIFIER_PATH.test(name)) return `db.${name}`; + return `db.getCollection(${JSON.stringify(name)})`; +} + +function formatArgs(argNames: readonly string[], args: readonly unknown[]): string { + const count = Math.min(argNames.length, args.length); + let last = count; + while (last > 0 && args[last - 1] === undefined) last--; + + const rendered: string[] = []; + for (let i = 0; i < last; i++) rendered.push(formatArg(argNames[i], args[i])); + return rendered.join(", "); +} + +function formatArg(name: string, value: unknown): string { + const kind = ARG_KINDS[name] ?? "document"; + switch (kind) { + case "name": + if (typeof value === "string") return JSON.stringify(value); + if (Array.isArray(value) && value.every((v) => typeof v === "string")) + return `[${value.map((v) => JSON.stringify(v)).join(", ")}]`; + return shape(value, false); + case "pipeline": + return shape(value, true); + case "document": + // An update can be a pipeline (an array of stages) instead of a document. + return shape(value, name === "update" && Array.isArray(value)); + case "options": + return shape(value, false); + } +} + +/** + * Renders the shape of a value: keys kept, leaves replaced with `?`. + * @param ordered when true, a top-level array keeps all of its elements in + * order (a pipeline); otherwise distinct element shapes are kept. + */ +export function shape(value: unknown, ordered = false): string { + return shapeOf(value, ordered, 0, new Set()); +} + +function shapeOf(value: unknown, ordered: boolean, depth: number, ancestors: Set): string { + if (depth > MAX_DEPTH) return PLACEHOLDER; + + if (Array.isArray(value)) { + if (ancestors.has(value)) return PLACEHOLDER; + ancestors.add(value); + try { + return shapeOfArray(value, ordered, depth, ancestors); + } finally { + ancestors.delete(value); + } + } + + const entries = documentEntries(value); + if (!entries) return PLACEHOLDER; + if (ancestors.has(value as object)) return PLACEHOLDER; + ancestors.add(value as object); + try { + const parts: string[] = []; + for (const [key, val] of entries) + parts.push(`${JSON.stringify(key)}: ${shapeOf(val, false, depth + 1, ancestors)}`); + return `{${parts.join(", ")}}`; + } finally { + ancestors.delete(value as object); + } +} + +function shapeOfArray( + value: unknown[], + ordered: boolean, + depth: number, + ancestors: Set, +): string { + const limit = Math.min(value.length, MAX_ARRAY_ELEMENTS); + const parts: string[] = []; + const seen = new Set(); + for (let i = 0; i < limit; i++) { + const part = shapeOf(value[i], false, depth + 1, ancestors); + if (ordered) parts.push(part); + else if (!seen.has(part)) { + seen.add(part); + parts.push(part); + } + } + return `[${parts.join(", ")}]`; +} + +// Returns the entries of a document-like value, or undefined for a leaf. +function documentEntries(value: unknown): Iterable<[string, unknown]> | undefined { + if (value === null || typeof value !== "object") return undefined; + if (value instanceof Map) + return [...value.entries()].map(([k, v]): [string, unknown] => [String(k), v]); + if (!isPlainObject(value)) return undefined; + return Object.keys(value).map((key): [string, unknown] => [ + key, + (value as Record)[key], + ]); +} + +// A document is a plain object: created by a literal, Object.create(null), or +// the Object constructor of another realm. Instances of any other class +// (ObjectId, Date, Buffer, driver sessions, user models) are leaves. +function isPlainObject(value: object): boolean { + const proto: unknown = Object.getPrototypeOf(value); + if (proto === null || proto === Object.prototype) return true; + const ctor: unknown = (proto as { constructor?: unknown }).constructor; + return ( + typeof ctor === "function" && ctor.name === "Object" && Object.getPrototypeOf(proto) === null + ); +} diff --git a/test/__snapshots__/mongo.test.ts.snap b/test/__snapshots__/mongo.test.ts.snap index a4b6445..619c7da 100644 --- a/test/__snapshots__/mongo.test.ts.snap +++ b/test/__snapshots__/mongo.test.ts.snap @@ -15,6 +15,12 @@ exports[`mapping MongoDB tests 1`] = ` "static": false, "type": "function", }, + { + "location": "mongodb/test:10", + "name": "deleteMany", + "static": false, + "type": "function", + }, { "location": "mongodb/test:2", "name": "insertOne", @@ -45,6 +51,24 @@ exports[`mapping MongoDB tests 1`] = ` "static": false, "type": "function", }, + { + "location": "mongodb/test:7", + "name": "insertMany", + "static": false, + "type": "function", + }, + { + "location": "mongodb/test:8", + "name": "aggregate", + "static": false, + "type": "function", + }, + { + "location": "mongodb/test:9", + "name": "createIndex", + "static": false, + "type": "function", + }, ], "name": "test", "type": "class", @@ -98,10 +122,26 @@ exports[`mapping MongoDB tests 1`] = ` "static": false, "thread_id": 0, }, + { + "event": "call", + "id": 3, + "sql_query": { + "database_type": "mongodb", + "sql": "db.test.drop()", + }, + "thread_id": 0, + }, { "elapsed": 31.337, "event": "return", - "id": 3, + "id": 4, + "parent_id": 3, + "thread_id": 0, + }, + { + "elapsed": 31.337, + "event": "return", + "id": 5, "parent_id": 2, "return_value": { "class": "Promise", @@ -113,7 +153,7 @@ exports[`mapping MongoDB tests 1`] = ` { "defined_class": "test", "event": "call", - "id": 5, + "id": 7, "lineno": 2, "method_id": "insertOne", "parameters": [ @@ -139,11 +179,27 @@ exports[`mapping MongoDB tests 1`] = ` "static": false, "thread_id": 0, }, + { + "event": "call", + "id": 8, + "sql_query": { + "database_type": "mongodb", + "sql": "db.test.insertOne({"a": ?})", + }, + "thread_id": 0, + }, { "elapsed": 31.337, "event": "return", - "id": 6, - "parent_id": 5, + "id": 9, + "parent_id": 8, + "thread_id": 0, + }, + { + "elapsed": 31.337, + "event": "return", + "id": 10, + "parent_id": 7, "return_value": { "class": "Promise", "object_id": 5, @@ -157,7 +213,7 @@ exports[`mapping MongoDB tests 1`] = ` { "defined_class": "test", "event": "call", - "id": 7, + "id": 11, "lineno": 2, "method_id": "insertOne", "parameters": [ @@ -183,11 +239,27 @@ exports[`mapping MongoDB tests 1`] = ` "static": false, "thread_id": 0, }, + { + "event": "call", + "id": 12, + "sql_query": { + "database_type": "mongodb", + "sql": "db.test.insertOne({"a": ?})", + }, + "thread_id": 0, + }, { "elapsed": 31.337, "event": "return", - "id": 8, - "parent_id": 7, + "id": 13, + "parent_id": 12, + "thread_id": 0, + }, + { + "elapsed": 31.337, + "event": "return", + "id": 14, + "parent_id": 11, "return_value": { "class": "Promise", "object_id": 7, @@ -201,7 +273,7 @@ exports[`mapping MongoDB tests 1`] = ` { "defined_class": "test", "event": "call", - "id": 9, + "id": 15, "lineno": 3, "method_id": "updateOne", "parameters": [ @@ -245,11 +317,27 @@ exports[`mapping MongoDB tests 1`] = ` "static": false, "thread_id": 0, }, + { + "event": "call", + "id": 16, + "sql_query": { + "database_type": "mongodb", + "sql": "db.test.updateOne({"a": ?}, {"$set": {"a": ?}})", + }, + "thread_id": 0, + }, { "elapsed": 31.337, "event": "return", - "id": 10, - "parent_id": 9, + "id": 17, + "parent_id": 16, + "thread_id": 0, + }, + { + "elapsed": 31.337, + "event": "return", + "id": 18, + "parent_id": 15, "return_value": { "class": "Promise", "object_id": 10, @@ -266,7 +354,7 @@ exports[`mapping MongoDB tests 1`] = ` { "defined_class": "test", "event": "call", - "id": 11, + "id": 19, "lineno": 4, "method_id": "findOne", "parameters": [ @@ -292,10 +380,19 @@ exports[`mapping MongoDB tests 1`] = ` "static": false, "thread_id": 0, }, + { + "event": "call", + "id": 20, + "sql_query": { + "database_type": "mongodb", + "sql": "db.test.findOne({"a": ?})", + }, + "thread_id": 0, + }, { "defined_class": "test", "event": "call", - "id": 12, + "id": 21, "lineno": 5, "method_id": "find", "parameters": [ @@ -331,8 +428,8 @@ exports[`mapping MongoDB tests 1`] = ` { "elapsed": 31.337, "event": "return", - "id": 13, - "parent_id": 12, + "id": 22, + "parent_id": 21, "return_value": { "class": "FindCursor", "object_id": 13, @@ -343,8 +440,15 @@ exports[`mapping MongoDB tests 1`] = ` { "elapsed": 31.337, "event": "return", - "id": 14, - "parent_id": 11, + "id": 23, + "parent_id": 20, + "thread_id": 0, + }, + { + "elapsed": 31.337, + "event": "return", + "id": 24, + "parent_id": 19, "return_value": { "class": "Promise", "object_id": 14, @@ -355,7 +459,7 @@ exports[`mapping MongoDB tests 1`] = ` { "defined_class": "test", "event": "call", - "id": 15, + "id": 25, "lineno": 6, "method_id": "countDocuments", "parameters": [], @@ -368,11 +472,27 @@ exports[`mapping MongoDB tests 1`] = ` "static": false, "thread_id": 0, }, + { + "event": "call", + "id": 26, + "sql_query": { + "database_type": "mongodb", + "sql": "db.test.countDocuments()", + }, + "thread_id": 0, + }, { "elapsed": 31.337, "event": "return", - "id": 16, - "parent_id": 15, + "id": 27, + "parent_id": 26, + "thread_id": 0, + }, + { + "elapsed": 31.337, + "event": "return", + "id": 28, + "parent_id": 25, "return_value": { "class": "Promise", "object_id": 15, @@ -380,10 +500,431 @@ exports[`mapping MongoDB tests 1`] = ` }, "thread_id": 0, }, + { + "defined_class": "test", + "event": "call", + "id": 29, + "lineno": 7, + "method_id": "insertMany", + "parameters": [ + { + "class": "Array", + "items": { + "class": "Object", + "properties": [ + { + "class": "Number", + "name": "a", + }, + { + "class": "Array", + "items": { + "class": "String", + }, + "name": "tags", + }, + ], + }, + "name": "docs", + "object_id": 16, + "size": 2, + "value": "[ { a: 4, tags: [Array] }, { a: 5, tags: [Array] } ]", + }, + ], + "path": "mongodb/test", + "receiver": { + "class": "Collection", + "object_id": 1, + "value": "[Collection test]", + }, + "static": false, + "thread_id": 0, + }, + { + "event": "call", + "id": 30, + "sql_query": { + "database_type": "mongodb", + "sql": "db.test.insertMany([{"a": ?, "tags": [?]}])", + }, + "thread_id": 0, + }, { "elapsed": 31.337, "event": "return", - "id": 4, + "id": 31, + "parent_id": 30, + "thread_id": 0, + }, + { + "elapsed": 31.337, + "event": "return", + "id": 32, + "parent_id": 29, + "return_value": { + "class": "Promise", + "object_id": 17, + "value": "Promise { { + acknowledged: true, + insertedCount: 2, + insertedIds: { '0': [ObjectId], '1': [ObjectId] } +} }", + }, + "thread_id": 0, + }, + { + "defined_class": "test", + "event": "call", + "id": 33, + "lineno": 5, + "method_id": "find", + "parameters": [ + { + "class": "Object", + "name": "filter", + "object_id": 18, + "properties": [ + { + "class": "Object", + "name": "a", + "properties": [ + { + "class": "Array", + "items": { + "class": "Number", + }, + "name": "$in", + }, + ], + }, + ], + "value": "{ a: { '$in': [Array] } }", + }, + { + "class": "Object", + "name": "options", + "object_id": 19, + "properties": [ + { + "class": "Object", + "name": "sort", + "properties": [ + { + "class": "Number", + "name": "a", + }, + ], + }, + { + "class": "Object", + "name": "projection", + "properties": [ + { + "class": "Number", + "name": "_id", + }, + ], + }, + ], + "value": "{ sort: { a: -1 }, projection: { _id: 0 } }", + }, + ], + "path": "mongodb/test", + "receiver": { + "class": "Collection", + "object_id": 1, + "value": "[Collection test]", + }, + "static": false, + "thread_id": 0, + }, + { + "event": "call", + "id": 34, + "sql_query": { + "database_type": "mongodb", + "sql": "db.test.find({"a": {"$in": [?]}}, {"sort": {"a": ?}, "projection": {"_id": ?}})", + }, + "thread_id": 0, + }, + { + "elapsed": 31.337, + "event": "return", + "id": 35, + "parent_id": 34, + "thread_id": 0, + }, + { + "elapsed": 31.337, + "event": "return", + "id": 36, + "parent_id": 33, + "return_value": { + "class": "FindCursor", + "object_id": 20, + "value": "[FindCursor appmap-node.test]", + }, + "thread_id": 0, + }, + { + "defined_class": "test", + "event": "call", + "id": 37, + "lineno": 8, + "method_id": "aggregate", + "parameters": [ + { + "class": "Array", + "name": "pipeline", + "object_id": 21, + "size": 2, + "value": "[ { '$match': [Object] }, { '$group': [Object] } ]", + }, + ], + "path": "mongodb/test", + "receiver": { + "class": "Collection", + "object_id": 1, + "value": "[Collection test]", + }, + "static": false, + "thread_id": 0, + }, + { + "event": "call", + "id": 38, + "sql_query": { + "database_type": "mongodb", + "sql": "db.test.aggregate([{"$match": {"a": {"$gte": ?}}}, {"$group": {"_id": ?, "total": {"$sum": ?}}}])", + }, + "thread_id": 0, + }, + { + "elapsed": 31.337, + "event": "return", + "id": 39, + "parent_id": 38, + "thread_id": 0, + }, + { + "elapsed": 31.337, + "event": "return", + "id": 40, + "parent_id": 37, + "return_value": { + "class": "AggregationCursor", + "object_id": 22, + "value": "[AggregationCursor appmap-node.test]", + }, + "thread_id": 0, + }, + { + "defined_class": "test", + "event": "call", + "id": 41, + "lineno": 9, + "method_id": "createIndex", + "parameters": [ + { + "class": "Object", + "name": "indexSpec", + "object_id": 23, + "properties": [ + { + "class": "Number", + "name": "a", + }, + ], + "value": "{ a: 1 }", + }, + { + "class": "Object", + "name": "options", + "object_id": 24, + "properties": [ + { + "class": "Boolean", + "name": "unique", + }, + ], + "value": "{ unique: true }", + }, + ], + "path": "mongodb/test", + "receiver": { + "class": "Collection", + "object_id": 1, + "value": "[Collection test]", + }, + "static": false, + "thread_id": 0, + }, + { + "event": "call", + "id": 42, + "sql_query": { + "database_type": "mongodb", + "sql": "db.test.createIndex({"a": ?}, {"unique": ?})", + }, + "thread_id": 0, + }, + { + "elapsed": 31.337, + "event": "return", + "id": 43, + "parent_id": 42, + "thread_id": 0, + }, + { + "elapsed": 31.337, + "event": "return", + "id": 44, + "parent_id": 41, + "return_value": { + "class": "Promise", + "object_id": 25, + "value": "Promise { 'a_1' }", + }, + "thread_id": 0, + }, + { + "defined_class": "test", + "event": "call", + "id": 45, + "lineno": 2, + "method_id": "insertOne", + "parameters": [ + { + "class": "Object", + "name": "doc", + "object_id": 26, + "properties": [ + { + "class": "Number", + "name": "a", + }, + ], + "value": "{ a: 3 }", + }, + ], + "path": "mongodb/test", + "receiver": { + "class": "Collection", + "object_id": 1, + "value": "[Collection test]", + }, + "static": false, + "thread_id": 0, + }, + { + "event": "call", + "id": 46, + "sql_query": { + "database_type": "mongodb", + "sql": "db.test.insertOne({"a": ?})", + }, + "thread_id": 0, + }, + { + "elapsed": 31.337, + "event": "return", + "exceptions": [ + { + "class": "MongoServerError", + "message": "E11000 duplicate key error collection: appmap-node.test index: a_1 dup key: { a: 3 }", + "object_id": 28, + }, + ], + "id": 47, + "parent_id": 46, + "thread_id": 0, + }, + { + "elapsed": 31.337, + "event": "return", + "exceptions": [ + { + "class": "MongoServerError", + "message": "E11000 duplicate key error collection: appmap-node.test index: a_1 dup key: { a: 3 }", + "object_id": 28, + }, + ], + "id": 48, + "parent_id": 45, + "return_value": { + "class": "Promise", + "object_id": 27, + "value": "Promise { }", + }, + "thread_id": 0, + }, + { + "defined_class": "test", + "event": "call", + "id": 49, + "lineno": 10, + "method_id": "deleteMany", + "parameters": [ + { + "class": "Object", + "name": "filter", + "object_id": 29, + "properties": [ + { + "class": "Object", + "name": "a", + "properties": [ + { + "class": "Number", + "name": "$gte", + }, + ], + }, + ], + "value": "{ a: { '$gte': 4 } }", + }, + ], + "path": "mongodb/test", + "receiver": { + "class": "Collection", + "object_id": 1, + "value": "[Collection test]", + }, + "static": false, + "thread_id": 0, + }, + { + "event": "call", + "id": 50, + "sql_query": { + "database_type": "mongodb", + "sql": "db.test.deleteMany({"a": {"$gte": ?}})", + }, + "thread_id": 0, + }, + { + "elapsed": 31.337, + "event": "return", + "id": 51, + "parent_id": 50, + "thread_id": 0, + }, + { + "elapsed": 31.337, + "event": "return", + "id": 52, + "parent_id": 49, + "return_value": { + "class": "Promise", + "object_id": 30, + "value": "Promise { { acknowledged: true, deletedCount: 2 } }", + }, + "thread_id": 0, + }, + { + "elapsed": 31.337, + "event": "return", + "id": 6, "parent_id": 1, "return_value": { "class": "Promise", diff --git a/test/mongo/index.js b/test/mongo/index.js index 30b9a87..7078de9 100644 --- a/test/mongo/index.js +++ b/test/mongo/index.js @@ -17,5 +17,35 @@ async function work() { console.log(result); // { a: 3 } console.log(await collection.countDocuments()); + // Documents of the same shape collapse to one in the query event. + await collection.insertMany([ + { a: 4, tags: ["x"] }, + { a: 5, tags: ["y", "z"] }, + ]); + + // A find with an operator and options; the cursor is consumed with toArray. + console.log( + await collection + .find({ a: { $in: [3, 4, 5] } }, { sort: { a: -1 }, projection: { _id: 0 } }) + .toArray(), + ); + + // A pipeline keeps its stages in order. + console.log( + await collection + .aggregate([{ $match: { a: { $gte: 3 } } }, { $group: { _id: null, total: { $sum: "$a" } } }]) + .toArray(), + ); + + // A failed operation is recorded as an exception on the query as well. + await collection.createIndex({ a: 1 }, { unique: true }); + try { + await collection.insertOne({ a: 3 }); + } catch (error) { + console.log("caught:", error.code); + } + + await collection.deleteMany({ a: { $gte: 4 } }); + await client.close(); }