From e4446e05fa7e1d1a3b8af6759f6a5ff2f00b9034 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 17 Sep 2026 13:08:14 +0000 Subject: [PATCH 1/2] feat: record MongoDB collection operations Port the Node agent's mongo hook to the Java driver's synchronous MongoCollection API (mongodb-driver-sync 3.x, 4.x and 5.x). Each hooked operation is recorded as a function call on the driver's collection class, with parameters named the way the Node agent names them (filter, update, doc, pipeline, ...), and, nested under it, a sql_query event with database_type "mongodb" whose statement is the operation in shell form with a normalized argument shape: db.people.updateOne({"name": ?}, {"$set": {"age": ?}, "$inc": {"visits": ?}}) MongoQueryShape implements the same rules as src/hooks/mongoQuery.ts in appmap-node, so both agents render the same statement for the same operation: keys and operators kept in order, leaves replaced with `?`, arrays collapsed to their distinct element shapes, pipelines kept in order, name arguments verbatim, and limits on depth, cycles and array length. Formatting never throws. The hook has no compile-time dependency on the driver. MongoDocumentConverter reaches the collection's namespace and codec registry by reflection, turns Bson filters and updates into documents with the collection's registry, renders WriteModels the way the Node driver's bulkWrite operations look, and encodes POJO documents with the collection's codec so their fields are the shape. Options objects are opaque and render as `?`. A leading ClientSession and a trailing result Class are recorded as parameters and left out of the statement. The mongo_operation unique key records only the outermost operation on a thread, so a wrapper collection that delegates to the driver is recorded once. The agent/test/mongo fixture runs JUnit tests against mongo-java-server, an in-process MongoDB, so CI needs no service. It pins driver 4.11.5 and mongo-java-server 1.43.0: later server releases need Java 11 or 17, and driver 5.x refuses the wire protocol version 1.43 speaks. mongo.bats asserts the statements, parameter names, nesting and exceptions. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01LntddoBsqjRDRBepBx7oLZ --- .../appland/appmap/process/hooks/Mongo.java | 767 ++++++++++++++++++ .../process/hooks/MongoDocumentConverter.java | 353 ++++++++ .../appmap/process/hooks/MongoQueryShape.java | 345 ++++++++ .../process/hooks/MongoOperationTest.java | 76 ++ .../process/hooks/MongoQueryShapeTest.java | 253 ++++++ .../com/mongodb/session/ClientSession.java | 9 + agent/test/mongo/appmap.yml | 3 + agent/test/mongo/build.gradle | 53 ++ agent/test/mongo/mongo.bats | 125 +++ agent/test/mongo/settings.gradle | 1 + .../main/java/com/example/mongo/Person.java | 42 + .../example/mongo/MongoCollectionTests.java | 171 ++++ 12 files changed, 2198 insertions(+) create mode 100644 agent/src/main/java/com/appland/appmap/process/hooks/Mongo.java create mode 100644 agent/src/main/java/com/appland/appmap/process/hooks/MongoDocumentConverter.java create mode 100644 agent/src/main/java/com/appland/appmap/process/hooks/MongoQueryShape.java create mode 100644 agent/src/test/java/com/appland/appmap/process/hooks/MongoOperationTest.java create mode 100644 agent/src/test/java/com/appland/appmap/process/hooks/MongoQueryShapeTest.java create mode 100644 agent/src/test/java/com/mongodb/session/ClientSession.java create mode 100644 agent/test/mongo/appmap.yml create mode 100644 agent/test/mongo/build.gradle create mode 100644 agent/test/mongo/mongo.bats create mode 100644 agent/test/mongo/settings.gradle create mode 100644 agent/test/mongo/src/main/java/com/example/mongo/Person.java create mode 100644 agent/test/mongo/src/test/java/com/example/mongo/MongoCollectionTests.java diff --git a/agent/src/main/java/com/appland/appmap/process/hooks/Mongo.java b/agent/src/main/java/com/appland/appmap/process/hooks/Mongo.java new file mode 100644 index 00000000..c8fd3625 --- /dev/null +++ b/agent/src/main/java/com/appland/appmap/process/hooks/Mongo.java @@ -0,0 +1,767 @@ +package com.appland.appmap.process.hooks; + +import java.util.HashMap; +import java.util.Map; + +import org.tinylog.TaggedLogger; + +import com.appland.appmap.config.AppMapConfig; +import com.appland.appmap.output.v1.Event; +import com.appland.appmap.record.Recorder; +import com.appland.appmap.transform.annotations.ArgumentArray; +import com.appland.appmap.transform.annotations.HookClass; +import com.appland.appmap.transform.annotations.MethodEvent; +import com.appland.appmap.transform.annotations.Unique; + +/** + * Hooks for the MongoDB Java driver's synchronous {@code MongoCollection} API + * (mongodb-driver-sync 3.x, 4.x and 5.x). + * + *

+ * This is a port of the Node agent's mongo hook. Each collection operation is + * recorded as two events: + *

    + *
  1. a function call on the driver's collection class, with the parameters + * named the way the Node agent names them ({@code filter}, {@code update}, + * {@code doc}, ...) rather than the driver's own parameter names, and
  2. + *
  3. nested under it, a {@code sql_query} event with database_type + * {@code mongodb} whose "sql" is the normalized statement built by + * {@link MongoQueryShape}, so that Mongo operations land in the same digests + * and diffs as SQL queries.
  4. + *
+ * + *

+ * Overloads: a leading {@code ClientSession} argument is recorded as a + * parameter named {@code session} and a trailing result {@code Class} as + * {@code resultClass}; neither takes part in the statement. The remaining + * arguments map onto the Node names positionally. + * + *

+ * {@code find}, {@code aggregate}, {@code watch}, {@code listIndexes} and + * {@code distinct} return lazy iterables; their statement is recorded when the + * iterable is created, so options applied to the iterable afterwards + * ({@code sort}, {@code limit}, {@code projection}) are not part of it. + * + *

+ * The hook has no compile-time dependency on the driver. It reaches the + * collection's namespace and codec registry, and converts {@code Bson} values, + * write models and POJOs to documents, by reflection + * ({@link MongoDocumentConverter}). Any failure there degrades the shape to + * {@code ?}; it never fails the application's call. + * + *

+ * The {@code mongo_operation} unique key keeps a wrapper collection that + * delegates to the driver's implementation from recording the same operation + * twice. Not covered: the legacy {@code DBCollection} API, the reactive + * streams driver, and the Atlas Search index methods. + */ +@Unique("mongo_operation") +public class Mongo { + private static final TaggedLogger logger = AppMapConfig.getLogger(null); + private static final Recorder recorder = Recorder.getInstance(); + + static final String MONGO_COLLECTION = "com.mongodb.client.MongoCollection"; + + /** Parameter names for each hooked method, in the order the Node agent uses. */ + static final Map ARG_NAMES = new HashMap(); + static { + ARG_NAMES.put("insertOne", new String[] { "doc", "options" }); + ARG_NAMES.put("insertMany", new String[] { "docs", "options" }); + ARG_NAMES.put("bulkWrite", new String[] { "operations", "options" }); + ARG_NAMES.put("updateOne", new String[] { "filter", "update", "options" }); + ARG_NAMES.put("replaceOne", new String[] { "filter", "replacement", "options" }); + ARG_NAMES.put("updateMany", new String[] { "filter", "update", "options" }); + ARG_NAMES.put("deleteOne", new String[] { "filter", "options" }); + ARG_NAMES.put("deleteMany", new String[] { "filter", "options" }); + ARG_NAMES.put("renameCollection", new String[] { "newName", "options" }); + ARG_NAMES.put("drop", new String[] { "options" }); + ARG_NAMES.put("find", new String[] { "filter" }); + ARG_NAMES.put("createIndex", new String[] { "indexSpec", "options" }); + ARG_NAMES.put("createIndexes", new String[] { "indexSpecs", "options" }); + ARG_NAMES.put("dropIndex", new String[] { "indexName", "options" }); + ARG_NAMES.put("dropIndexes", new String[] { "options" }); + ARG_NAMES.put("listIndexes", new String[] { }); + ARG_NAMES.put("estimatedDocumentCount", new String[] { "options" }); + ARG_NAMES.put("countDocuments", new String[] { "filter", "options" }); + ARG_NAMES.put("distinct", new String[] { "key", "filter" }); + ARG_NAMES.put("findOneAndDelete", new String[] { "filter", "options" }); + ARG_NAMES.put("findOneAndReplace", new String[] { "filter", "replacement", "options" }); + ARG_NAMES.put("findOneAndUpdate", new String[] { "filter", "update", "options" }); + ARG_NAMES.put("aggregate", new String[] { "pipeline" }); + ARG_NAMES.put("watch", new String[] { "pipeline" }); + } + + // What was recorded for the operation in progress on this thread. The + // unique key above means at most one hooked operation is in progress per + // thread, so a single slot is enough. It prevents a return event from being + // emitted for a call that was never recorded (a recording that started + // while the operation was running), which would unbalance the call stack. + private enum Recorded { + NOTHING, CALL, CALL_AND_QUERY + } + + private static final ThreadLocal recorded = new ThreadLocal() { + @Override + protected Recorded initialValue() { + return Recorded.NOTHING; + } + }; + + static void onCall(Event event, Object self, Object[] args, String method) { + recorded.set(Recorded.NOTHING); + if (!recorder.hasActiveSession()) { + return; + } + + Operation op; + try { + op = Operation.of(self, method, args); + } catch (Throwable e) { + logger.debug(e, "failed to inspect mongo operation {}", method); + op = null; + } + + event.setParameters(null); + if (op != null) { + for (int i = 0; i < op.parameterNames.length; i++) { + event.addParameter(args[i], op.parameterNames[i]); + } + } else if (args != null) { + for (int i = 0; i < args.length; i++) { + event.addParameter(args[i], "arg" + i); + } + } + event.setReceiver(self); + recorder.add(event); + recorded.set(Recorded.CALL); + + if (op == null) { + return; + } + String statement; + try { + statement = MongoQueryShape.formatStatement(op.collection, method, op.argNames, op.operationArgs, + MongoDocumentConverter.forCollection(self)); + } catch (Throwable e) { + logger.debug(e, "failed to format mongo statement for {}", method); + statement = MongoQueryShape.formatCollection(op.collection) + "." + method + "(" + MongoQueryShape.PLACEHOLDER + + ")"; + } + Event query = Event.functionCallEvent(); + query.setSqlQuery(MongoQueryShape.DATABASE_TYPE, statement); + recorder.add(query); + recorded.set(Recorded.CALL_AND_QUERY); + } + + static void onReturn(Event event, Object returnValue) { + Recorded state = recorded.get(); + recorded.set(Recorded.NOTHING); + if (state == Recorded.NOTHING) { + return; + } + if (state == Recorded.CALL_AND_QUERY) { + recorder.add(Event.functionReturnEvent()); + } + event.setReturnValue(returnValue); + recorder.add(event); + } + + static void onException(Event event, Throwable exception) { + Recorded state = recorded.get(); + recorded.set(Recorded.NOTHING); + if (state == Recorded.NOTHING) { + return; + } + if (state == Recorded.CALL_AND_QUERY) { + Event queryReturn = Event.functionReturnEvent(); + queryReturn.setException(exception); + recorder.add(queryReturn); + } + event.setException(exception); + recorder.add(event); + } + + /** A hooked call, split into the parts the statement and the parameters need. */ + static final class Operation { + final String collection; + /** Node-style names for every argument, session and result class included. */ + final String[] parameterNames; + /** Node-style names for the arguments that take part in the statement. */ + final String[] argNames; + /** The arguments that take part in the statement. */ + final Object[] operationArgs; + + private Operation(String collection, String[] parameterNames, String[] argNames, Object[] operationArgs) { + this.collection = collection; + this.parameterNames = parameterNames; + this.argNames = argNames; + this.operationArgs = operationArgs; + } + + static Operation of(Object self, String method, Object[] args) { + if (args == null) { + args = new Object[0]; + } + String[] names = ARG_NAMES.get(method); + if (names == null) { + names = new String[0]; + } + + int first = 0; + int last = args.length; + boolean session = args.length > 0 && MongoDocumentConverter.isClientSession(args[0]); + if (session) { + first = 1; + } + boolean resultClass = last > first && args[last - 1] instanceof Class; + if (resultClass) { + last--; + } + + Object[] operationArgs = new Object[last - first]; + System.arraycopy(args, first, operationArgs, 0, operationArgs.length); + + String[] parameterNames = new String[args.length]; + int n = 0; + if (session) { + parameterNames[n++] = "session"; + } + for (int i = 0; i < operationArgs.length; i++) { + parameterNames[n++] = i < names.length ? names[i] : "arg" + i; + } + if (resultClass) { + parameterNames[n++] = "resultClass"; + } + + return new Operation(MongoDocumentConverter.collectionName(self), parameterNames, names, operationArgs); + } + } + + // --------------------------------------------------------------------------- + // MongoCollection.insertOne + // --------------------------------------------------------------------------- + + @ArgumentArray + @HookClass(MONGO_COLLECTION) + public static void insertOne(Event event, Object self, Object[] args) { + onCall(event, self, args, "insertOne"); + } + + @ArgumentArray + @HookClass(value = MONGO_COLLECTION, methodEvent = MethodEvent.METHOD_RETURN) + public static void insertOne(Event event, Object self, Object returnValue, Object[] args) { + onReturn(event, returnValue); + } + + @ArgumentArray + @HookClass(value = MONGO_COLLECTION, methodEvent = MethodEvent.METHOD_EXCEPTION) + public static void insertOne(Event event, Object self, Throwable exception, Object[] args) { + onException(event, exception); + } + + // --------------------------------------------------------------------------- + // MongoCollection.insertMany + // --------------------------------------------------------------------------- + + @ArgumentArray + @HookClass(MONGO_COLLECTION) + public static void insertMany(Event event, Object self, Object[] args) { + onCall(event, self, args, "insertMany"); + } + + @ArgumentArray + @HookClass(value = MONGO_COLLECTION, methodEvent = MethodEvent.METHOD_RETURN) + public static void insertMany(Event event, Object self, Object returnValue, Object[] args) { + onReturn(event, returnValue); + } + + @ArgumentArray + @HookClass(value = MONGO_COLLECTION, methodEvent = MethodEvent.METHOD_EXCEPTION) + public static void insertMany(Event event, Object self, Throwable exception, Object[] args) { + onException(event, exception); + } + + // --------------------------------------------------------------------------- + // MongoCollection.bulkWrite + // --------------------------------------------------------------------------- + + @ArgumentArray + @HookClass(MONGO_COLLECTION) + public static void bulkWrite(Event event, Object self, Object[] args) { + onCall(event, self, args, "bulkWrite"); + } + + @ArgumentArray + @HookClass(value = MONGO_COLLECTION, methodEvent = MethodEvent.METHOD_RETURN) + public static void bulkWrite(Event event, Object self, Object returnValue, Object[] args) { + onReturn(event, returnValue); + } + + @ArgumentArray + @HookClass(value = MONGO_COLLECTION, methodEvent = MethodEvent.METHOD_EXCEPTION) + public static void bulkWrite(Event event, Object self, Throwable exception, Object[] args) { + onException(event, exception); + } + + // --------------------------------------------------------------------------- + // MongoCollection.updateOne + // --------------------------------------------------------------------------- + + @ArgumentArray + @HookClass(MONGO_COLLECTION) + public static void updateOne(Event event, Object self, Object[] args) { + onCall(event, self, args, "updateOne"); + } + + @ArgumentArray + @HookClass(value = MONGO_COLLECTION, methodEvent = MethodEvent.METHOD_RETURN) + public static void updateOne(Event event, Object self, Object returnValue, Object[] args) { + onReturn(event, returnValue); + } + + @ArgumentArray + @HookClass(value = MONGO_COLLECTION, methodEvent = MethodEvent.METHOD_EXCEPTION) + public static void updateOne(Event event, Object self, Throwable exception, Object[] args) { + onException(event, exception); + } + + // --------------------------------------------------------------------------- + // MongoCollection.replaceOne + // --------------------------------------------------------------------------- + + @ArgumentArray + @HookClass(MONGO_COLLECTION) + public static void replaceOne(Event event, Object self, Object[] args) { + onCall(event, self, args, "replaceOne"); + } + + @ArgumentArray + @HookClass(value = MONGO_COLLECTION, methodEvent = MethodEvent.METHOD_RETURN) + public static void replaceOne(Event event, Object self, Object returnValue, Object[] args) { + onReturn(event, returnValue); + } + + @ArgumentArray + @HookClass(value = MONGO_COLLECTION, methodEvent = MethodEvent.METHOD_EXCEPTION) + public static void replaceOne(Event event, Object self, Throwable exception, Object[] args) { + onException(event, exception); + } + + // --------------------------------------------------------------------------- + // MongoCollection.updateMany + // --------------------------------------------------------------------------- + + @ArgumentArray + @HookClass(MONGO_COLLECTION) + public static void updateMany(Event event, Object self, Object[] args) { + onCall(event, self, args, "updateMany"); + } + + @ArgumentArray + @HookClass(value = MONGO_COLLECTION, methodEvent = MethodEvent.METHOD_RETURN) + public static void updateMany(Event event, Object self, Object returnValue, Object[] args) { + onReturn(event, returnValue); + } + + @ArgumentArray + @HookClass(value = MONGO_COLLECTION, methodEvent = MethodEvent.METHOD_EXCEPTION) + public static void updateMany(Event event, Object self, Throwable exception, Object[] args) { + onException(event, exception); + } + + // --------------------------------------------------------------------------- + // MongoCollection.deleteOne + // --------------------------------------------------------------------------- + + @ArgumentArray + @HookClass(MONGO_COLLECTION) + public static void deleteOne(Event event, Object self, Object[] args) { + onCall(event, self, args, "deleteOne"); + } + + @ArgumentArray + @HookClass(value = MONGO_COLLECTION, methodEvent = MethodEvent.METHOD_RETURN) + public static void deleteOne(Event event, Object self, Object returnValue, Object[] args) { + onReturn(event, returnValue); + } + + @ArgumentArray + @HookClass(value = MONGO_COLLECTION, methodEvent = MethodEvent.METHOD_EXCEPTION) + public static void deleteOne(Event event, Object self, Throwable exception, Object[] args) { + onException(event, exception); + } + + // --------------------------------------------------------------------------- + // MongoCollection.deleteMany + // --------------------------------------------------------------------------- + + @ArgumentArray + @HookClass(MONGO_COLLECTION) + public static void deleteMany(Event event, Object self, Object[] args) { + onCall(event, self, args, "deleteMany"); + } + + @ArgumentArray + @HookClass(value = MONGO_COLLECTION, methodEvent = MethodEvent.METHOD_RETURN) + public static void deleteMany(Event event, Object self, Object returnValue, Object[] args) { + onReturn(event, returnValue); + } + + @ArgumentArray + @HookClass(value = MONGO_COLLECTION, methodEvent = MethodEvent.METHOD_EXCEPTION) + public static void deleteMany(Event event, Object self, Throwable exception, Object[] args) { + onException(event, exception); + } + + // --------------------------------------------------------------------------- + // MongoCollection.renameCollection + // --------------------------------------------------------------------------- + + @ArgumentArray + @HookClass(MONGO_COLLECTION) + public static void renameCollection(Event event, Object self, Object[] args) { + onCall(event, self, args, "renameCollection"); + } + + @ArgumentArray + @HookClass(value = MONGO_COLLECTION, methodEvent = MethodEvent.METHOD_RETURN) + public static void renameCollection(Event event, Object self, Object returnValue, Object[] args) { + onReturn(event, returnValue); + } + + @ArgumentArray + @HookClass(value = MONGO_COLLECTION, methodEvent = MethodEvent.METHOD_EXCEPTION) + public static void renameCollection(Event event, Object self, Throwable exception, Object[] args) { + onException(event, exception); + } + + // --------------------------------------------------------------------------- + // MongoCollection.drop + // --------------------------------------------------------------------------- + + @ArgumentArray + @HookClass(MONGO_COLLECTION) + public static void drop(Event event, Object self, Object[] args) { + onCall(event, self, args, "drop"); + } + + @ArgumentArray + @HookClass(value = MONGO_COLLECTION, methodEvent = MethodEvent.METHOD_RETURN) + public static void drop(Event event, Object self, Object returnValue, Object[] args) { + onReturn(event, returnValue); + } + + @ArgumentArray + @HookClass(value = MONGO_COLLECTION, methodEvent = MethodEvent.METHOD_EXCEPTION) + public static void drop(Event event, Object self, Throwable exception, Object[] args) { + onException(event, exception); + } + + // --------------------------------------------------------------------------- + // MongoCollection.find + // --------------------------------------------------------------------------- + + @ArgumentArray + @HookClass(MONGO_COLLECTION) + public static void find(Event event, Object self, Object[] args) { + onCall(event, self, args, "find"); + } + + @ArgumentArray + @HookClass(value = MONGO_COLLECTION, methodEvent = MethodEvent.METHOD_RETURN) + public static void find(Event event, Object self, Object returnValue, Object[] args) { + onReturn(event, returnValue); + } + + @ArgumentArray + @HookClass(value = MONGO_COLLECTION, methodEvent = MethodEvent.METHOD_EXCEPTION) + public static void find(Event event, Object self, Throwable exception, Object[] args) { + onException(event, exception); + } + + // --------------------------------------------------------------------------- + // MongoCollection.createIndex + // --------------------------------------------------------------------------- + + @ArgumentArray + @HookClass(MONGO_COLLECTION) + public static void createIndex(Event event, Object self, Object[] args) { + onCall(event, self, args, "createIndex"); + } + + @ArgumentArray + @HookClass(value = MONGO_COLLECTION, methodEvent = MethodEvent.METHOD_RETURN) + public static void createIndex(Event event, Object self, Object returnValue, Object[] args) { + onReturn(event, returnValue); + } + + @ArgumentArray + @HookClass(value = MONGO_COLLECTION, methodEvent = MethodEvent.METHOD_EXCEPTION) + public static void createIndex(Event event, Object self, Throwable exception, Object[] args) { + onException(event, exception); + } + + // --------------------------------------------------------------------------- + // MongoCollection.createIndexes + // --------------------------------------------------------------------------- + + @ArgumentArray + @HookClass(MONGO_COLLECTION) + public static void createIndexes(Event event, Object self, Object[] args) { + onCall(event, self, args, "createIndexes"); + } + + @ArgumentArray + @HookClass(value = MONGO_COLLECTION, methodEvent = MethodEvent.METHOD_RETURN) + public static void createIndexes(Event event, Object self, Object returnValue, Object[] args) { + onReturn(event, returnValue); + } + + @ArgumentArray + @HookClass(value = MONGO_COLLECTION, methodEvent = MethodEvent.METHOD_EXCEPTION) + public static void createIndexes(Event event, Object self, Throwable exception, Object[] args) { + onException(event, exception); + } + + // --------------------------------------------------------------------------- + // MongoCollection.dropIndex + // --------------------------------------------------------------------------- + + @ArgumentArray + @HookClass(MONGO_COLLECTION) + public static void dropIndex(Event event, Object self, Object[] args) { + onCall(event, self, args, "dropIndex"); + } + + @ArgumentArray + @HookClass(value = MONGO_COLLECTION, methodEvent = MethodEvent.METHOD_RETURN) + public static void dropIndex(Event event, Object self, Object returnValue, Object[] args) { + onReturn(event, returnValue); + } + + @ArgumentArray + @HookClass(value = MONGO_COLLECTION, methodEvent = MethodEvent.METHOD_EXCEPTION) + public static void dropIndex(Event event, Object self, Throwable exception, Object[] args) { + onException(event, exception); + } + + // --------------------------------------------------------------------------- + // MongoCollection.dropIndexes + // --------------------------------------------------------------------------- + + @ArgumentArray + @HookClass(MONGO_COLLECTION) + public static void dropIndexes(Event event, Object self, Object[] args) { + onCall(event, self, args, "dropIndexes"); + } + + @ArgumentArray + @HookClass(value = MONGO_COLLECTION, methodEvent = MethodEvent.METHOD_RETURN) + public static void dropIndexes(Event event, Object self, Object returnValue, Object[] args) { + onReturn(event, returnValue); + } + + @ArgumentArray + @HookClass(value = MONGO_COLLECTION, methodEvent = MethodEvent.METHOD_EXCEPTION) + public static void dropIndexes(Event event, Object self, Throwable exception, Object[] args) { + onException(event, exception); + } + + // --------------------------------------------------------------------------- + // MongoCollection.listIndexes + // --------------------------------------------------------------------------- + + @ArgumentArray + @HookClass(MONGO_COLLECTION) + public static void listIndexes(Event event, Object self, Object[] args) { + onCall(event, self, args, "listIndexes"); + } + + @ArgumentArray + @HookClass(value = MONGO_COLLECTION, methodEvent = MethodEvent.METHOD_RETURN) + public static void listIndexes(Event event, Object self, Object returnValue, Object[] args) { + onReturn(event, returnValue); + } + + @ArgumentArray + @HookClass(value = MONGO_COLLECTION, methodEvent = MethodEvent.METHOD_EXCEPTION) + public static void listIndexes(Event event, Object self, Throwable exception, Object[] args) { + onException(event, exception); + } + + // --------------------------------------------------------------------------- + // MongoCollection.estimatedDocumentCount + // --------------------------------------------------------------------------- + + @ArgumentArray + @HookClass(MONGO_COLLECTION) + public static void estimatedDocumentCount(Event event, Object self, Object[] args) { + onCall(event, self, args, "estimatedDocumentCount"); + } + + @ArgumentArray + @HookClass(value = MONGO_COLLECTION, methodEvent = MethodEvent.METHOD_RETURN) + public static void estimatedDocumentCount(Event event, Object self, Object returnValue, Object[] args) { + onReturn(event, returnValue); + } + + @ArgumentArray + @HookClass(value = MONGO_COLLECTION, methodEvent = MethodEvent.METHOD_EXCEPTION) + public static void estimatedDocumentCount(Event event, Object self, Throwable exception, Object[] args) { + onException(event, exception); + } + + // --------------------------------------------------------------------------- + // MongoCollection.countDocuments + // --------------------------------------------------------------------------- + + @ArgumentArray + @HookClass(MONGO_COLLECTION) + public static void countDocuments(Event event, Object self, Object[] args) { + onCall(event, self, args, "countDocuments"); + } + + @ArgumentArray + @HookClass(value = MONGO_COLLECTION, methodEvent = MethodEvent.METHOD_RETURN) + public static void countDocuments(Event event, Object self, Object returnValue, Object[] args) { + onReturn(event, returnValue); + } + + @ArgumentArray + @HookClass(value = MONGO_COLLECTION, methodEvent = MethodEvent.METHOD_EXCEPTION) + public static void countDocuments(Event event, Object self, Throwable exception, Object[] args) { + onException(event, exception); + } + + // --------------------------------------------------------------------------- + // MongoCollection.distinct + // --------------------------------------------------------------------------- + + @ArgumentArray + @HookClass(MONGO_COLLECTION) + public static void distinct(Event event, Object self, Object[] args) { + onCall(event, self, args, "distinct"); + } + + @ArgumentArray + @HookClass(value = MONGO_COLLECTION, methodEvent = MethodEvent.METHOD_RETURN) + public static void distinct(Event event, Object self, Object returnValue, Object[] args) { + onReturn(event, returnValue); + } + + @ArgumentArray + @HookClass(value = MONGO_COLLECTION, methodEvent = MethodEvent.METHOD_EXCEPTION) + public static void distinct(Event event, Object self, Throwable exception, Object[] args) { + onException(event, exception); + } + + // --------------------------------------------------------------------------- + // MongoCollection.findOneAndDelete + // --------------------------------------------------------------------------- + + @ArgumentArray + @HookClass(MONGO_COLLECTION) + public static void findOneAndDelete(Event event, Object self, Object[] args) { + onCall(event, self, args, "findOneAndDelete"); + } + + @ArgumentArray + @HookClass(value = MONGO_COLLECTION, methodEvent = MethodEvent.METHOD_RETURN) + public static void findOneAndDelete(Event event, Object self, Object returnValue, Object[] args) { + onReturn(event, returnValue); + } + + @ArgumentArray + @HookClass(value = MONGO_COLLECTION, methodEvent = MethodEvent.METHOD_EXCEPTION) + public static void findOneAndDelete(Event event, Object self, Throwable exception, Object[] args) { + onException(event, exception); + } + + // --------------------------------------------------------------------------- + // MongoCollection.findOneAndReplace + // --------------------------------------------------------------------------- + + @ArgumentArray + @HookClass(MONGO_COLLECTION) + public static void findOneAndReplace(Event event, Object self, Object[] args) { + onCall(event, self, args, "findOneAndReplace"); + } + + @ArgumentArray + @HookClass(value = MONGO_COLLECTION, methodEvent = MethodEvent.METHOD_RETURN) + public static void findOneAndReplace(Event event, Object self, Object returnValue, Object[] args) { + onReturn(event, returnValue); + } + + @ArgumentArray + @HookClass(value = MONGO_COLLECTION, methodEvent = MethodEvent.METHOD_EXCEPTION) + public static void findOneAndReplace(Event event, Object self, Throwable exception, Object[] args) { + onException(event, exception); + } + + // --------------------------------------------------------------------------- + // MongoCollection.findOneAndUpdate + // --------------------------------------------------------------------------- + + @ArgumentArray + @HookClass(MONGO_COLLECTION) + public static void findOneAndUpdate(Event event, Object self, Object[] args) { + onCall(event, self, args, "findOneAndUpdate"); + } + + @ArgumentArray + @HookClass(value = MONGO_COLLECTION, methodEvent = MethodEvent.METHOD_RETURN) + public static void findOneAndUpdate(Event event, Object self, Object returnValue, Object[] args) { + onReturn(event, returnValue); + } + + @ArgumentArray + @HookClass(value = MONGO_COLLECTION, methodEvent = MethodEvent.METHOD_EXCEPTION) + public static void findOneAndUpdate(Event event, Object self, Throwable exception, Object[] args) { + onException(event, exception); + } + + // --------------------------------------------------------------------------- + // MongoCollection.aggregate + // --------------------------------------------------------------------------- + + @ArgumentArray + @HookClass(MONGO_COLLECTION) + public static void aggregate(Event event, Object self, Object[] args) { + onCall(event, self, args, "aggregate"); + } + + @ArgumentArray + @HookClass(value = MONGO_COLLECTION, methodEvent = MethodEvent.METHOD_RETURN) + public static void aggregate(Event event, Object self, Object returnValue, Object[] args) { + onReturn(event, returnValue); + } + + @ArgumentArray + @HookClass(value = MONGO_COLLECTION, methodEvent = MethodEvent.METHOD_EXCEPTION) + public static void aggregate(Event event, Object self, Throwable exception, Object[] args) { + onException(event, exception); + } + + // --------------------------------------------------------------------------- + // MongoCollection.watch + // --------------------------------------------------------------------------- + + @ArgumentArray + @HookClass(MONGO_COLLECTION) + public static void watch(Event event, Object self, Object[] args) { + onCall(event, self, args, "watch"); + } + + @ArgumentArray + @HookClass(value = MONGO_COLLECTION, methodEvent = MethodEvent.METHOD_RETURN) + public static void watch(Event event, Object self, Object returnValue, Object[] args) { + onReturn(event, returnValue); + } + + @ArgumentArray + @HookClass(value = MONGO_COLLECTION, methodEvent = MethodEvent.METHOD_EXCEPTION) + public static void watch(Event event, Object self, Throwable exception, Object[] args) { + onException(event, exception); + } +} diff --git a/agent/src/main/java/com/appland/appmap/process/hooks/MongoDocumentConverter.java b/agent/src/main/java/com/appland/appmap/process/hooks/MongoDocumentConverter.java new file mode 100644 index 00000000..efe2a804 --- /dev/null +++ b/agent/src/main/java/com/appland/appmap/process/hooks/MongoDocumentConverter.java @@ -0,0 +1,353 @@ +package com.appland.appmap.process.hooks; + +import java.lang.reflect.Method; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.WeakHashMap; +import java.util.concurrent.ConcurrentHashMap; + +import org.tinylog.TaggedLogger; + +import com.appland.appmap.config.AppMapConfig; + +/** + * Reaches into the MongoDB Java driver by reflection, so that + * {@link MongoQueryShape} can walk {@code Bson} filters and updates, write + * models, index models and POJO documents as plain Maps and Lists. + * + *

+ * Every lookup is defensive: the agent may run against any driver version + * (3.x through 5.x), or against a wrapper that only implements part of the + * API. Anything that cannot be converted is reported as null and rendered as + * {@code ?}. + */ +final class MongoDocumentConverter implements MongoQueryShape.DocumentConverter { + private static final TaggedLogger logger = AppMapConfig.getLogger(null); + + static final String BSON = "org.bson.conversions.Bson"; + static final String BSON_VALUE = "org.bson.BsonValue"; + static final String BSON_DOCUMENT = "org.bson.BsonDocument"; + static final String BSON_WRITER = "org.bson.BsonWriter"; + static final String BSON_DOCUMENT_WRITER = "org.bson.BsonDocumentWriter"; + static final String CODEC_REGISTRY = "org.bson.codecs.configuration.CodecRegistry"; + static final String ENCODER = "org.bson.codecs.Encoder"; + static final String ENCODER_CONTEXT = "org.bson.codecs.EncoderContext"; + static final String CLIENT_SESSION = "com.mongodb.session.ClientSession"; + static final String WRITE_MODEL = "com.mongodb.client.model.WriteModel"; + static final String INDEX_MODEL = "com.mongodb.client.model.IndexModel"; + static final String MONGO_NAMESPACE = "com.mongodb.MongoNamespace"; + + // Whether a class is (a subtype of) a named driver type. Keyed by the class, + // then by the type name. Classes are held weakly so unloaded applications + // are not pinned. + private static final Map, ConcurrentHashMap> typeCache = Collections + .synchronizedMap(new WeakHashMap, ConcurrentHashMap>()); + + static boolean isA(Object value, String typeName) { + if (value == null) { + return false; + } + Class cls = value.getClass(); + ConcurrentHashMap forClass = typeCache.get(cls); + if (forClass == null) { + forClass = new ConcurrentHashMap(); + typeCache.put(cls, forClass); + } + Boolean known = forClass.get(typeName); + if (known == null) { + known = hasSupertype(cls, typeName); + forClass.put(typeName, known); + } + return known; + } + + private static boolean hasSupertype(Class cls, String typeName) { + for (Class c = cls; c != null; c = c.getSuperclass()) { + if (c.getName().equals(typeName)) { + return true; + } + for (Class i : c.getInterfaces()) { + if (i.getName().equals(typeName) || hasSupertype(i, typeName)) { + return true; + } + } + } + return false; + } + + static boolean isClientSession(Object value) { + return isA(value, CLIENT_SESSION); + } + + // Reflection handles for one class loader's copy of the driver. + private static final class Handles { + final Class bsonDocument; + final Method toBsonDocument; + /** The no-argument overload, available from bson 4.2 on; null before that. */ + final Method toBsonDocumentDefault; + final Method registryGet; + final Method encode; + final Object encoderContext; + final java.lang.reflect.Constructor documentWriter; + + Handles(ClassLoader loader) throws Exception { + Class bson = Class.forName(BSON, false, loader); + Class registry = Class.forName(CODEC_REGISTRY, false, loader); + bsonDocument = Class.forName(BSON_DOCUMENT, false, loader); + toBsonDocument = bson.getMethod("toBsonDocument", Class.class, registry); + Method noArgs = null; + try { + noArgs = bson.getMethod("toBsonDocument"); + } catch (NoSuchMethodException e) { + // bson < 4.2 + } + toBsonDocumentDefault = noArgs; + registryGet = registry.getMethod("get", Class.class); + Class writer = Class.forName(BSON_WRITER, false, loader); + Class context = Class.forName(ENCODER_CONTEXT, false, loader); + encode = Class.forName(ENCODER, false, loader).getMethod("encode", writer, Object.class, context); + Object builder = context.getMethod("builder").invoke(null); + encoderContext = builder.getClass().getMethod("build").invoke(builder); + documentWriter = Class.forName(BSON_DOCUMENT_WRITER, false, loader).getConstructor(bsonDocument); + } + } + + private static final Map handlesByLoader = Collections + .synchronizedMap(new WeakHashMap()); + private static final Handles NO_HANDLES = null; + + private static Handles handles(ClassLoader loader) { + if (loader == null) { + loader = ClassLoader.getSystemClassLoader(); + } + synchronized (handlesByLoader) { + if (handlesByLoader.containsKey(loader)) { + return handlesByLoader.get(loader); + } + Handles h; + try { + h = new Handles(loader); + } catch (Throwable e) { + logger.debug(e, "bson classes not available from {}", loader); + h = NO_HANDLES; + } + handlesByLoader.put(loader, h); + return h; + } + } + + // Accessors on the collection object itself, cached per collection class. + private static final Map, Method[]> collectionAccessors = Collections + .synchronizedMap(new WeakHashMap, Method[]>()); + + private static Method[] accessors(Object collection) { + Class cls = collection.getClass(); + Method[] found = collectionAccessors.get(cls); + if (found == null) { + found = new Method[2]; + try { + found[0] = cls.getMethod("getNamespace"); + found[0].setAccessible(true); + } catch (Throwable e) { + logger.debug(e, "{} has no getNamespace()", cls.getName()); + } + try { + found[1] = cls.getMethod("getCodecRegistry"); + found[1].setAccessible(true); + } catch (Throwable e) { + logger.debug(e, "{} has no getCodecRegistry()", cls.getName()); + } + collectionAccessors.put(cls, found); + } + return found; + } + + /** The collection name, or null when it cannot be determined. */ + static String collectionName(Object collection) { + if (collection == null) { + return null; + } + Method getNamespace = accessors(collection)[0]; + if (getNamespace == null) { + return null; + } + try { + Object namespace = getNamespace.invoke(collection); + if (namespace == null) { + return null; + } + Object name = namespace.getClass().getMethod("getCollectionName").invoke(namespace); + return name == null ? null : name.toString(); + } catch (Throwable e) { + logger.debug(e, "failed to get collection name"); + return null; + } + } + + static MongoQueryShape.DocumentConverter forCollection(Object collection) { + Object registry = null; + ClassLoader loader = collection == null ? null : collection.getClass().getClassLoader(); + if (collection != null) { + Method getCodecRegistry = accessors(collection)[1]; + if (getCodecRegistry != null) { + try { + registry = getCodecRegistry.invoke(collection); + } catch (Throwable e) { + logger.debug(e, "failed to get codec registry"); + } + } + } + return new MongoDocumentConverter(loader, registry); + } + + private final Handles handles; + private final Object registry; + + MongoDocumentConverter(ClassLoader loader, Object registry) { + this.handles = handles(loader); + this.registry = registry; + } + + @Override + public Object convert(Object value, boolean documentPosition) { + if (value == null) { + return null; + } + try { + if (isA(value, BSON)) { + return toBsonDocument(value); + } + if (isA(value, BSON_VALUE)) { + // A scalar BsonValue (BsonDocument and BsonArray are a Map and a List, + // and never get here). + return null; + } + if (isA(value, WRITE_MODEL)) { + return writeModel(value); + } + if (isA(value, INDEX_MODEL)) { + Map shape = new LinkedHashMap(); + shape.put("key", getter(value, "getKeys")); + return shape; + } + if (isA(value, MONGO_NAMESPACE)) { + Object fullName = getter(value, "getFullName"); + return fullName == null ? null : fullName.toString(); + } + if (documentPosition && !isJdkType(value)) { + return encode(value); + } + } catch (Throwable e) { + logger.debug(e, "failed to convert {} to a document", value.getClass().getName()); + } + return null; + } + + private static boolean isJdkType(Object value) { + String name = value.getClass().getName(); + return name.startsWith("java.") || name.startsWith("javax."); + } + + private Object toBsonDocument(Object bson) throws Exception { + if (handles == null) { + return null; + } + if (registry != null) { + return handles.toBsonDocument.invoke(bson, handles.bsonDocument, registry); + } + if (handles.toBsonDocumentDefault != null) { + return handles.toBsonDocumentDefault.invoke(bson); + } + return null; + } + + /** + * Encodes an arbitrary document object (a POJO, a Kotlin data class, ...) with + * the collection's codec registry, the way the driver will when it sends it. + * Returns null when the registry has no codec for it. + */ + private Object encode(Object document) throws Exception { + if (handles == null || registry == null) { + return null; + } + Object codec; + try { + codec = handles.registryGet.invoke(registry, document.getClass()); + } catch (Throwable e) { + // CodecConfigurationException: no codec for this class + return null; + } + if (codec == null) { + return null; + } + Object target = handles.bsonDocument.getConstructor().newInstance(); + Object writer = handles.documentWriter.newInstance(target); + handles.encode.invoke(codec, writer, document, handles.encoderContext); + return target; + } + + // Renders a WriteModel the way the Node driver's bulkWrite operations look: + // { "insertOne": { "document": ... } }, { "updateOne": { "filter": ..., + // "update": ... } }, and so on. + private Object writeModel(Object model) { + String kind = model.getClass().getSimpleName(); + Map body = new LinkedHashMap(); + String key; + switch (kind) { + case "InsertOneModel": + key = "insertOne"; + body.put("document", document(getter(model, "getDocument"))); + break; + case "UpdateOneModel": + case "UpdateManyModel": + key = kind.equals("UpdateOneModel") ? "updateOne" : "updateMany"; + body.put("filter", getter(model, "getFilter")); + Object update = getter(model, "getUpdate"); + if (update == null) { + update = getter(model, "getUpdatePipeline"); + } + body.put("update", update); + break; + case "ReplaceOneModel": + key = "replaceOne"; + body.put("filter", getter(model, "getFilter")); + body.put("replacement", document(getter(model, "getReplacement"))); + break; + case "DeleteOneModel": + case "DeleteManyModel": + key = kind.equals("DeleteOneModel") ? "deleteOne" : "deleteMany"; + body.put("filter", getter(model, "getFilter")); + break; + default: + return null; + } + Map shape = new LinkedHashMap(); + shape.put(key, body); + return shape; + } + + // A document nested in a write model sits in a document position, but the + // shape renderer only knows that for top-level arguments. Convert it here. + private Object document(Object value) { + if (value == null || value instanceof Map || MongoQueryShape.isSequence(value)) { + return value; + } + Object converted = convert(value, true); + return converted == null ? value : converted; + } + + private static Object getter(Object target, String name) { + if (target == null) { + return null; + } + try { + Method m = target.getClass().getMethod(name); + m.setAccessible(true); + return m.invoke(target); + } catch (Throwable e) { + logger.debug(e, "{}.{}() failed", target.getClass().getName(), name); + return null; + } + } +} diff --git a/agent/src/main/java/com/appland/appmap/process/hooks/MongoQueryShape.java b/agent/src/main/java/com/appland/appmap/process/hooks/MongoQueryShape.java new file mode 100644 index 00000000..2816fb81 --- /dev/null +++ b/agent/src/main/java/com/appland/appmap/process/hooks/MongoQueryShape.java @@ -0,0 +1,345 @@ +package com.appland.appmap.process.hooks; + +import java.util.ArrayList; +import java.util.HashSet; +import java.util.IdentityHashMap; +import java.util.Iterator; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.regex.Pattern; + +/** + * Renders a MongoDB collection operation as a query statement with a + * normalized argument shape, so that it can be recorded as a {@code sql_query} + * event (database_type "mongodb") and compared across recordings the way SQL + * is. + * + *

+ * The same rules are implemented by the Node agent + * ({@code src/hooks/mongoQuery.ts} in appmap-node). Keep them in sync. + * + *

+ * Statement form: {@code db..(, , ...)} + *

+ */ +public final class MongoQueryShape { + public static final String DATABASE_TYPE = "mongodb"; + public static final int MAX_DEPTH = 32; + public static final int MAX_ARRAY_ELEMENTS = 1000; + + static final String PLACEHOLDER = "?"; + + /** + * Turns driver-specific objects into documents the shape renderer can walk. + * Implementations reach into the driver by reflection; this class has no + * dependency on it. + */ + public interface DocumentConverter { + /** + * @param value a non-null value that is not a Map, Iterable or array + * @param documentPosition true when the value sits where the driver expects + * a document (an inserted document, a replacement, a pipeline + * stage), so that an arbitrary object may be encoded with the + * collection's codec + * @return a Map, Iterable or array to walk instead of {@code value}, a + * String to render verbatim, or null when the value is a leaf + */ + Object convert(Object value, boolean documentPosition); + } + + static final DocumentConverter NO_CONVERSION = new DocumentConverter() { + @Override + public Object convert(Object value, boolean documentPosition) { + return null; + } + }; + + enum ArgKind { + DOCUMENT, PIPELINE, NAME, OPTIONS + } + + static ArgKind argKind(String name) { + switch (name) { + case "pipeline": + return ArgKind.PIPELINE; + case "key": + case "newName": + case "indexName": + case "indexes": + return ArgKind.NAME; + case "options": + return ArgKind.OPTIONS; + default: + return ArgKind.DOCUMENT; + } + } + + private MongoQueryShape() { + } + + /** + * Formats a collection method call as a normalized statement. Never throws: if + * the arguments cannot be inspected the statement is rendered with a single + * {@code ?} in place of the argument list. + */ + public static String formatStatement(String collection, String method, String[] argNames, Object[] args, + DocumentConverter converter) { + String prefix = formatCollection(collection) + "." + method; + try { + return prefix + "(" + formatArgs(argNames, args, converter) + ")"; + } catch (Throwable e) { + return prefix + "(" + PLACEHOLDER + ")"; + } + } + + private static final Pattern IDENTIFIER_PATH = Pattern.compile("[A-Za-z_$][\\w$]*(\\.[A-Za-z_$][\\w$]*)*"); + + public static String formatCollection(String name) { + if (name == null) { + return "db.getCollection(" + PLACEHOLDER + ")"; + } + if (IDENTIFIER_PATH.matcher(name).matches()) { + return "db." + name; + } + return "db.getCollection(" + quote(name) + ")"; + } + + private static String formatArgs(String[] argNames, Object[] args, DocumentConverter converter) { + int last = Math.min(argNames.length, args == null ? 0 : args.length); + while (last > 0 && args[last - 1] == null) { + last--; + } + StringBuilder sb = new StringBuilder(); + for (int i = 0; i < last; i++) { + if (i > 0) { + sb.append(", "); + } + sb.append(formatArg(argNames[i], args[i], converter)); + } + return sb.toString(); + } + + private static String formatArg(String name, Object value, DocumentConverter converter) { + switch (argKind(name)) { + case NAME: + if (value instanceof CharSequence) { + return quote(value.toString()); + } + if (value instanceof Iterable && allStrings((Iterable) value)) { + StringBuilder sb = new StringBuilder("["); + boolean first = true; + for (Object v : (Iterable) value) { + if (!first) { + sb.append(", "); + } + first = false; + sb.append(quote(v.toString())); + } + return sb.append("]").toString(); + } + return shape(value, false, false, converter); + case PIPELINE: + return shape(value, true, true, converter); + case OPTIONS: + return shape(value, false, false, converter); + case DOCUMENT: + default: + // An update can be a pipeline (a list of stages) instead of a document. + boolean ordered = "update".equals(name) && isSequence(value); + return shape(value, ordered, true, converter); + } + } + + private static boolean allStrings(Iterable values) { + for (Object v : values) { + if (!(v instanceof CharSequence)) { + return false; + } + } + return true; + } + + /** + * Renders the shape of a value: keys kept, leaves replaced with {@code ?}. + * + * @param ordered when true, a top-level array keeps all of its elements in + * order (a pipeline); otherwise distinct element shapes are kept + * @param documentPosition whether the value (or the elements of a top-level + * array) sit where the driver expects a document + */ + public static String shape(Object value, boolean ordered, boolean documentPosition, DocumentConverter converter) { + StringBuilder sb = new StringBuilder(); + shapeOf(value, ordered, documentPosition, 0, new IdentityHashMap(), converter, sb); + return sb.toString(); + } + + private static void shapeOf(Object value, boolean ordered, boolean documentPosition, int depth, + IdentityHashMap ancestors, DocumentConverter converter, StringBuilder sb) { + if (depth > MAX_DEPTH || value == null) { + sb.append(PLACEHOLDER); + return; + } + + if (!(value instanceof Map) && !isSequence(value)) { + Object converted = converter.convert(value, documentPosition); + if (converted == null) { + sb.append(PLACEHOLDER); + return; + } + if (converted instanceof CharSequence) { + sb.append(quote(converted.toString())); + return; + } + value = converted; + } + + if (ancestors.containsKey(value)) { + sb.append(PLACEHOLDER); + return; + } + ancestors.put(value, Boolean.TRUE); + try { + if (value instanceof Map) { + shapeOfMap((Map) value, depth, ancestors, converter, sb); + } else if (isSequence(value)) { + shapeOfSequence(value, ordered, documentPosition, depth, ancestors, converter, sb); + } else { + sb.append(PLACEHOLDER); + } + } finally { + ancestors.remove(value); + } + } + + private static void shapeOfMap(Map map, int depth, IdentityHashMap ancestors, + DocumentConverter converter, StringBuilder sb) { + sb.append('{'); + boolean first = true; + for (Map.Entry entry : map.entrySet()) { + if (!first) { + sb.append(", "); + } + first = false; + sb.append(quote(String.valueOf(entry.getKey()))).append(": "); + shapeOf(entry.getValue(), false, false, depth + 1, ancestors, converter, sb); + } + sb.append('}'); + } + + private static void shapeOfSequence(Object value, boolean ordered, boolean documentPosition, int depth, + IdentityHashMap ancestors, DocumentConverter converter, StringBuilder sb) { + List parts = new ArrayList(); + Set seen = ordered ? null : new HashSet(); + Iterator it = iterator(value); + for (int i = 0; i < MAX_ARRAY_ELEMENTS && it.hasNext(); i++) { + StringBuilder part = new StringBuilder(); + shapeOf(it.next(), false, documentPosition, depth + 1, ancestors, converter, part); + String rendered = part.toString(); + if (ordered || seen.add(rendered)) { + parts.add(rendered); + } + } + sb.append('['); + for (int i = 0; i < parts.size(); i++) { + if (i > 0) { + sb.append(", "); + } + sb.append(parts.get(i)); + } + sb.append(']'); + } + + static boolean isSequence(Object value) { + return value instanceof Iterable || (value != null && value.getClass().isArray() + && !value.getClass().getComponentType().isPrimitive()); + } + + private static Iterator iterator(Object value) { + if (value instanceof Iterable) { + return ((Iterable) value).iterator(); + } + final Object[] array = (Object[]) value; + return new Iterator() { + private int i = 0; + + @Override + public boolean hasNext() { + return i < array.length; + } + + @Override + public Object next() { + return array[i++]; + } + }; + } + + /** Quotes a string the way JSON.stringify does. */ + static String quote(String s) { + StringBuilder sb = new StringBuilder(s.length() + 2); + sb.append('"'); + for (int i = 0; i < s.length(); i++) { + char c = s.charAt(i); + switch (c) { + case '"': + sb.append("\\\""); + break; + case '\\': + sb.append("\\\\"); + break; + case '\n': + sb.append("\\n"); + break; + case '\r': + sb.append("\\r"); + break; + case '\t': + sb.append("\\t"); + break; + case '\b': + sb.append("\\b"); + break; + case '\f': + sb.append("\\f"); + break; + default: + if (c < 0x20) { + sb.append(String.format("\\u%04x", (int) c)); + } else { + sb.append(c); + } + } + } + return sb.append('"').toString(); + } +} diff --git a/agent/src/test/java/com/appland/appmap/process/hooks/MongoOperationTest.java b/agent/src/test/java/com/appland/appmap/process/hooks/MongoOperationTest.java new file mode 100644 index 00000000..e51a323e --- /dev/null +++ b/agent/src/test/java/com/appland/appmap/process/hooks/MongoOperationTest.java @@ -0,0 +1,76 @@ +package com.appland.appmap.process.hooks; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; + +import java.util.Collections; + +import org.junit.jupiter.api.Test; + +import com.appland.appmap.process.hooks.Mongo.Operation; + +public class MongoOperationTest { + /** Looks enough like a driver collection for the hook: it has a namespace. */ + public static class FakeCollection { + public FakeNamespace getNamespace() { + return new FakeNamespace(); + } + } + + public static class FakeNamespace { + public String getCollectionName() { + return "people"; + } + } + + private static final Object SESSION = new com.mongodb.session.ClientSession() { + }; + + @Test + public void plainArguments() { + Object filter = Collections.singletonMap("a", 1); + Object update = Collections.singletonMap("$set", 1); + Operation op = Operation.of(new FakeCollection(), "updateOne", new Object[] { filter, update }); + assertEquals("people", op.collection); + assertArrayEquals(new String[] { "filter", "update" }, op.parameterNames); + assertArrayEquals(new String[] { "filter", "update", "options" }, op.argNames); + assertArrayEquals(new Object[] { filter, update }, op.operationArgs); + } + + @Test + public void sessionAndResultClassAreNamedButNotPartOfTheStatement() { + Object filter = Collections.singletonMap("a", 1); + Operation op = Operation.of(new FakeCollection(), "find", new Object[] { SESSION, filter, String.class }); + assertArrayEquals(new String[] { "session", "filter", "resultClass" }, op.parameterNames); + assertArrayEquals(new Object[] { filter }, op.operationArgs); + + op = Operation.of(new FakeCollection(), "countDocuments", new Object[] { SESSION }); + assertArrayEquals(new String[] { "session" }, op.parameterNames); + assertEquals(0, op.operationArgs.length); + + op = Operation.of(new FakeCollection(), "distinct", new Object[] { "email", String.class }); + assertArrayEquals(new String[] { "key", "resultClass" }, op.parameterNames); + assertArrayEquals(new Object[] { "email" }, op.operationArgs); + } + + @Test + public void unknownMethodsAndCollections() { + Operation op = Operation.of(new Object(), "somethingNew", new Object[] { 1, 2 }); + assertNull(op.collection); + assertArrayEquals(new String[] { "arg0", "arg1" }, op.parameterNames); + assertEquals(0, op.argNames.length); + + op = Operation.of(null, "find", null); + assertNull(op.collection); + assertEquals(0, op.parameterNames.length); + } + + @Test + public void statementFromOperation() { + Object filter = Collections.singletonMap("a", 1); + Operation op = Operation.of(new FakeCollection(), "find", new Object[] { SESSION, filter, String.class }); + assertEquals("db.people.find({\"a\": ?})", MongoQueryShape.formatStatement(op.collection, "find", op.argNames, + op.operationArgs, MongoDocumentConverter.forCollection(new FakeCollection()))); + } +} diff --git a/agent/src/test/java/com/appland/appmap/process/hooks/MongoQueryShapeTest.java b/agent/src/test/java/com/appland/appmap/process/hooks/MongoQueryShapeTest.java new file mode 100644 index 00000000..77d5aa73 --- /dev/null +++ b/agent/src/test/java/com/appland/appmap/process/hooks/MongoQueryShapeTest.java @@ -0,0 +1,253 @@ +package com.appland.appmap.process.hooks; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.Date; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import org.junit.jupiter.api.Test; + +import com.appland.appmap.process.hooks.MongoQueryShape.DocumentConverter; + +/** + * The expectations here match the Node agent's tests for + * {@code src/hooks/mongoQuery.ts}: both agents must render the same + * statement for the same operation. + */ +public class MongoQueryShapeTest { + private static final DocumentConverter NONE = MongoQueryShape.NO_CONVERSION; + + private static Map doc(Object... kv) { + Map m = new LinkedHashMap(); + for (int i = 0; i < kv.length; i += 2) { + m.put((String) kv[i], kv[i + 1]); + } + return m; + } + + private static String statement(String method, String[] names, Object... args) { + return MongoQueryShape.formatStatement("users", method, names, args, NONE); + } + + private static String shape(Object value) { + return MongoQueryShape.shape(value, false, false, NONE); + } + + @Test + public void collectionNames() { + assertEquals("db.users", MongoQueryShape.formatCollection("users")); + assertEquals("db.users.archive", MongoQueryShape.formatCollection("users.archive")); + assertEquals("db._tmp$1", MongoQueryShape.formatCollection("_tmp$1")); + assertEquals("db.getCollection(\"my-coll\")", MongoQueryShape.formatCollection("my-coll")); + assertEquals("db.getCollection(\"with space\")", MongoQueryShape.formatCollection("with space")); + assertEquals("db.getCollection(\"\")", MongoQueryShape.formatCollection("")); + assertEquals("db.getCollection(\"q\\\"uote\")", MongoQueryShape.formatCollection("q\"uote")); + assertEquals("db.getCollection(\"a..b\")", MongoQueryShape.formatCollection("a..b")); + assertEquals("db.getCollection(?)", MongoQueryShape.formatCollection(null)); + } + + @Test + public void keysKeptLeavesReplaced() { + assertEquals("{\"a\": ?, \"b\": ?, \"c\": ?, \"e\": ?}", shape(doc("a", 1, "b", "x", "c", null, "e", true))); + assertEquals("{\"z\": {\"y\": {\"x\": ?}}, \"a\": ?}", shape(doc("z", doc("y", doc("x", 1)), "a", 2))); + } + + @Test + public void operatorsAreKeys() { + Object filter = doc("age", doc("$gt", 18, "$lt", 65), "$or", Arrays.asList(doc("a", 1), doc("b", 2))); + assertEquals("{\"age\": {\"$gt\": ?, \"$lt\": ?}, \"$or\": [{\"a\": ?}, {\"b\": ?}]}", shape(filter)); + } + + @Test + public void arraysCollapseToDistinctShapes() { + assertEquals("{\"$in\": [?]}", shape(doc("$in", Arrays.asList(1, 2, 3)))); + assertEquals("[{\"a\": ?}, {\"b\": ?}]", shape(Arrays.asList(doc("a", 1), doc("a", 2), doc("b", 3), doc("a", 4)))); + assertEquals("[?]", shape(new Object[] { "x", "y" })); + assertEquals("[]", shape(Collections.emptyList())); + assertEquals("{}", shape(Collections.emptyMap())); + } + + @Test + public void orderedArraysKeepEveryElement() { + List pipeline = Arrays.asList(doc("$unwind", "$a"), doc("$unwind", "$b")); + assertEquals("[{\"$unwind\": ?}, {\"$unwind\": ?}]", MongoQueryShape.shape(pipeline, true, true, NONE)); + // only the top level is ordered; nested arrays are still collapsed + Object stage = doc("$match", doc("a", doc("$in", Arrays.asList(1, 2)))); + assertEquals("[{\"$match\": {\"a\": {\"$in\": [?]}}}]", + MongoQueryShape.shape(Collections.singletonList(stage), true, true, NONE)); + } + + @Test + public void leaves() { + assertEquals("{\"at\": ?, \"bytes\": ?, \"n\": ?, \"o\": ?}", + shape(doc("at", new Date(), "bytes", new byte[] { 1 }, "n", 1.5, "o", new Object()))); + assertEquals("?", shape(null)); + assertEquals("?", shape("string")); + } + + @Test + public void keysAreEscaped() { + assertEquals("{\"he said \\\"hi\\\"\": ?, \"a.b\": ?, \"\": ?, \"\\n\": ?, \"\\u0001\": ?}", + shape(doc("he said \"hi\"", 1, "a.b", 2, "", 3, "\n", 4, "\u0001", 5))); + // non-string keys are stringified + Map m = new LinkedHashMap(); + m.put(2, "x"); + assertEquals("{\"2\": ?}", shape(m)); + } + + @Test + public void cyclesDoNotRecurse() { + Map d = doc("a", 1); + d.put("self", d); + List arr = new ArrayList(); + arr.add(1); + arr.add(arr); + d.put("arr", arr); + assertEquals("{\"a\": ?, \"self\": ?, \"arr\": [?]}", shape(d)); + + // the same object in two places is not a cycle + Map inner = doc("x", 1); + assertEquals("{\"a\": {\"x\": ?}, \"b\": {\"x\": ?}}", shape(doc("a", inner, "b", inner))); + } + + @Test + public void depthLimit() { + Object d = 1; + for (int i = 0; i < MongoQueryShape.MAX_DEPTH + 5; i++) { + d = doc("n", d); + } + String rendered = shape(d); + StringBuilder open = new StringBuilder(); + StringBuilder close = new StringBuilder(); + for (int i = 0; i <= MongoQueryShape.MAX_DEPTH; i++) { + open.append("{\"n\": "); + close.append("}"); + } + assertTrue(rendered.startsWith(open.toString()), rendered); + assertTrue(rendered.endsWith("\"n\": ?" + close), rendered); + } + + @Test + public void arrayElementLimit() { + List arr = new ArrayList(); + for (int i = 0; i < MongoQueryShape.MAX_ARRAY_ELEMENTS; i++) { + arr.add(doc("a", i)); + } + arr.add(doc("b", 2)); + assertEquals("[{\"a\": ?}]", shape(arr)); + } + + @Test + public void statementArguments() { + String[] names = { "filter", "update", "options" }; + assertEquals("db.users.updateOne({\"_id\": ?}, {\"$set\": {\"name\": ?}, \"$inc\": {\"n\": ?}}, ?)", + statement("updateOne", names, doc("_id", new Object()), doc("$set", doc("name", "x"), "$inc", doc("n", 1)), + new Object())); + // trailing nulls are omitted; inner nulls are placeholders + assertEquals("db.users.find()", statement("find", new String[] { "filter" })); + assertEquals("db.users.find()", statement("find", new String[] { "filter" }, (Object) null)); + assertEquals("db.users.find({})", statement("find", new String[] { "filter", "options" }, doc(), null)); + assertEquals("db.users.countDocuments(?, ?)", + statement("countDocuments", new String[] { "filter", "options" }, null, new Object())); + // arguments beyond the declared parameters are ignored + assertEquals("db.users.drop({})", statement("drop", new String[] { "options" }, doc(), "extra", 1)); + assertEquals("db.users.listIndexes()", statement("listIndexes", new String[0])); + } + + @Test + public void pipelinesKeepOrder() { + List pipeline = Arrays.asList(doc("$match", doc("status", "A")), doc("$unwind", "$items"), + doc("$unwind", "$items.parts"), doc("$group", doc("_id", "$cust", "total", doc("$sum", "$amount")))); + assertEquals( + "db.users.aggregate([{\"$match\": {\"status\": ?}}, {\"$unwind\": ?}, {\"$unwind\": ?}, {\"$group\": {\"_id\": ?, \"total\": {\"$sum\": ?}}}])", + statement("aggregate", new String[] { "pipeline" }, pipeline)); + // an update given as a pipeline keeps its order too + assertEquals("db.users.updateMany({}, [{\"$set\": {\"a\": ?}}, {\"$set\": {\"b\": ?}}])", + statement("updateMany", new String[] { "filter", "update", "options" }, doc(), + Arrays.asList(doc("$set", doc("a", 1)), doc("$set", doc("b", 2))))); + } + + @Test + public void documentListsCollapse() { + assertEquals("db.users.insertMany([{\"a\": ?}])", + statement("insertMany", new String[] { "docs", "options" }, Arrays.asList(doc("a", 1), doc("a", 2)))); + Object ops = Arrays.asList(doc("insertOne", doc("document", doc("a", 1))), + doc("insertOne", doc("document", doc("a", 2))), + doc("updateOne", doc("filter", doc("a", 1), "update", doc("$set", doc("b", 1))))); + assertEquals( + "db.users.bulkWrite([{\"insertOne\": {\"document\": {\"a\": ?}}}, {\"updateOne\": {\"filter\": {\"a\": ?}, \"update\": {\"$set\": {\"b\": ?}}}}])", + statement("bulkWrite", new String[] { "operations", "options" }, ops)); + } + + @Test + public void namesAreKeptVerbatim() { + assertEquals("db.users.distinct(\"email\", {\"a\": ?})", + statement("distinct", new String[] { "key", "filter" }, "email", doc("a", 1))); + assertEquals("db.users.renameCollection(\"people\")", + statement("renameCollection", new String[] { "newName", "options" }, "people")); + assertEquals("db.users.dropIndex(\"a_1\")", statement("dropIndex", new String[] { "indexName", "options" }, "a_1")); + assertEquals("db.users.indexExists([\"a_1\", \"b_1\"])", + statement("indexExists", new String[] { "indexes", "options" }, Arrays.asList("a_1", "b_1"))); + // a name argument that is not a string is rendered as a shape + assertEquals("db.users.dropIndex({\"a\": ?})", + statement("dropIndex", new String[] { "indexName", "options" }, doc("a", 1))); + assertEquals("db.users.distinct([?])", statement("distinct", new String[] { "key", "filter" }, Arrays.asList("a", 1))); + } + + @Test + public void converterOutputIsWalked() { + DocumentConverter converter = new DocumentConverter() { + @Override + public Object convert(Object value, boolean documentPosition) { + if (value instanceof StringBuilder) { + return value.toString(); + } + if (documentPosition && value instanceof Integer) { + return doc("boxed", value); + } + if (value instanceof Long) { + return doc("filter", doc("id", "x")); + } + return null; + } + }; + assertEquals("db.users.renameCollection(\"db.people\")", MongoQueryShape.formatStatement("users", + "renameCollection", new String[] { "newName" }, new Object[] { new StringBuilder("db.people") }, converter)); + // a converted value in document position, at the top level and in a list + assertEquals("db.users.insertOne({\"boxed\": ?})", + MongoQueryShape.formatStatement("users", "insertOne", new String[] { "doc" }, new Object[] { 1 }, converter)); + assertEquals("db.users.insertMany([{\"boxed\": ?}])", MongoQueryShape.formatStatement("users", "insertMany", + new String[] { "docs" }, new Object[] { Arrays.asList(1, 2) }, converter)); + // nested values are not in document position + assertEquals("{\"n\": ?, \"m\": {\"filter\": {\"id\": ?}}}", + MongoQueryShape.shape(doc("n", 1, "m", 2L), false, false, converter)); + // options are opaque even when the converter would know them + assertEquals("db.users.drop(?)", + MongoQueryShape.formatStatement("users", "drop", new String[] { "options" }, new Object[] { 1 }, converter)); + } + + @Test + public void neverThrows() { + Map hostile = new LinkedHashMap() { + @Override + public java.util.Set> entrySet() { + throw new IllegalStateException("boom"); + } + }; + assertEquals("db.users.find(?)", statement("find", new String[] { "filter" }, hostile)); + DocumentConverter failing = new DocumentConverter() { + @Override + public Object convert(Object value, boolean documentPosition) { + throw new RuntimeException("boom"); + } + }; + assertEquals("db.users.insertOne(?)", MongoQueryShape.formatStatement("users", "insertOne", + new String[] { "doc" }, new Object[] { new Object() }, failing)); + } +} diff --git a/agent/src/test/java/com/mongodb/session/ClientSession.java b/agent/src/test/java/com/mongodb/session/ClientSession.java new file mode 100644 index 00000000..2881ea2c --- /dev/null +++ b/agent/src/test/java/com/mongodb/session/ClientSession.java @@ -0,0 +1,9 @@ +package com.mongodb.session; + +/** + * Stand-in for the driver's session interface, so that the Mongo hook's + * argument handling can be tested without the driver on the class path. The + * hook recognizes sessions by this type name. + */ +public interface ClientSession { +} diff --git a/agent/test/mongo/appmap.yml b/agent/test/mongo/appmap.yml new file mode 100644 index 00000000..b186f257 --- /dev/null +++ b/agent/test/mongo/appmap.yml @@ -0,0 +1,3 @@ +name: mongo +packages: +- path: com.example.mongo diff --git a/agent/test/mongo/build.gradle b/agent/test/mongo/build.gradle new file mode 100644 index 00000000..ff22c2a4 --- /dev/null +++ b/agent/test/mongo/build.gradle @@ -0,0 +1,53 @@ +buildscript { + ext { + // JDK 25 no longer compiles for a Java 8 target. + if (JavaVersion.current() >= JavaVersion.VERSION_25) { + javaVersion = JavaVersion.VERSION_17 + } else { + javaVersion = JavaVersion.VERSION_1_8 + } + } +} + +plugins { + id 'java' +} + +group = 'com.example' +version = '0.0.1-SNAPSHOT' + +java { + sourceCompatibility = javaVersion + targetCompatibility = javaVersion +} + +repositories { + mavenCentral() +} + +dependencies { + // 4.11 is the last driver line that accepts the wire protocol version + // mongo-java-server 1.43 speaks. The hook itself works with 3.x, 4.x and 5.x. + implementation 'org.mongodb:mongodb-driver-sync:4.11.5' + // An in-process MongoDB server, so the test needs no external service. + // 1.43.0 is the last release that runs on Java 8. + testImplementation 'de.bwaldvogel:mongo-java-server:1.43.0' + testImplementation platform('org.junit:junit-bom:5.8.2') + testImplementation 'org.junit.jupiter:junit-jupiter' + testRuntimeOnly 'org.junit.platform:junit-platform-launcher' +} + +def appmapJar = "$System.env.AGENT_JAR" + +test { + useJUnitPlatform() + if (System.env.AGENT_JAR) { + inputs.file(System.env.AGENT_JAR) + } + jvmArgs += [ + "-javaagent:${appmapJar}", + "-Dappmap.config.file=appmap.yml", + "-Djava.util.logging.config.file=${System.env.JUL_CONFIG}", + // "-Dappmap.debug=true", + ] +} diff --git a/agent/test/mongo/mongo.bats b/agent/test/mongo/mongo.bats new file mode 100644 index 00000000..5140525c --- /dev/null +++ b/agent/test/mongo/mongo.bats @@ -0,0 +1,125 @@ +#!/usr/bin/env bats +# +# Test the MongoCollection hook against an in-process MongoDB server +# (mongo-java-server), so no external service is needed. + +load '../helper' +load '../jdbc/helper' + +setup_file() { + cd "$BATS_TEST_DIRNAME" || exit 1 + _configure_logging + + gradlew -q clean +} + +setup() { + rm -rf tmp/appmap +} + +# Loads the AppMap of one test method into $output for assert_json_eq. Note +# that `run` overwrites $output, so call this again after a `run`. +appmap_for() { + local map_file="tmp/appmap/junit/com_example_mongo_MongoCollectionTests_$1.appmap.json" + [ -f "$map_file" ] + output="$(<"$map_file")" +} + +# Prints the sql of every sql_query event, one per line. +queries() { + jq -r '.events[] | select(.sql_query) | .sql_query.sql' <<< "$output" +} + +@test "collection operations are recorded as queries" { + run gradlew -q test --tests 'MongoCollectionTests.crud' --rerun-tasks + assert_success + + appmap_for crud + assert_json_eq '.metadata.test_status' succeeded + # every query event says which database it is for + assert_json_eq '[.events[] | select(.sql_query) | .sql_query.database_type] | unique | .[0]' mongodb + + run assert_all_calls_returned tmp/appmap/junit/*.appmap.json + assert_success + + appmap_for crud + run queries + assert_line 'db.people.insertOne({"name": ?, "age": ?})' + assert_line 'db.people.insertMany([{"name": ?, "age": ?}, {"name": ?, "age": ?, "tags": [?]}])' + assert_line 'db.people.updateOne({"name": ?}, {"$set": {"age": ?}, "$inc": {"visits": ?}})' + assert_line 'db.people.updateMany({"age": {"$gt": ?}}, {"$set": {"senior": ?}})' + assert_line 'db.people.replaceOne({"name": ?}, {"name": ?, "age": ?})' + assert_line 'db.people.find({"name": ?})' + assert_line 'db.people.find({"$and": [{"name": {"$in": [?]}}, {"senior": {"$exists": ?}}]})' + assert_line 'db.people.countDocuments()' + assert_line 'db.people.countDocuments({"age": {"$lt": ?}})' + assert_line 'db.people.estimatedDocumentCount()' + assert_line 'db.people.distinct("name")' + assert_line 'db.people.aggregate([{"$match": {"age": {"$gte": ?}}}, {"$group": {"_id": ?, "total": {"$sum": ?}}}])' + assert_line 'db.people.deleteOne({"name": ?})' + assert_line 'db.people.deleteMany({"name": {"$in": [?]}})' +} + +@test "the function call event carries the Node parameter names" { + run gradlew -q test --tests 'MongoCollectionTests.crud' --rerun-tasks + assert_success + + appmap_for crud + # the query event is a child of the collection method call + assert_json_eq '[.events[] | select(.method_id == "updateOne")][0].parameters | map(.name) | join(",")' 'filter,update' + local call_id + call_id="$(jq -r '[.events[] | select(.method_id == "updateOne")][0].id' <<< "$output")" + assert_json_eq "[.events[] | select(.event == \"call\" and .id == $((call_id + 1)))][0].sql_query.database_type" mongodb + assert_json_eq '[.events[] | select(.method_id == "distinct")][0].parameters | map(.name) | join(",")' 'key,resultClass' +} + +@test "bulk writes and update pipelines" { + run gradlew -q test --tests 'MongoCollectionTests.bulkWriteAndPipelineUpdate' --rerun-tasks + assert_success + + appmap_for bulkWriteAndPipelineUpdate + run queries + assert_line 'db.people.bulkWrite([{"insertOne": {"document": {"name": ?, "age": ?}}}, {"updateOne": {"filter": {"name": ?}, "update": {"$set": {"age": ?}}}}])' + assert_line 'db.people.updateMany({"name": {"$exists": ?}}, [{"$set": {"adult": ?}}, {"$unset": ?}])' + assert_line 'db.people.findOneAndUpdate({"name": ?}, {"$set": {"age": ?}})' + assert_line 'db.people.findOneAndDelete({"name": ?})' +} + +@test "index operations and a failed insert" { + run gradlew -q test --tests 'MongoCollectionTests.indexes' --rerun-tasks + assert_success + + appmap_for indexes + # the duplicate insert is recorded as an exception on the query and on the call + assert_json_eq '[.events[] | select(.exceptions) | .exceptions[0].class] | unique | .[0]' com.mongodb.MongoWriteException + assert_json_eq '[.events[] | select(.exceptions)] | length' 2 + + run queries + assert_line 'db.people.createIndex({"name": ?}, ?)' + assert_line 'db.people.listIndexes()' + assert_line 'db.people.dropIndex("name_1")' + assert_line 'db.people.dropIndexes()' +} + +@test "POJO documents" { + run gradlew -q test --tests 'MongoCollectionTests.pojos' --rerun-tasks + assert_success + + appmap_for pojos + run queries + # a POJO is encoded with the collection's codec, so its fields are the shape + assert_line 'db.people.insertOne({"age": ?, "name": ?})' + assert_line 'db.people.insertMany([{"age": ?, "name": ?}])' + assert_line 'db.people.find({"name": ?})' + assert_line 'db.people.countDocuments()' +} + +@test "collection names that are not identifiers" { + run gradlew -q test --tests 'MongoCollectionTests.oddCollectionName' --rerun-tasks + assert_success + + appmap_for oddCollectionName + run queries + assert_line 'db.getCollection("with-dash").insertOne({"a": ?})' + assert_line 'db.getCollection("with-dash").countDocuments()' +} diff --git a/agent/test/mongo/settings.gradle b/agent/test/mongo/settings.gradle new file mode 100644 index 00000000..7be7e14d --- /dev/null +++ b/agent/test/mongo/settings.gradle @@ -0,0 +1 @@ +rootProject.name = 'mongo-test' diff --git a/agent/test/mongo/src/main/java/com/example/mongo/Person.java b/agent/test/mongo/src/main/java/com/example/mongo/Person.java new file mode 100644 index 00000000..71d1d843 --- /dev/null +++ b/agent/test/mongo/src/main/java/com/example/mongo/Person.java @@ -0,0 +1,42 @@ +package com.example.mongo; + +import org.bson.types.ObjectId; + +/** A POJO stored with the driver's automatic POJO codec. */ +public class Person { + private ObjectId id; + private String name; + private int age; + + public Person() { + } + + public Person(String name, int age) { + this.name = name; + this.age = age; + } + + public ObjectId getId() { + return id; + } + + public void setId(ObjectId id) { + this.id = id; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public int getAge() { + return age; + } + + public void setAge(int age) { + this.age = age; + } +} diff --git a/agent/test/mongo/src/test/java/com/example/mongo/MongoCollectionTests.java b/agent/test/mongo/src/test/java/com/example/mongo/MongoCollectionTests.java new file mode 100644 index 00000000..560e6b09 --- /dev/null +++ b/agent/test/mongo/src/test/java/com/example/mongo/MongoCollectionTests.java @@ -0,0 +1,171 @@ +package com.example.mongo; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.net.InetSocketAddress; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; + +import org.bson.Document; +import org.bson.codecs.configuration.CodecRegistries; +import org.bson.codecs.configuration.CodecRegistry; +import org.bson.codecs.pojo.PojoCodecProvider; +import org.bson.conversions.Bson; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.parallel.Execution; +import org.junit.jupiter.api.parallel.ExecutionMode; + +import com.mongodb.MongoClientSettings; +import com.mongodb.MongoCommandException; +import com.mongodb.MongoWriteException; +import com.mongodb.client.MongoClient; +import com.mongodb.client.MongoClients; +import com.mongodb.client.MongoCollection; +import com.mongodb.client.MongoDatabase; +import com.mongodb.client.model.Aggregates; +import com.mongodb.client.model.Filters; +import com.mongodb.client.model.IndexOptions; +import com.mongodb.client.model.Indexes; +import com.mongodb.client.model.InsertOneModel; +import com.mongodb.client.model.Sorts; +import com.mongodb.client.model.UpdateOneModel; +import com.mongodb.client.model.Updates; +import com.mongodb.client.model.WriteModel; + +import de.bwaldvogel.mongo.MongoServer; +import de.bwaldvogel.mongo.backend.memory.MemoryBackend; + +/** + * Exercises the driver's MongoCollection API against an in-process server. + * The bats test checks the AppMaps these tests produce. mongo-java-server does + * not support sessions, so the ClientSession overloads are covered by the + * agent's unit tests instead. + */ +@Execution(ExecutionMode.SAME_THREAD) +public class MongoCollectionTests { + private static MongoServer server; + private static MongoClient client; + private MongoDatabase db; + private MongoCollection people; + + @BeforeAll + public static void startServer() { + server = new MongoServer(new MemoryBackend()); + InetSocketAddress address = server.bind(); + client = MongoClients.create("mongodb://" + address.getHostString() + ":" + address.getPort()); + } + + @AfterAll + public static void stopServer() { + client.close(); + server.shutdown(); + } + + @BeforeEach + public void setUp() { + db = client.getDatabase("appmap-java"); + people = db.getCollection("people"); + people.drop(); + } + + @AfterEach + public void tearDown() { + people.drop(); + } + + @Test + public void crud() { + people.insertOne(new Document("name", "alice").append("age", 30)); + people.insertMany(Arrays.asList(new Document("name", "bob").append("age", 40), + new Document("name", "carol").append("age", 50).append("tags", Arrays.asList("x", "y")))); + + people.updateOne(Filters.eq("name", "alice"), Updates.combine(Updates.set("age", 31), Updates.inc("visits", 1))); + people.updateMany(Filters.gt("age", 35), Updates.set("senior", true)); + people.replaceOne(Filters.eq("name", "bob"), new Document("name", "bob").append("age", 41)); + + Document alice = people.find(Filters.eq("name", "alice")).first(); + assertEquals(31, alice.getInteger("age")); + + // bob was replaced after the update, so only carol is still marked senior + List seniors = people.find(Filters.and(Filters.in("name", "bob", "carol"), Filters.exists("senior"))) + .sort(Sorts.descending("age")).into(new ArrayList()); + assertEquals(1, seniors.size()); + + assertEquals(3, people.countDocuments()); + assertEquals(1, people.countDocuments(Filters.lt("age", 35))); + assertEquals(3, people.estimatedDocumentCount()); + + List names = people.distinct("name", String.class).into(new ArrayList()); + assertEquals(3, names.size()); + + Document total = people.aggregate(Arrays.asList(Aggregates.match(Filters.gte("age", 31)), + Aggregates.group(null, com.mongodb.client.model.Accumulators.sum("total", "$age")))).first(); + assertEquals(122, total.getInteger("total")); // 31 + 41 + 50 + + people.deleteOne(Filters.eq("name", "carol")); + people.deleteMany(Filters.in("name", Arrays.asList("alice", "bob"))); + assertEquals(0, people.countDocuments()); + } + + @Test + public void bulkWriteAndPipelineUpdate() { + List> ops = new ArrayList>(); + ops.add(new InsertOneModel(new Document("name", "dave").append("age", 20))); + ops.add(new InsertOneModel(new Document("name", "erin").append("age", 25))); + ops.add(new UpdateOneModel(Filters.eq("name", "dave"), Updates.set("age", 21))); + people.bulkWrite(ops); + + List pipeline = Arrays.asList(new Document("$set", new Document("adult", true)), + new Document("$unset", "age")); + try { + people.updateMany(Filters.exists("name"), pipeline); + } catch (MongoCommandException e) { + // mongo-java-server 1.43 does not implement pipeline updates. The + // statement is recorded either way. + } + + Document found = people.findOneAndUpdate(Filters.eq("name", "erin"), Updates.set("age", 26)); + assertEquals("erin", found.getString("name")); + people.findOneAndDelete(Filters.eq("name", "dave")); + } + + @Test + public void indexes() { + String name = people.createIndex(Indexes.ascending("name"), new IndexOptions().unique(true)); + List indexes = people.listIndexes().into(new ArrayList()); + assertTrue(indexes.size() >= 2); + people.insertOne(new Document("name", "frank")); + // The second insert violates the unique index. The failure must be + // recorded on the query event and still be thrown to the application. + assertThrows(MongoWriteException.class, () -> people.insertOne(new Document("name", "frank"))); + people.dropIndex(name); + people.dropIndexes(); + } + + @Test + public void pojos() { + CodecRegistry pojoRegistry = CodecRegistries.fromRegistries(MongoClientSettings.getDefaultCodecRegistry(), + CodecRegistries.fromProviders(PojoCodecProvider.builder().automatic(true).build())); + MongoCollection persons = db.getCollection("people", Person.class).withCodecRegistry(pojoRegistry); + persons.insertOne(new Person("grace", 35)); + persons.insertMany(Arrays.asList(new Person("heidi", 36), new Person("ivan", 37))); + Person grace = persons.find(Filters.eq("name", "grace")).first(); + assertEquals(35, grace.getAge()); + assertEquals(3, persons.countDocuments()); + } + + @Test + public void oddCollectionName() { + MongoCollection odd = db.getCollection("with-dash"); + odd.insertOne(new Document("a", 1)); + assertEquals(1, odd.countDocuments()); + odd.drop(); + } +} From 4b217ba72ec2e39628270fa88a568abb14322867 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 17 Sep 2026 13:33:06 +0000 Subject: [PATCH 2/2] test(mongo): allow running the fixture against a real MongoDB MONGODB_URI points the tests at an external server instead of the in-process mongo-java-server, and -PmongoDriverVersion selects the driver. The session overloads are exercised only against a real server, since mongo-java-server does not support sessions. Run against MongoDB 8.0.4 with drivers 4.11.5 and 5.6.1: all tests pass, and the statements match the in-process run. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01LntddoBsqjRDRBepBx7oLZ --- agent/test/mongo/build.gradle | 10 ++++-- .../example/mongo/MongoCollectionTests.java | 34 ++++++++++++++++--- 2 files changed, 36 insertions(+), 8 deletions(-) diff --git a/agent/test/mongo/build.gradle b/agent/test/mongo/build.gradle index ff22c2a4..63c5f04a 100644 --- a/agent/test/mongo/build.gradle +++ b/agent/test/mongo/build.gradle @@ -25,10 +25,14 @@ repositories { mavenCentral() } +// 4.11 is the last driver line that accepts the wire protocol version +// mongo-java-server 1.43 speaks. The hook itself works with 3.x, 4.x and 5.x; +// to run the tests against another driver and a real server, use +// MONGODB_URI=mongodb://localhost:27017 ../gradlew test -PmongoDriverVersion=5.6.1 +def mongoDriverVersion = project.findProperty('mongoDriverVersion') ?: '4.11.5' + dependencies { - // 4.11 is the last driver line that accepts the wire protocol version - // mongo-java-server 1.43 speaks. The hook itself works with 3.x, 4.x and 5.x. - implementation 'org.mongodb:mongodb-driver-sync:4.11.5' + implementation "org.mongodb:mongodb-driver-sync:${mongoDriverVersion}" // An in-process MongoDB server, so the test needs no external service. // 1.43.0 is the last release that runs on Java 8. testImplementation 'de.bwaldvogel:mongo-java-server:1.43.0' diff --git a/agent/test/mongo/src/test/java/com/example/mongo/MongoCollectionTests.java b/agent/test/mongo/src/test/java/com/example/mongo/MongoCollectionTests.java index 560e6b09..ef419a31 100644 --- a/agent/test/mongo/src/test/java/com/example/mongo/MongoCollectionTests.java +++ b/agent/test/mongo/src/test/java/com/example/mongo/MongoCollectionTests.java @@ -3,6 +3,7 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assumptions.assumeTrue; import java.net.InetSocketAddress; import java.util.ArrayList; @@ -25,6 +26,7 @@ import com.mongodb.MongoClientSettings; import com.mongodb.MongoCommandException; import com.mongodb.MongoWriteException; +import com.mongodb.client.ClientSession; import com.mongodb.client.MongoClient; import com.mongodb.client.MongoClients; import com.mongodb.client.MongoCollection; @@ -43,10 +45,11 @@ import de.bwaldvogel.mongo.backend.memory.MemoryBackend; /** - * Exercises the driver's MongoCollection API against an in-process server. - * The bats test checks the AppMaps these tests produce. mongo-java-server does - * not support sessions, so the ClientSession overloads are covered by the - * agent's unit tests instead. + * Exercises the driver's MongoCollection API. By default the tests run against + * mongo-java-server, an in-process server, so CI needs no service. Set + * MONGODB_URI to run them against a real MongoDB instead; the session test only + * runs then, because mongo-java-server does not support sessions. The bats test + * checks the AppMaps these tests produce. */ @Execution(ExecutionMode.SAME_THREAD) public class MongoCollectionTests { @@ -55,8 +58,14 @@ public class MongoCollectionTests { private MongoDatabase db; private MongoCollection people; + private static final String EXTERNAL_URI = System.getenv("MONGODB_URI"); + @BeforeAll public static void startServer() { + if (EXTERNAL_URI != null && !EXTERNAL_URI.isEmpty()) { + client = MongoClients.create(EXTERNAL_URI); + return; + } server = new MongoServer(new MemoryBackend()); InetSocketAddress address = server.bind(); client = MongoClients.create("mongodb://" + address.getHostString() + ":" + address.getPort()); @@ -65,7 +74,9 @@ public static void startServer() { @AfterAll public static void stopServer() { client.close(); - server.shutdown(); + if (server != null) { + server.shutdown(); + } } @BeforeEach @@ -161,6 +172,19 @@ public void pojos() { assertEquals(3, persons.countDocuments()); } + @Test + public void sessions() { + assumeTrue(server == null, "mongo-java-server does not support sessions"); + // A session is recorded as a parameter and left out of the statement. + try (ClientSession session = client.startSession()) { + people.insertOne(session, new Document("name", "judy").append("age", 38)); + people.updateOne(session, Filters.eq("name", "judy"), Updates.set("age", 39)); + assertEquals(1, people.countDocuments(session)); + Document judy = people.find(session, Filters.eq("name", "judy")).first(); + assertEquals(39, judy.getInteger("age")); + } + } + @Test public void oddCollectionName() { MongoCollection odd = db.getCollection("with-dash");