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..63c5f04a --- /dev/null +++ b/agent/test/mongo/build.gradle @@ -0,0 +1,57 @@ +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() +} + +// 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 { + 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' + 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..ef419a31 --- /dev/null +++ b/agent/test/mongo/src/test/java/com/example/mongo/MongoCollectionTests.java @@ -0,0 +1,195 @@ +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 static org.junit.jupiter.api.Assumptions.assumeTrue; + +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.ClientSession; +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. 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 { + private static MongoServer server; + private static MongoClient client; + 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()); + } + + @AfterAll + public static void stopServer() { + client.close(); + if (server != null) { + 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 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"); + odd.insertOne(new Document("a", 1)); + assertEquals(1, odd.countDocuments()); + odd.drop(); + } +}