From 1b31c523bddc926b5a019a4aaa3c13493307c49f Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 17 Sep 2026 12:58:21 +0000 Subject: [PATCH] feat(mongo): record collection operations as normalized query events The mongo hook records each Collection method as a function call with its actual arguments. That is fine for reading one AppMap, but it keeps Mongo operations out of everything that works on sql_query events: the digest, the query diff in change reports, and the Database lifeline in sequence diagrams. Each recorded operation now also gets a sql_query event, nested under the function call the way the Prisma hook nests the SQL it observes. The event has database_type "mongodb" and a statement in shell form with a normalized argument shape: db.users.updateOne({"_id": ?}, {"$set": {"name": ?}}, {"upsert": ?}) Keys and operators are kept in order, every leaf value becomes `?`, arrays collapse to their distinct element shapes (so an insertMany of a thousand documents of one shape is one statement, and `$in` lists do not vary by length), and aggregation and update pipelines keep every stage in order. Name arguments (a distinct field, an index name, a new collection name) stay verbatim. BSON values, class instances, Buffers, functions and cyclic or very deep structures are leaves. The rules are written down at the top of src/hooks/mongoQuery.ts; the Java agent implements the same rules. The query return event is emitted when the driver hands back its promise and is fixed up with the real elapsed time, or turned into an exception event, when the promise settles. Cursors (find, aggregate, listIndexes, watch) are not promises, so their return stays as emitted. Methods the driver implements on top of other Collection methods (findOne calls find) produce one query event, for the outer call only. The fixture now also covers insertMany, find with an operator and options, aggregate, createIndex, a caught duplicate key error, and deleteMany. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01LntddoBsqjRDRBepBx7oLZ --- src/hooks/__tests__/mongoQuery.test.ts | 260 +++++++++++ src/hooks/mongo.ts | 118 ++++- src/hooks/mongoQuery.ts | 194 +++++++++ test/__snapshots__/mongo.test.ts.snap | 581 ++++++++++++++++++++++++- test/mongo/index.js | 30 ++ 5 files changed, 1143 insertions(+), 40 deletions(-) create mode 100644 src/hooks/__tests__/mongoQuery.test.ts create mode 100644 src/hooks/mongoQuery.ts 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(); }