diff --git a/.github/workflows/build-and-test.yml b/.github/workflows/build-and-test.yml index d3c576c5..a5482109 100644 --- a/.github/workflows/build-and-test.yml +++ b/.github/workflows/build-and-test.yml @@ -7,6 +7,10 @@ permissions: # setting permissions, we ensure it has no unnecessary access. contents: read +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + jobs: build-and-check: name: Build and check @@ -37,6 +41,18 @@ jobs: annotation/build/libs/*.jar test-suite: + services: + oracle: + image: docker.io/gvenzl/oracle-free:slim-faststart + ports: + - 1521:1521 + env: + ORACLE_PASSWORD: oracle + options: >- + --health-cmd healthcheck.sh + --health-interval 10s + --health-timeout 5s + --health-retries 5 strategy: matrix: java: ['21', '17', '11', '8'] @@ -115,5 +131,8 @@ jobs: # Github token is just to avoid rate limiting when IntelliJ tests # are run and download the AppMap service binaries GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + ORACLE_URL: jdbc:oracle:thin:@localhost:1521 + ORACLE_USERNAME: system + ORACLE_PASSWORD: oracle working-directory: ./agent run: bin/test_run diff --git a/.gitignore b/.gitignore index c8c96301..32dc79d3 100644 --- a/.gitignore +++ b/.gitignore @@ -22,3 +22,6 @@ tmp # test output /.metadata/ + +# Log files +*.log diff --git a/agent/src/main/java/com/appland/appmap/process/hooks/SqlQuery.java b/agent/src/main/java/com/appland/appmap/process/hooks/SqlQuery.java index 343ee265..51bd7efa 100644 --- a/agent/src/main/java/com/appland/appmap/process/hooks/SqlQuery.java +++ b/agent/src/main/java/com/appland/appmap/process/hooks/SqlQuery.java @@ -1,9 +1,12 @@ package com.appland.appmap.process.hooks; import java.sql.Connection; -import java.sql.DatabaseMetaData; -import java.sql.SQLException; import java.sql.Statement; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.WeakHashMap; import com.appland.appmap.output.v1.Event; import com.appland.appmap.record.Recorder; @@ -21,342 +24,278 @@ public class SqlQuery { private static final Recorder recorder = Recorder.getInstance(); - // ================================================================================================ - // Calls - // ================================================================================================ - public static void recordSql(Event event, String databaseType, String sql) { event.setSqlQuery(databaseType, sql); event.setParameters(null); recorder.add(event); } - private static boolean isMock(Object o) { - final Class c = o.getClass(); - final Package p = c.getPackage(); - if (p == null) { - // If there's no package info, it's not a Mockito object. - return false; - } - - return p.getName().startsWith("org.mockito"); - } + private static final Map databases = Collections.synchronizedMap(new WeakHashMap()); private static String getDbName(Connection c) { - String dbname = ""; if (c == null) { - return dbname; + return null; + } + if (databases.containsKey(c)) { + return databases.get(c); } + String dbname = null; try { - DatabaseMetaData metadata; - if (isMock(c) || isMock(metadata = c.getMetaData())) { - return "[mocked]"; - } - - dbname = metadata.getDatabaseProductName(); - } catch (SQLException e) { + dbname = c.getMetaData().getDatabaseProductName(); + } catch (Throwable e) { Logger.println("WARNING, failed to get database name"); e.printStackTrace(System.err); + // fall through and put null to ensure we don't try again } + databases.put(c, dbname); return dbname; } private static String getDbName(Statement s) { - String dbname = ""; if (s == null) { - return dbname; + return null; + } + if (databases.containsKey(s)) { + return databases.get(s); } + String dbname = null; try { - if (isMock(s)) { - return "[mocked]"; - } - dbname = getDbName(s.getConnection()); - } catch (SQLException e) { + } catch (Throwable e) { Logger.println("WARNING, failed to get statement's connection"); e.printStackTrace(System.err); + // fall through and put null to ensure we don't try again } + databases.put(s, dbname); return dbname; } - public static void recordSql(Event event, Connection c, String sql) { - recordSql(event, getDbName(c), sql); - } - public static void recordSql(Event event, Statement s, String sql) { recordSql(event, getDbName(s), sql); } - @HookClass("java.sql.Connection") - public static void nativeSQL(Event event, Connection c, String sql) { - recordSql(event, c, sql); - } - - @HookClass("java.sql.Connection") - public static void prepareCall(Event event, Connection c, String sql) { - recordSql(event, c, sql); + public static void recordSql(Event event, Statement s, Object args[]) { + recordSql(event, getDbName(s), getSql(s, args)); } - @HookClass("java.sql.Connection") - public static void prepareCall(Event event, Connection c, String sql, int resultSetType, int resultSetConcurrency) { - recordSql(event, c, sql); - } + private static Map statements = Collections.synchronizedMap(new WeakHashMap()); - @HookClass("java.sql.Connection") - public static void prepareCall(Event event, Connection c, String sql, int resultSetType, int resultSetConcurrency, - int resultSetHoldability) { - recordSql(event, c, sql); + /** + * Get the SQL string based on the arguments or the prepared statement. + * + * If the first argument is a string, it is returned. + * If the statement is a prepared statement, the SQL string is returned. + * Otherwise, the last resort is to return "-- [unknown sql]". + * + * @param s The statement + * @param args The arguments + * @return The SQL string + */ + private static String getSql(Statement s, Object args[]) { + if (args.length > 0 && args[0] instanceof String) { + return (String) args[0]; + } + String sql = statements.get(s); + if (sql == null) { + // last resort, shouldn't happen + return "-- [unknown sql]"; + } + return sql; } - @HookClass("java.sql.Connection") - public static void prepareStatement(Event event, Connection c, String sql) { - recordSql(event, c, sql); - } + // ================================================================================================ + // Preparing calls and statements + // ================================================================================================ - @HookClass("java.sql.Connection") - public static void prepareStatement(Event event, Connection c, String sql, int autoGeneratedKeys) { - recordSql(event, c, sql); + @ArgumentArray + @HookClass(value = "java.sql.Connection", methodEvent = MethodEvent.METHOD_RETURN) + public static void prepareCall(Event event, Connection c, Object returnValue, Object[] args) { + databases.put(returnValue, getDbName(c)); + if (args.length > 0 && args[0] instanceof String) { + statements.put(returnValue, (String) args[0]); + } } - @HookClass("java.sql.Connection") - public static void prepareStatement(Event event, Connection c, String sql, int[] columnIndexes) { - recordSql(event, c, sql); + @ArgumentArray + @HookClass(value = "java.sql.Connection", methodEvent = MethodEvent.METHOD_RETURN) + public static void prepareStatement(Event event, Connection c, Object returnValue, Object[] args) { + databases.put(returnValue, getDbName(c)); + if (args.length > 0 && args[0] instanceof String) { + statements.put(returnValue, (String) args[0]); + } } - @HookClass("java.sql.Connection") - public static void prepareStatement(Event event, Connection c, String sql, int resultSetType, - int resultSetConcurrency) { - recordSql(event, c, sql); - } + // ================================================================================================ + // Batch manipulation + // ================================================================================================ - @HookClass("java.sql.Connection") - public static void prepareStatement(Event event, Connection c, String sql, int resultSetType, - int resultSetConcurrency, int resultSetHoldability) { - recordSql(event, c, sql); - } + private static final Map> batchStatements = new WeakHashMap<>(); - @HookClass("java.sql.Connection") - public static void prepareStatement(Event event, Connection c, String sql, String[] columnNames) { - recordSql(event, c, sql); + /** + * Pop the batch statements for the given statement. + * The batch statements are joined with ";\n". + * + * Note that this will remove the batch statements from the map. + * + * @param s The statement + * @return The batch statements + */ + private static String popBatchStatements(Statement s) { + synchronized (batchStatements) { + List statements = batchStatements.remove(s); + if (statements == null) { + return ""; + } + return String.join(";\n", statements); + } } + @ArgumentArray @HookClass(value = "java.sql.Statement", methodEvent = MethodEvent.METHOD_RETURN) - public static void addBatch(Event event, Statement s, String sql) { - recordSql(event, s, sql); - } - - @HookClass("java.sql.Statement") - public static void execute(Event event, Statement s, String sql) { - recordSql(event, s, sql); - } - - @HookClass("java.sql.Statement") - public static void execute(Event event, Statement s, String sql, int autoGeneratedKeys) { - recordSql(event, s, sql); - } - - @HookClass("java.sql.Statement") - public static void execute(Event event, Statement s, String sql, int[] columnIndexes) { - recordSql(event, s, sql); - } - - @HookClass("java.sql.Statement") - public static void execute(Event event, Statement s, String sql, String[] columnNames) { - recordSql(event, s, sql); - } - - @HookClass("java.sql.Statement") - public static void executeQuery(Event event, Statement s, String sql) { - recordSql(event, s, sql); - } - - @HookClass("java.sql.Statement") - public static void executeUpdate(Event event, Statement s, String sql) { - recordSql(event, s, sql); - } - - @HookClass("java.sql.Statement") - public static void executeUpdate(Event event, Statement s, String sql, int autoGeneratedKeys) { - recordSql(event, s, sql); - } - - @HookClass("java.sql.Statement") - public static void executeUpdate(Event event, Statement s, String sql, int[] columnIndexes) { - recordSql(event, s, sql); + public static void addBatch(Event event, Statement s, Object returnValue, Object[] args) { + String sql = getSql(s, args); + synchronized (batchStatements) { + batchStatements.computeIfAbsent(s, k -> new ArrayList<>()).add(sql); + } } - @HookClass("java.sql.Statement") - public static void executeUpdate(Event event, Statement s, String sql, String[] columnNames) { - recordSql(event, s, sql); + @HookClass(value = "java.sql.Statement", methodEvent = MethodEvent.METHOD_RETURN) + public static void clearBatch(Event event, Statement s, Object returnValue) { + synchronized (batchStatements) { + batchStatements.remove(s); + } } // ================================================================================================ - // Returns + // Statement.executeBatch // ================================================================================================ - @HookClass(value = "java.sql.Connection", methodEvent = MethodEvent.METHOD_RETURN) - public static void nativeSQL(Event event, Connection c, Object returnValue, String sql) { - recorder.add(event); - } - - @HookClass(value = "java.sql.Connection", methodEvent = MethodEvent.METHOD_RETURN) - public static void prepareCall(Event event, Connection c, Object returnValue, String sql) { - recorder.add(event); - } - - @HookClass(value = "java.sql.Connection", methodEvent = MethodEvent.METHOD_RETURN) - public static void prepareCall(Event event, Connection c, Object returnValue, String sql, int resultSetType, - int resultSetConcurrency) { - recorder.add(event); - } - - @HookClass(value = "java.sql.Connection", methodEvent = MethodEvent.METHOD_RETURN) - public static void prepareCall(Event event, Connection c, Object returnValue, String sql, int resultSetType, - int resultSetConcurrency, int resultSetHoldability) { - recorder.add(event); - } - - @HookClass(value = "java.sql.Connection", methodEvent = MethodEvent.METHOD_RETURN) - public static void prepareStatement(Event event, Connection c, Object returnValue, String sql) { - recorder.add(event); - } - - @HookClass(value = "java.sql.Connection", methodEvent = MethodEvent.METHOD_RETURN) - public static void prepareStatement(Event event, Connection c, Object returnValue, String sql, - int autoGeneratedKeys) { - recorder.add(event); + @HookClass(value = "java.sql.Statement") + public static void executeBatch(Event event, Statement s) { + recordSql(event, s, popBatchStatements(s)); } - @HookClass(value = "java.sql.Connection", methodEvent = MethodEvent.METHOD_RETURN) - public static void prepareStatement(Event event, Connection c, Object returnValue, String sql, int[] columnIndexes) { + @HookClass(value = "java.sql.Statement", methodEvent = MethodEvent.METHOD_RETURN) + public static void executeBatch(Event event, Statement s, Object returnValue) { recorder.add(event); } - @HookClass(value = "java.sql.Connection", methodEvent = MethodEvent.METHOD_RETURN) - public static void prepareStatement(Event event, Connection c, Object returnValue, String sql, int resultSetType, - int resultSetConcurrency) { + @HookClass(value = "java.sql.Statement", methodEvent = MethodEvent.METHOD_EXCEPTION) + public static void executeBatch(Event event, Statement s, Throwable exception) { + event.setException(exception); recorder.add(event); } - @HookClass(value = "java.sql.Connection", methodEvent = MethodEvent.METHOD_RETURN) - public static void prepareStatement(Event event, Connection c, Object returnValue, String sql, int resultSetType, - int resultSetConcurrency, int resultSetHoldability) { - recorder.add(event); - } + // ================================================================================================ + // Statement.executeLargeBatch + // ================================================================================================ - @HookClass(value = "java.sql.Connection", methodEvent = MethodEvent.METHOD_RETURN) - public static void prepareStatement(Event event, Connection c, Object returnValue, String sql, String[] columnNames) { - recorder.add(event); + @HookClass(value = "java.sql.Statement") + public static void executeLargeBatch(Event event, Statement s) { + recordSql(event, s, popBatchStatements(s)); } @HookClass(value = "java.sql.Statement", methodEvent = MethodEvent.METHOD_RETURN) - public static void addBatch(Event event, Statement s, Object returnValue, String sql) { + public static void executeLargeBatch(Event event, Statement s, Object returnValue) { recorder.add(event); } - @HookClass(value = "java.sql.Statement", methodEvent = MethodEvent.METHOD_RETURN) - public static void execute(Event event, Statement s, Object returnValue, String sql) { + @HookClass(value = "java.sql.Statement", methodEvent = MethodEvent.METHOD_EXCEPTION) + public static void executeLargeBatch(Event event, Statement s, Throwable exception) { + event.setException(exception); recorder.add(event); } - @HookClass(value = "java.sql.Statement", methodEvent = MethodEvent.METHOD_RETURN) - public static void execute(Event event, Statement s, Object returnValue, String sql, int autoGeneratedKeys) { - recorder.add(event); - } + // ================================================================================================ + // Statement.execute + // ================================================================================================ - @HookClass(value = "java.sql.Statement", methodEvent = MethodEvent.METHOD_RETURN) - public static void execute(Event event, Statement s, Object returnValue, String sql, int[] columnIndexes) { - recorder.add(event); + @HookClass("java.sql.Statement") + @ArgumentArray + public static void execute(Event event, Statement s, Object[] args) { + recordSql(event, s, args); } + @ArgumentArray @HookClass(value = "java.sql.Statement", methodEvent = MethodEvent.METHOD_RETURN) - public static void execute(Event event, Statement s, Object returnValue, String sql, String[] columnNames) { + public static void execute(Event event, Statement s, Object returnValue, Object[] args) { recorder.add(event); } - @HookClass(value = "java.sql.Statement", methodEvent = MethodEvent.METHOD_RETURN) - public static void executeQuery(Event event, Statement s, Object returnValue, String sql) { + @ArgumentArray + @HookClass(value = "java.sql.Statement", methodEvent = MethodEvent.METHOD_EXCEPTION) + public static void execute(Event event, Statement s, Throwable exception, Object[] args) { + event.setException(exception); recorder.add(event); } - @HookClass(value = "java.sql.Statement", methodEvent = MethodEvent.METHOD_RETURN) - public static void executeUpdate(Event event, Statement s, Object returnValue, String sql) { - recorder.add(event); - } + // ================================================================================================ + // Statement.executeQuery + // ================================================================================================ - @HookClass(value = "java.sql.Statement", methodEvent = MethodEvent.METHOD_RETURN) - public static void executeUpdate(Event event, Statement s, Object returnValue, String sql, int autoGeneratedKeys) { - recorder.add(event); + @ArgumentArray + @HookClass("java.sql.Statement") + public static void executeQuery(Event event, Statement s, Object[] args) { + recordSql(event, s, args); } + @ArgumentArray @HookClass(value = "java.sql.Statement", methodEvent = MethodEvent.METHOD_RETURN) - public static void executeUpdate(Event event, Statement s, Object returnValue, String sql, int[] columnIndexes) { + public static void executeQuery(Event event, Statement s, Object returnValue, Object[] args) { recorder.add(event); } - @HookClass(value = "java.sql.Statement", methodEvent = MethodEvent.METHOD_RETURN) - public static void executeUpdate(Event event, Statement s, Object returnValue, String sql, String[] columnNames) { + @ArgumentArray + @HookClass(value = "java.sql.Statement", methodEvent = MethodEvent.METHOD_EXCEPTION) + public static void executeQuery(Event event, Statement s, Throwable exception, Object[] args) { + event.setException(exception); recorder.add(event); } // ================================================================================================ - // Exceptions + // Statement.executeUpdate // ================================================================================================ - /* - * Many of the methods below are overloaded. However, the hook implementations - * don't make use of the arguments passed to the original method. So, take - * advantage of ArgumentArray's "feature" that causes it to match all - * overloaded mehods by name, and have the hook apply to each of them. - */ - - @ArgumentArray - @HookClass(value = "java.sql.Connection", methodEvent = MethodEvent.METHOD_EXCEPTION) - public static void nativeSQL(Event event, Connection c, Throwable exception, Object[] args) { - event.setException(exception); - recorder.add(event); - } - @ArgumentArray - @HookClass(value = "java.sql.Connection", methodEvent = MethodEvent.METHOD_EXCEPTION) - public static void prepareCall(Event event, Connection c, Throwable exception, Object[] args) { - event.setException(exception); - recorder.add(event); + @HookClass("java.sql.Statement") + public static void executeUpdate(Event event, Statement s, Object args[]) { + recordSql(event, s, args); } @ArgumentArray - @HookClass(value = "java.sql.Connection", methodEvent = MethodEvent.METHOD_EXCEPTION) - public static void prepareStatement(Event event, Connection c, Throwable exception, Object[] args) { - event.setException(exception); + @HookClass(value = "java.sql.Statement", methodEvent = MethodEvent.METHOD_RETURN) + public static void executeUpdate(Event event, Statement s, Object returnValue, Object[] args) { recorder.add(event); } @ArgumentArray @HookClass(value = "java.sql.Statement", methodEvent = MethodEvent.METHOD_EXCEPTION) - public static void addBatch(Event event, Statement s, Throwable exception, Object[] args) { + public static void executeUpdate(Event event, Statement s, Throwable exception, Object[] args) { event.setException(exception); recorder.add(event); } + // ================================================================================================ + // Statement.executeLargeUpdate + // ================================================================================================ + @ArgumentArray - @HookClass(value = "java.sql.Statement", methodEvent = MethodEvent.METHOD_EXCEPTION) - public static void execute(Event event, Statement s, Throwable exception, Object[] args) { - event.setException(exception); - recorder.add(event); + @HookClass("java.sql.Statement") + public static void executeLargeUpdate(Event event, Statement s, Object args[]) { + recordSql(event, s, args); } @ArgumentArray - @HookClass(value = "java.sql.Statement", methodEvent = MethodEvent.METHOD_EXCEPTION) - public static void executeQuery(Event event, Statement s, Throwable exception, Object[] args) { - event.setException(exception); + @HookClass(value = "java.sql.Statement", methodEvent = MethodEvent.METHOD_RETURN) + public static void executeLargeUpdate(Event event, Statement s, Object returnValue, Object[] args) { recorder.add(event); } @ArgumentArray @HookClass(value = "java.sql.Statement", methodEvent = MethodEvent.METHOD_EXCEPTION) - public static void executeUpdate(Event event, Statement s, Throwable exception, Object[] args) { + public static void executeLargeUpdate(Event event, Statement s, Throwable exception, Object[] args) { event.setException(exception); recorder.add(event); } diff --git a/agent/src/test/java/com/appland/appmap/process/hooks/SqlQuerySQLExceptionAvailabilityTest.java b/agent/src/test/java/com/appland/appmap/process/hooks/SqlQuerySQLExceptionAvailabilityTest.java new file mode 100644 index 00000000..eca24a71 --- /dev/null +++ b/agent/src/test/java/com/appland/appmap/process/hooks/SqlQuerySQLExceptionAvailabilityTest.java @@ -0,0 +1,121 @@ +package com.appland.appmap.process.hooks; + +import org.junit.jupiter.api.Test; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.lang.reflect.Method; +import java.sql.Statement; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.mockito.Mockito.mock; + +/** + * Regression test for a {@link NoClassDefFoundError} involving {@link java.sql.SQLException}. + *

+ * In certain environments (e.g., specific configurations of Oracle UCP or custom container classloaders), + * {@code java.sql.SQLException} might not be visible to the classloader responsible for loading + * {@code com.appland.appmap.process.hooks.SqlQuery}. This can lead to a crash when the agent attempts + * to handle SQL events. + *

+ * The crash manifests as: + *

+ * Caused by: com.example.operation.flow.FlowException: java/sql/SQLException
+ * ...
+ * Caused by: java.lang.NoClassDefFoundError: java/sql/SQLException
+ *     at com.appland.appmap.process.hooks.SqlQuery.getDbName(SqlQuery.java:76)
+ *     at com.appland.appmap.process.hooks.SqlQuery.recordSql(SqlQuery.java:89)
+ *     at com.appland.appmap.process.hooks.SqlQuery.executeQuery(SqlQuery.java:172)
+ * 
+ *

+ * This test reproduces the environment by using a custom {@link ClassLoader} that explicitly + * throws {@link ClassNotFoundException} when {@code java.sql.SQLException} is requested. + * It verifies that {@code SqlQuery} can be loaded and executed without triggering the error. + */ +public class SqlQuerySQLExceptionAvailabilityTest { + + @Test + public void testSqlQueryResilienceToMissingSQLException() throws Exception { + // 1. Create a RestrictedClassLoader that hides java.sql.SQLException + ClassLoader restrictedLoader = new RestrictedClassLoader(this.getClass().getClassLoader()); + + // 2. Load the SqlQuery class using the restricted loader. + // This forces the verifier to check dependencies of SqlQuery using our restricted loader. + // If SqlQuery explicitly catches or references SQLException in a way that requires resolution, + // this (or the method invocation below) should fail. + String sqlQueryClassName = "com.appland.appmap.process.hooks.SqlQuery"; + Class sqlQueryClass = restrictedLoader.loadClass(sqlQueryClassName); + + // 3. Invoke a method that triggers the problematic code path (getDbName). + // We choose recordSql(Event, Connection, String) which calls getDbName(Connection). + Method recordSqlMethod = sqlQueryClass.getMethod("recordSql", + com.appland.appmap.output.v1.Event.class, + java.sql.Statement.class, + String.class + ); + + // Prepare arguments + com.appland.appmap.output.v1.Event mockEvent = mock(com.appland.appmap.output.v1.Event.class); + java.sql.Statement mockStatement = mock(java.sql.Statement.class); + + assertDoesNotThrow(() -> { + recordSqlMethod.invoke(null, mockEvent, mockStatement, "SELECT 1"); + }, "SqlQuery should not fail even if java.sql.SQLException is missing"); + } + + /** + * A ClassLoader that throws ClassNotFoundException for java.sql.SQLException + * and forces re-definition of SqlQuery to ensure it's loaded by this loader. + */ + private static class RestrictedClassLoader extends ClassLoader { + + public RestrictedClassLoader(ClassLoader parent) { + super(parent); + } + + @Override + public Class loadClass(String name) throws ClassNotFoundException { + String forbiddenClassName = "java.sql.SQLException"; + if (forbiddenClassName.equals(name)) { + throw new ClassNotFoundException("Simulated missing class: " + name); + } + + // If it's the target class, we want to define it ourselves to ensure + // this classloader (and its restrictions) is used for verification. + String targetClassName = "com.appland.appmap.process.hooks.SqlQuery"; + if (targetClassName.equals(name)) { + // Check if already loaded + Class loaded = findLoadedClass(name); + if (loaded != null) { + return loaded; + } + + try { + byte[] bytes = loadClassBytes(name); + return defineClass(name, bytes, 0, bytes.length); + } catch (IOException e) { + throw new ClassNotFoundException("Failed to read bytes for " + name, e); + } + } + + // For everything else, delegate to parent + return super.loadClass(name); + } + + private byte[] loadClassBytes(String className) throws IOException { + String resourceName = "/" + className.replace('.', '/') + ".class"; + try (InputStream is = getClass().getResourceAsStream(resourceName)) { + if (is == null) { + throw new IOException("Resource not found: " + resourceName); + } + ByteArrayOutputStream stream = new ByteArrayOutputStream(); + byte[] buffer = new byte[4096]; + int bytesRead; + while ((bytesRead = is.read(buffer)) != -1) { + stream.write(buffer, 0, bytesRead); + } + return stream.toByteArray(); + } + } + } +} diff --git a/agent/test/jdbc/build.gradle b/agent/test/jdbc/build.gradle index ea106d16..162848ca 100644 --- a/agent/test/jdbc/build.gradle +++ b/agent/test/jdbc/build.gradle @@ -2,21 +2,25 @@ plugins { id 'org.springframework.boot' version '2.7.0' id 'io.spring.dependency-management' version '1.0.11.RELEASE' id 'java' - // id 'com.appland.appmap' version '1.1.0' } group = 'com.example' version = '0.0.1-SNAPSHOT' sourceCompatibility = '1.8' +// suppress warnings about source compatibility +tasks.withType(JavaCompile) { + options.compilerArgs << '-Xlint:-options' +} + repositories { - // mavenLocal() mavenCentral() } dependencies { implementation 'org.springframework.boot:spring-boot-starter-data-jpa' runtimeOnly 'com.h2database:h2' + runtimeOnly 'com.oracle.database.jdbc:ojdbc8:21.9.0.0' testImplementation 'org.springframework.boot:spring-boot-starter-test' } @@ -24,11 +28,16 @@ def appmapJar = "$System.env.AGENT_JAR" test { useJUnitPlatform() + if (System.env.ORACLE_URL) { + inputs.property("oracleUrl", System.env.ORACLE_URL) + } + 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", - // "-Dappmap.debug.file=../../build/log/jdbc-appmap.log" + "-javaagent:${appmapJar}", + "-Dappmap.config.file=appmap.yml", + "-Djava.util.logging.config.file=${System.env.JUL_CONFIG}", + // "-Dappmap.debug=true", ] } diff --git a/agent/test/jdbc/docker-compose.yml b/agent/test/jdbc/docker-compose.yml new file mode 100644 index 00000000..a2ed3ffa --- /dev/null +++ b/agent/test/jdbc/docker-compose.yml @@ -0,0 +1,10 @@ +# This docker-compose file is used for local, manual execution of the Oracle JDBC integration tests. +# It starts a standalone Oracle database for testing purposes. +version: '3.8' +services: + oracle: + image: docker.io/gvenzl/oracle-free:slim-faststart + ports: + - "1521:1521" + environment: + ORACLE_PASSWORD: oracle diff --git a/agent/test/jdbc/helper.bash b/agent/test/jdbc/helper.bash new file mode 100644 index 00000000..9499bb6f --- /dev/null +++ b/agent/test/jdbc/helper.bash @@ -0,0 +1,66 @@ +#!/usr/bin/env bash + +# generate_sql_snapshots +# +# Generates .sql files in from .appmap.json files in +# that match . +generate_sql_snapshots() { + local appmap_dir="$1" + local target_dir="$2" + local file_glob="$3" + + mkdir -p "$target_dir" + + for f in "$appmap_dir"/$file_glob; do + if [ -f "$f" ]; then + local snapshot_name + snapshot_name=$(basename "$f" .appmap.json).sql + jq -r '.events[] | select(.sql_query) | .sql_query.sql' "$f" >"$target_dir/$snapshot_name" + fi + done +} + +# assert_all_calls_returned [ ...] +# +# Validates that all 'call' events in AppMap JSON file(s) have corresponding 'return' events. +# Returns failure if any call IDs are missing their return events (orphaned calls). +# Supports multiple files as arguments. +assert_all_calls_returned() { + local has_errors=0 + + for json_file in "$@"; do + if [ ! -f "$json_file" ]; then + echo "File not found: $json_file" + has_errors=1 + continue + fi + + # Extract IDs that exist as 'call' but not as a 'return' parent_id + local orphans + orphans=$(jq -e -r '.events | + (map(select(.event == "call").id) // []) as $calls | + (map(select(.event == "return").parent_id) // []) as $returns | + ($calls - $returns)[] + ' "$json_file" 2>/dev/null) + + # If orphans is not empty, print them and mark as error + if [[ -n "$orphans" ]]; then + echo "Validation Failed: $json_file" + echo "The following call IDs are missing a return event:" + echo "$orphans" + has_errors=1 + fi + done + + return $has_errors +} + +# requires_oracle +# +# Skips the current test if ORACLE_URL environment variable is not set. +# Used to conditionally run Oracle-specific tests. +requires_oracle() { + if [ -z "$ORACLE_URL" ]; then + skip "ORACLE_URL is not set" + fi +} diff --git a/agent/test/jdbc/jdbc.bats b/agent/test/jdbc/jdbc.bats old mode 100644 new mode 100755 index f72a8977..57e870ed --- a/agent/test/jdbc/jdbc.bats +++ b/agent/test/jdbc/jdbc.bats @@ -1,9 +1,10 @@ #!/usr/bin/env bats load '../helper' +load 'helper' setup_file() { - cd test/jdbc + cd "$BATS_TEST_DIRNAME" || exit 1 _configure_logging gradlew -q clean @@ -13,19 +14,19 @@ setup() { rm -rf tmp/appmap } -@test "successful test" { - run gradlew -q test --tests 'CustomerRepositoryTests.testFindFromBogusTable' +@test "h2 successful test" { + run gradlew -q test --tests 'CustomerRepositoryTests.testFindFromBogusTable' --rerun-tasks assert_success output="$(<./tmp/appmap/junit/com_example_accessingdatajpa_CustomerRepositoryTests_testFindFromBogusTable.appmap.json)" assert_json_eq '.metadata.test_status' succeeded - assert_json_eq '.events | length' 6 - assert_json_eq '.events[3].exceptions | length' 1 - assert_json_eq '.events[3].exceptions[0].class' org.h2.jdbc.JdbcSQLSyntaxErrorException + assert_json_eq '.events | length' 4 + assert_json_eq '.events[2].exceptions | length' 3 + assert_json_eq '.events[2].exceptions[2].class' org.h2.jdbc.JdbcSQLSyntaxErrorException } -@test "failing test" { - run gradlew -q test --tests 'CustomerRepositoryTests.testFails' +@test "h2 failing test" { + run gradlew -q test --tests 'CustomerRepositoryTests.testFails' --rerun-tasks assert_failure output="$(<./tmp/appmap/junit/com_example_accessingdatajpa_CustomerRepositoryTests_testFails.appmap.json)" @@ -33,5 +34,64 @@ setup() { assert_json_eq '.metadata.test_failure.message' 'expected: but was: ' } +# Requires a running Oracle instance. +# Locally: docker-compose up -d (in agent/test/jdbc) +# CI: Service is configured in .github/workflows/build-and-test.yml +@test "oracle jpa test" { + requires_oracle + run gradlew -q test --tests 'OracleRepositoryTests' --rerun-tasks + assert_success + + map_file="tmp/appmap/junit/com_example_accessingdatajpa_OracleRepositoryTests_testFindByLastName.appmap.json" + [ -f "$map_file" ] + output="$(<"$map_file")" + assert_json_eq '.metadata.test_status' succeeded + event_count=$(echo "$output" | jq '.events | length') + if [ "$event_count" -le 0 ]; then + echo "Expected event count to be greater than 0, but it was $event_count" + return 1 + fi +} + +# To regenerate the SQL snapshots, run ./regenerate_jdbc_snapshots.sh from this directory. +@test "h2 pure jdbc test suite (snapshot)" { + export -n ORACLE_URL + run gradlew -q test --tests 'PureJDBCTests' --rerun-tasks + assert_success + + local appmap_dir="tmp/appmap/junit" + local snapshot_dir="snapshots/h2" + local test_output_dir + test_output_dir="$(mktemp -d)" + + generate_sql_snapshots "$appmap_dir" "$test_output_dir" "com_example_accessingdatajpa_PureJDBCTests_*.appmap.json" + + run assert_all_calls_returned "$appmap_dir"/*.appmap.json + assert_success + run diff -u <(cd "$snapshot_dir" && grep -ri . | sort -s -t: -k1,1) <(cd "$test_output_dir" && grep -ri . | sort -s -t: -k1,1) + assert_success "Snapshot mismatch" + rm -rf "$test_output_dir" +} + +@test "oracle pure jdbc test suite (snapshot)" { + requires_oracle + export ORACLE_URL + run gradlew -q test --tests 'PureJDBCTests' --rerun-tasks + assert_success + + local appmap_dir="tmp/appmap/junit" + local snapshot_dir="snapshots/oracle" + local test_output_dir + test_output_dir="$(mktemp -d)" + + generate_sql_snapshots "$appmap_dir" "$test_output_dir" "com_example_accessingdatajpa_PureJDBCTests_*.appmap.json" + + run assert_all_calls_returned "$appmap_dir"/*.appmap.json + assert_success + run diff -u <(cd "$snapshot_dir" && grep -ri . | sort -s -t: -k1,1) <(cd "$test_output_dir" && grep -ri . | sort -s -t: -k1,1) + assert_success "Snapshot mismatch" + + rm -rf "$test_output_dir" +} diff --git a/agent/test/jdbc/regenerate_jdbc_snapshots.sh b/agent/test/jdbc/regenerate_jdbc_snapshots.sh new file mode 100755 index 00000000..348c4269 --- /dev/null +++ b/agent/test/jdbc/regenerate_jdbc_snapshots.sh @@ -0,0 +1,57 @@ +#!/usr/bin/env bash + +set -eo pipefail + +# This script regenerates the SQL snapshots for the PureJDBCTests. +# It should be run from the agent/test/jdbc directory. +# +# Usage: +# ./regenerate_jdbc_snapshots.sh # Regenerate H2 snapshots +# ORACLE_URL=... ./regenerate_jdbc_snapshots.sh # Regenerate Oracle snapshots + +# Source helper.bash to get _find_agent_jar function +# Set BATS_TEST_DIR so helper.bash can locate files correctly +export BATS_TEST_DIR="$(pwd)" +source ../helper.bash +source ./helper.bash + +find_agent_jar +if [[ -z "$AGENT_JAR" ]]; then + echo "ERROR: Agent JAR not found by helper.bash. Please ensure the agent is built." >&2 + exit 1 +fi + +export AGENT_JAR + +regenerate_snapshots() { + local db_type="$1" + local snapshot_dir="$PWD/snapshots/$db_type" + local appmap_dir="$PWD/tmp/appmap/junit" + + echo "INFO: Regenerating $db_type snapshots..." + + # Clear old snapshots and appmap dirs + rm -f "$snapshot_dir"/* + rm -f "$appmap_dir"/com_example_accessingdatajpa_PureJDBCTests_*.appmap.json + + # Run the tests to generate fresh AppMaps + ../gradlew -q test --tests 'PureJDBCTests' --rerun-tasks + + echo "INFO: Generating raw SQL snapshots for $db_type..." + + # Generate new raw SQL snapshots + generate_sql_snapshots "$appmap_dir" "$snapshot_dir" "com_example_accessingdatajpa_PureJDBCTests_*.appmap.json" + + echo "INFO: $db_type snapshots regenerated successfully in $snapshot_dir" +} + +if [[ -z "${ORACLE_URL:-}" ]]; then + echo "WARNING: ORACLE_URL is not set. Skipping Oracle snapshot regeneration." >&2 + echo "To regenerate Oracle snapshots, set ORACLE_URL and run this script again." >&2 +else + export ORACLE_URL + regenerate_snapshots "oracle" +fi + +unset ORACLE_URL +regenerate_snapshots "h2" diff --git a/agent/test/jdbc/snapshots/h2/com_example_accessingdatajpa_PureJDBCTests_testBatch.sql b/agent/test/jdbc/snapshots/h2/com_example_accessingdatajpa_PureJDBCTests_testBatch.sql new file mode 100644 index 00000000..9f689b39 --- /dev/null +++ b/agent/test/jdbc/snapshots/h2/com_example_accessingdatajpa_PureJDBCTests_testBatch.sql @@ -0,0 +1,6 @@ +INSERT INTO customer (id, first_name, last_name) VALUES (5, 'I', 'J'); +INSERT INTO customer (id, first_name, last_name) VALUES (6, 'K', 'L') +INSERT INTO customer (id, first_name, last_name) VALUES (7, 'M', 'N'); +INSERT INTO customer (id, first_name, last_name) VALUES (8, 'O', 'P') + +SELECT * FROM customer WHERE batch error = ? diff --git a/agent/test/jdbc/snapshots/h2/com_example_accessingdatajpa_PureJDBCTests_testCallableStatement.sql b/agent/test/jdbc/snapshots/h2/com_example_accessingdatajpa_PureJDBCTests_testCallableStatement.sql new file mode 100644 index 00000000..afdd2476 --- /dev/null +++ b/agent/test/jdbc/snapshots/h2/com_example_accessingdatajpa_PureJDBCTests_testCallableStatement.sql @@ -0,0 +1,6 @@ + call test_proc(?, ?) -- call 1 + call test_proc(?, ?) -- call 1 + call test_proc(?, ?) -- call 2 + call test_proc(?, ?) -- call 2 + call test_proc(?, ?) -- call 3 + call test_proc(?, ?) -- call 3 diff --git a/agent/test/jdbc/snapshots/h2/com_example_accessingdatajpa_PureJDBCTests_testExceptions.sql b/agent/test/jdbc/snapshots/h2/com_example_accessingdatajpa_PureJDBCTests_testExceptions.sql new file mode 100644 index 00000000..739a5c57 --- /dev/null +++ b/agent/test/jdbc/snapshots/h2/com_example_accessingdatajpa_PureJDBCTests_testExceptions.sql @@ -0,0 +1 @@ +SELECT * FROM non_existent_table diff --git a/agent/test/jdbc/snapshots/h2/com_example_accessingdatajpa_PureJDBCTests_testExecuteQuery.sql b/agent/test/jdbc/snapshots/h2/com_example_accessingdatajpa_PureJDBCTests_testExecuteQuery.sql new file mode 100644 index 00000000..ce73a32d --- /dev/null +++ b/agent/test/jdbc/snapshots/h2/com_example_accessingdatajpa_PureJDBCTests_testExecuteQuery.sql @@ -0,0 +1 @@ +SELECT * FROM customer diff --git a/agent/test/jdbc/snapshots/h2/com_example_accessingdatajpa_PureJDBCTests_testExecuteUpdate.sql b/agent/test/jdbc/snapshots/h2/com_example_accessingdatajpa_PureJDBCTests_testExecuteUpdate.sql new file mode 100644 index 00000000..bba438c3 --- /dev/null +++ b/agent/test/jdbc/snapshots/h2/com_example_accessingdatajpa_PureJDBCTests_testExecuteUpdate.sql @@ -0,0 +1,8 @@ +INSERT INTO customer (id, first_name, last_name) VALUES (20, 'A', 'B') +UPDATE customer SET first_name = 'C' WHERE id = 20 +DELETE FROM customer WHERE id = 20 +INSERT INTO customer (id, first_name, last_name) VALUES (21, 'D', 'E') +UPDATE customer SET first_name = 'F' WHERE id = 21 +UPDATE customer SET first_name = 'G' WHERE id = 21 +UPDATE customer SET first_name = 'H' WHERE id = 21 +UPDATE customer SET first_name = 'I' WHERE id = 21 diff --git a/agent/test/jdbc/snapshots/h2/com_example_accessingdatajpa_PureJDBCTests_testNativeSQL.sql b/agent/test/jdbc/snapshots/h2/com_example_accessingdatajpa_PureJDBCTests_testNativeSQL.sql new file mode 100644 index 00000000..e69de29b diff --git a/agent/test/jdbc/snapshots/h2/com_example_accessingdatajpa_PureJDBCTests_testPrepareStatement.sql b/agent/test/jdbc/snapshots/h2/com_example_accessingdatajpa_PureJDBCTests_testPrepareStatement.sql new file mode 100644 index 00000000..bac37fe8 --- /dev/null +++ b/agent/test/jdbc/snapshots/h2/com_example_accessingdatajpa_PureJDBCTests_testPrepareStatement.sql @@ -0,0 +1,12 @@ +SELECT * FROM customer WHERE id = ? -- op 1 +SELECT * FROM customer WHERE id = ? -- op 1 +SELECT first_name FROM customer WHERE id = ? -- op 2 +SELECT first_name FROM customer WHERE id = ? -- op 2 +UPDATE customer SET last_name = ? WHERE id = ? -- op 3 +UPDATE customer SET last_name = ? WHERE id = ? -- op 3 +UPDATE customer SET first_name = ? WHERE id = ? -- op 4 +UPDATE customer SET first_name = ? WHERE id = ? -- op 4 +UPDATE customer SET last_name = ? WHERE id = ? -- op 5 +UPDATE customer SET last_name = ? WHERE id = ? -- op 5 +SELECT count(*) FROM customer WHERE id = ? -- op 6 +SELECT count(*) FROM customer WHERE id = ? -- op 6 diff --git a/agent/test/jdbc/snapshots/h2/com_example_accessingdatajpa_PureJDBCTests_testPreparedStatementBatch.sql b/agent/test/jdbc/snapshots/h2/com_example_accessingdatajpa_PureJDBCTests_testPreparedStatementBatch.sql new file mode 100644 index 00000000..6cd539f0 --- /dev/null +++ b/agent/test/jdbc/snapshots/h2/com_example_accessingdatajpa_PureJDBCTests_testPreparedStatementBatch.sql @@ -0,0 +1,4 @@ +INSERT INTO customer (id, first_name, last_name) VALUES (?, ?, ?); +INSERT INTO customer (id, first_name, last_name) VALUES (?, ?, ?) +INSERT INTO customer (id, first_name, last_name) VALUES (?, ?, ?); +INSERT INTO customer (id, first_name, last_name) VALUES (?, ?, ?) diff --git a/agent/test/jdbc/snapshots/h2/com_example_accessingdatajpa_PureJDBCTests_testStatementExecute.sql b/agent/test/jdbc/snapshots/h2/com_example_accessingdatajpa_PureJDBCTests_testStatementExecute.sql new file mode 100644 index 00000000..a31275b2 --- /dev/null +++ b/agent/test/jdbc/snapshots/h2/com_example_accessingdatajpa_PureJDBCTests_testStatementExecute.sql @@ -0,0 +1,4 @@ +INSERT INTO customer (id, first_name, last_name) VALUES (1, 'A', 'B') +UPDATE customer SET first_name = 'C' WHERE id = 1 +DELETE FROM customer WHERE id = 1 +INSERT INTO customer (id, first_name, last_name) VALUES (2, 'X', 'Y') diff --git a/agent/test/jdbc/snapshots/oracle/com_example_accessingdatajpa_PureJDBCTests_testBatch.sql b/agent/test/jdbc/snapshots/oracle/com_example_accessingdatajpa_PureJDBCTests_testBatch.sql new file mode 100644 index 00000000..9f689b39 --- /dev/null +++ b/agent/test/jdbc/snapshots/oracle/com_example_accessingdatajpa_PureJDBCTests_testBatch.sql @@ -0,0 +1,6 @@ +INSERT INTO customer (id, first_name, last_name) VALUES (5, 'I', 'J'); +INSERT INTO customer (id, first_name, last_name) VALUES (6, 'K', 'L') +INSERT INTO customer (id, first_name, last_name) VALUES (7, 'M', 'N'); +INSERT INTO customer (id, first_name, last_name) VALUES (8, 'O', 'P') + +SELECT * FROM customer WHERE batch error = ? diff --git a/agent/test/jdbc/snapshots/oracle/com_example_accessingdatajpa_PureJDBCTests_testCallableStatement.sql b/agent/test/jdbc/snapshots/oracle/com_example_accessingdatajpa_PureJDBCTests_testCallableStatement.sql new file mode 100644 index 00000000..0823fdfe --- /dev/null +++ b/agent/test/jdbc/snapshots/oracle/com_example_accessingdatajpa_PureJDBCTests_testCallableStatement.sql @@ -0,0 +1,6 @@ +{call test_proc(?, ?)} -- call 1 +{call test_proc(?, ?)} -- call 1 +{call test_proc(?, ?)} -- call 2 +{call test_proc(?, ?)} -- call 2 +{call test_proc(?, ?)} -- call 3 +{call test_proc(?, ?)} -- call 3 diff --git a/agent/test/jdbc/snapshots/oracle/com_example_accessingdatajpa_PureJDBCTests_testExceptions.sql b/agent/test/jdbc/snapshots/oracle/com_example_accessingdatajpa_PureJDBCTests_testExceptions.sql new file mode 100644 index 00000000..6ecb2a6d --- /dev/null +++ b/agent/test/jdbc/snapshots/oracle/com_example_accessingdatajpa_PureJDBCTests_testExceptions.sql @@ -0,0 +1,2 @@ +SELECT * FROM non_existent_table +INVALID SQL diff --git a/agent/test/jdbc/snapshots/oracle/com_example_accessingdatajpa_PureJDBCTests_testExecuteQuery.sql b/agent/test/jdbc/snapshots/oracle/com_example_accessingdatajpa_PureJDBCTests_testExecuteQuery.sql new file mode 100644 index 00000000..ce73a32d --- /dev/null +++ b/agent/test/jdbc/snapshots/oracle/com_example_accessingdatajpa_PureJDBCTests_testExecuteQuery.sql @@ -0,0 +1 @@ +SELECT * FROM customer diff --git a/agent/test/jdbc/snapshots/oracle/com_example_accessingdatajpa_PureJDBCTests_testExecuteUpdate.sql b/agent/test/jdbc/snapshots/oracle/com_example_accessingdatajpa_PureJDBCTests_testExecuteUpdate.sql new file mode 100644 index 00000000..bba438c3 --- /dev/null +++ b/agent/test/jdbc/snapshots/oracle/com_example_accessingdatajpa_PureJDBCTests_testExecuteUpdate.sql @@ -0,0 +1,8 @@ +INSERT INTO customer (id, first_name, last_name) VALUES (20, 'A', 'B') +UPDATE customer SET first_name = 'C' WHERE id = 20 +DELETE FROM customer WHERE id = 20 +INSERT INTO customer (id, first_name, last_name) VALUES (21, 'D', 'E') +UPDATE customer SET first_name = 'F' WHERE id = 21 +UPDATE customer SET first_name = 'G' WHERE id = 21 +UPDATE customer SET first_name = 'H' WHERE id = 21 +UPDATE customer SET first_name = 'I' WHERE id = 21 diff --git a/agent/test/jdbc/snapshots/oracle/com_example_accessingdatajpa_PureJDBCTests_testNativeSQL.sql b/agent/test/jdbc/snapshots/oracle/com_example_accessingdatajpa_PureJDBCTests_testNativeSQL.sql new file mode 100644 index 00000000..e69de29b diff --git a/agent/test/jdbc/snapshots/oracle/com_example_accessingdatajpa_PureJDBCTests_testPrepareStatement.sql b/agent/test/jdbc/snapshots/oracle/com_example_accessingdatajpa_PureJDBCTests_testPrepareStatement.sql new file mode 100644 index 00000000..bac37fe8 --- /dev/null +++ b/agent/test/jdbc/snapshots/oracle/com_example_accessingdatajpa_PureJDBCTests_testPrepareStatement.sql @@ -0,0 +1,12 @@ +SELECT * FROM customer WHERE id = ? -- op 1 +SELECT * FROM customer WHERE id = ? -- op 1 +SELECT first_name FROM customer WHERE id = ? -- op 2 +SELECT first_name FROM customer WHERE id = ? -- op 2 +UPDATE customer SET last_name = ? WHERE id = ? -- op 3 +UPDATE customer SET last_name = ? WHERE id = ? -- op 3 +UPDATE customer SET first_name = ? WHERE id = ? -- op 4 +UPDATE customer SET first_name = ? WHERE id = ? -- op 4 +UPDATE customer SET last_name = ? WHERE id = ? -- op 5 +UPDATE customer SET last_name = ? WHERE id = ? -- op 5 +SELECT count(*) FROM customer WHERE id = ? -- op 6 +SELECT count(*) FROM customer WHERE id = ? -- op 6 diff --git a/agent/test/jdbc/snapshots/oracle/com_example_accessingdatajpa_PureJDBCTests_testPreparedStatementBatch.sql b/agent/test/jdbc/snapshots/oracle/com_example_accessingdatajpa_PureJDBCTests_testPreparedStatementBatch.sql new file mode 100644 index 00000000..6cd539f0 --- /dev/null +++ b/agent/test/jdbc/snapshots/oracle/com_example_accessingdatajpa_PureJDBCTests_testPreparedStatementBatch.sql @@ -0,0 +1,4 @@ +INSERT INTO customer (id, first_name, last_name) VALUES (?, ?, ?); +INSERT INTO customer (id, first_name, last_name) VALUES (?, ?, ?) +INSERT INTO customer (id, first_name, last_name) VALUES (?, ?, ?); +INSERT INTO customer (id, first_name, last_name) VALUES (?, ?, ?) diff --git a/agent/test/jdbc/snapshots/oracle/com_example_accessingdatajpa_PureJDBCTests_testStatementExecute.sql b/agent/test/jdbc/snapshots/oracle/com_example_accessingdatajpa_PureJDBCTests_testStatementExecute.sql new file mode 100644 index 00000000..a31275b2 --- /dev/null +++ b/agent/test/jdbc/snapshots/oracle/com_example_accessingdatajpa_PureJDBCTests_testStatementExecute.sql @@ -0,0 +1,4 @@ +INSERT INTO customer (id, first_name, last_name) VALUES (1, 'A', 'B') +UPDATE customer SET first_name = 'C' WHERE id = 1 +DELETE FROM customer WHERE id = 1 +INSERT INTO customer (id, first_name, last_name) VALUES (2, 'X', 'Y') diff --git a/agent/test/jdbc/src/test/java/com/example/accessingdatajpa/OracleRepositoryTests.java b/agent/test/jdbc/src/test/java/com/example/accessingdatajpa/OracleRepositoryTests.java new file mode 100644 index 00000000..1c0456b0 --- /dev/null +++ b/agent/test/jdbc/src/test/java/com/example/accessingdatajpa/OracleRepositoryTests.java @@ -0,0 +1,41 @@ +package com.example.accessingdatajpa; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.sql.SQLException; +import java.util.List; +import javax.sql.DataSource; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; +import org.springframework.boot.test.autoconfigure.orm.jpa.TestEntityManager; +import org.springframework.test.context.ActiveProfiles; + +@DataJpaTest +@ActiveProfiles("oracle") +@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) +@EnabledIfEnvironmentVariable(named = "ORACLE_URL", matches = ".*") +public class OracleRepositoryTests { + + @Autowired + private TestEntityManager entityManager; + + @Autowired + private CustomerRepository customers; + + @Autowired + private DataSource dataSource; + + @Test + public void testFindByLastName() { + Customer customer = new Customer("Oracle", "User"); + entityManager.persist(customer); + + List findByLastName = customers.findByLastName(customer.getLastName()); + + assertThat(findByLastName).extracting(Customer::getLastName) + .containsOnly(customer.getLastName()); + } +} diff --git a/agent/test/jdbc/src/test/java/com/example/accessingdatajpa/PureJDBCTests.java b/agent/test/jdbc/src/test/java/com/example/accessingdatajpa/PureJDBCTests.java new file mode 100644 index 00000000..a92b609c --- /dev/null +++ b/agent/test/jdbc/src/test/java/com/example/accessingdatajpa/PureJDBCTests.java @@ -0,0 +1,303 @@ +package com.example.accessingdatajpa; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.sql.CallableStatement; +import java.sql.Connection; +import java.sql.DriverManager; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.sql.Statement; +import org.junit.jupiter.api.AfterEach; +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; + +@Execution(ExecutionMode.SAME_THREAD) +public class PureJDBCTests { + + private Connection connection; + private boolean isOracle; + + @BeforeEach + public void setUp() throws SQLException { + String oracleUrl = System.getenv("ORACLE_URL"); + + // Determine which database to use + if (oracleUrl != null && !oracleUrl.isEmpty()) { + // Use Oracle + isOracle = true; + String oracleUsername = System.getenv("ORACLE_USERNAME"); + if (oracleUsername == null) { + oracleUsername = "system"; + } + String oraclePassword = System.getenv("ORACLE_PASSWORD"); + if (oraclePassword == null) { + oraclePassword = "oracle"; + } + connection = DriverManager.getConnection(oracleUrl, oracleUsername, oraclePassword); + } else { + // Use H2 + isOracle = false; + connection = DriverManager.getConnection("jdbc:h2:mem:testdb", "sa", ""); + } + + // Create table with database-specific DDL + try (Statement statement = connection.createStatement()) { + String createTableSql; + if (isOracle) { + createTableSql = "CREATE TABLE customer (id NUMBER(19,0) NOT NULL, first_name VARCHAR2(255 CHAR), last_name VARCHAR2(255 CHAR), PRIMARY KEY (id))"; + } else { + createTableSql = "CREATE TABLE customer (id BIGINT NOT NULL, first_name VARCHAR(255), last_name VARCHAR(255), PRIMARY KEY (id))"; + } + statement.execute("DROP TABLE IF EXISTS customer"); + statement.execute(createTableSql); + + // Create a test procedure for CallableStatement tests + if (isOracle) { + statement.execute("CREATE OR REPLACE PROCEDURE test_proc(p1 IN VARCHAR2, p2 IN VARCHAR2) AS BEGIN NULL; END;"); + } else { + statement.execute("CREATE ALIAS IF NOT EXISTS test_proc FOR \"java.lang.System.setProperty\""); + } + } + } + + @AfterEach + public void tearDown() throws SQLException { + try (Statement statement = connection.createStatement()) { + statement.execute("DROP TABLE customer"); + } + if (connection != null && !connection.isClosed()) { + connection.close(); + } + } + + @Test + void testStatementExecute() throws Exception { + try (Statement stmt = connection.createStatement()) { + stmt.execute("INSERT INTO customer (id, first_name, last_name) VALUES (1, 'A', 'B')"); + stmt.execute("UPDATE customer SET first_name = 'C' WHERE id = 1", Statement.NO_GENERATED_KEYS); + stmt.execute("DELETE FROM customer WHERE id = 1", new int[] { 1 }); + stmt.execute("INSERT INTO customer (id, first_name, last_name) VALUES (2, 'X', 'Y')", new String[] { "id" }); + } + } + + // Note this test should generate no SQL in the AppMap - this was a bug in the + // agent + @Test + void testNativeSQL() throws Exception { + // Test nativeSQL method which converts SQL to the database's native grammar + String sql = "SELECT * FROM customer WHERE id = ?"; + String nativeSql = connection.nativeSQL(sql); + + assertTrue(nativeSql != null); + assertTrue(nativeSql.contains("customer")); + } + + @Test + void testBatch() throws Exception { + try (Statement stmt = connection.createStatement()) { + stmt.addBatch("INSERT INTO customer (id, first_name, last_name) VALUES (3, 'E', 'F')"); + stmt.addBatch("INSERT INTO customer (id, first_name, last_name) VALUES (4, 'G', 'H')"); + stmt.clearBatch(); + + stmt.addBatch("INSERT INTO customer (id, first_name, last_name) VALUES (5, 'I', 'J')"); + stmt.addBatch("INSERT INTO customer (id, first_name, last_name) VALUES (6, 'K', 'L')"); + stmt.executeBatch(); + + stmt.addBatch("INSERT INTO customer (id, first_name, last_name) VALUES (7, 'M', 'N')"); + stmt.addBatch("INSERT INTO customer (id, first_name, last_name) VALUES (8, 'O', 'P')"); + stmt.executeLargeBatch(); + + // This should generate empty SQL in the AppMap + stmt.executeLargeBatch(); + + // Let's try invalid sequel to cause an exception + try { + stmt.addBatch("SELECT * FROM customer WHERE batch error = ?"); + stmt.executeBatch(); + } catch (SQLException e) { + // expected + } + } + } + + @Test + void testPreparedStatementBatch() throws Exception { + String sql = "INSERT INTO customer (id, first_name, last_name) VALUES (?, ?, ?)"; + try (PreparedStatement pstmt = connection.prepareStatement(sql)) { + pstmt.setLong(1, 9); + pstmt.setString(2, "Q"); + pstmt.setString(3, "R"); + pstmt.addBatch(); + pstmt.clearBatch(); + + pstmt.setLong(1, 10); + pstmt.setString(2, "S"); + pstmt.setString(3, "T"); + pstmt.addBatch(); + pstmt.setLong(1, 11); + pstmt.setString(2, "U"); + pstmt.setString(3, "V"); + pstmt.addBatch(); + pstmt.executeBatch(); + + pstmt.setLong(1, 12); + pstmt.setString(2, "W"); + pstmt.setString(3, "X"); + pstmt.addBatch(); + pstmt.setLong(1, 13); + pstmt.setString(2, "Y"); + pstmt.setString(3, "Z"); + pstmt.addBatch(); + pstmt.executeLargeBatch(); + } + } + + @Test + void testCallableStatement() throws Exception { + // Each call uses slightly different SQL to ensure unique identification in + // AppMap + String sql1 = "{call test_proc(?, ?)} -- call 1"; + String sql2 = "{call test_proc(?, ?)} -- call 2"; + String sql3 = "{call test_proc(?, ?)} -- call 3"; + + // Test various prepareCall overloads and execute multiple times + try (CallableStatement cstmt = connection.prepareCall(sql1)) { + cstmt.setString(1, "key1.1"); + cstmt.setString(2, "val1.1"); + cstmt.execute(); + cstmt.setString(1, "key1.2"); + cstmt.setString(2, "val1.2"); + cstmt.execute(); + } + + try (CallableStatement cstmt = connection.prepareCall(sql2, ResultSet.TYPE_FORWARD_ONLY, + ResultSet.CONCUR_READ_ONLY)) { + cstmt.setString(1, "key2.1"); + cstmt.setString(2, "val2.1"); + cstmt.execute(); + cstmt.setString(1, "key2.2"); + cstmt.setString(2, "val2.2"); + cstmt.execute(); + } + + try (CallableStatement cstmt = connection.prepareCall(sql3, ResultSet.TYPE_FORWARD_ONLY, ResultSet.CONCUR_READ_ONLY, + ResultSet.HOLD_CURSORS_OVER_COMMIT)) { + cstmt.setString(1, "key3.1"); + cstmt.setString(2, "val3.1"); + cstmt.execute(); + cstmt.setString(1, "key3.2"); + cstmt.setString(2, "val3.2"); + cstmt.execute(); + } + } + + @Test + void testExecuteUpdate() throws Exception { + try (Statement stmt = connection.createStatement()) { + stmt.executeUpdate("INSERT INTO customer (id, first_name, last_name) VALUES (20, 'A', 'B')"); + stmt.executeUpdate("UPDATE customer SET first_name = 'C' WHERE id = 20", Statement.NO_GENERATED_KEYS); + stmt.executeUpdate("DELETE FROM customer WHERE id = 20", new int[] { 1 }); + stmt.executeUpdate("INSERT INTO customer (id, first_name, last_name) VALUES (21, 'D', 'E')", + new String[] { "id" }); + + // Test executeLargeUpdate overloads + stmt.executeLargeUpdate("UPDATE customer SET first_name = 'F' WHERE id = 21"); + stmt.executeLargeUpdate("UPDATE customer SET first_name = 'G' WHERE id = 21", Statement.NO_GENERATED_KEYS); + stmt.executeLargeUpdate("UPDATE customer SET first_name = 'H' WHERE id = 21", new int[] { 1 }); + stmt.executeLargeUpdate("UPDATE customer SET first_name = 'I' WHERE id = 21", new String[] { "id" }); + } + } + + @Test + void testExecuteQuery() throws Exception { + try (Statement stmt = connection.createStatement()) { + try (ResultSet rs = stmt.executeQuery("SELECT * FROM customer")) { + while (rs.next()) { + } + } + } + } + + @Test + void testPrepareStatement() throws Exception { + // Unique SQL for each overload + String sql1 = "SELECT * FROM customer WHERE id = ? -- op 1"; + String sql2 = "SELECT first_name FROM customer WHERE id = ? -- op 2"; + String sql3 = "UPDATE customer SET last_name = ? WHERE id = ? -- op 3"; + String sql4 = "UPDATE customer SET first_name = ? WHERE id = ? -- op 4"; + String sql5 = "UPDATE customer SET last_name = ? WHERE id = ? -- op 5"; + String sql6 = "SELECT count(*) FROM customer WHERE id = ? -- op 6"; + + try (PreparedStatement pstmt = connection.prepareStatement(sql1)) { + pstmt.setLong(1, 1); + pstmt.execute(); + pstmt.setLong(1, 2); + pstmt.execute(); + } + try (PreparedStatement pstmt = connection.prepareStatement(sql2, Statement.RETURN_GENERATED_KEYS)) { + pstmt.setLong(1, 1); + try (ResultSet rs = pstmt.executeQuery()) { + } + pstmt.setLong(1, 2); + try (ResultSet rs = pstmt.executeQuery()) { + } + } + try (PreparedStatement pstmt = connection.prepareStatement(sql3, new int[] { 1 })) { + pstmt.setString(1, "LastName3.1"); + pstmt.setLong(2, 1); + pstmt.executeUpdate(); + pstmt.setString(1, "LastName3.2"); + pstmt.setLong(2, 1); + pstmt.executeUpdate(); + } + try (PreparedStatement pstmt = connection.prepareStatement(sql4, ResultSet.TYPE_FORWARD_ONLY, + ResultSet.CONCUR_READ_ONLY)) { + pstmt.setString(1, "Name1"); + pstmt.setLong(2, 21); + pstmt.executeLargeUpdate(); + pstmt.setString(1, "Name2"); + pstmt.setLong(2, 21); + pstmt.executeLargeUpdate(); + } + try (PreparedStatement pstmt = connection.prepareStatement(sql5, ResultSet.TYPE_FORWARD_ONLY, + ResultSet.CONCUR_READ_ONLY, ResultSet.HOLD_CURSORS_OVER_COMMIT)) { + pstmt.setString(1, "LName1"); + pstmt.setLong(2, 21); + pstmt.executeUpdate(); + pstmt.setString(1, "LName2"); + pstmt.setLong(2, 21); + pstmt.executeUpdate(); + } + try (PreparedStatement pstmt = connection.prepareStatement(sql6, new String[] { "id" })) { + pstmt.setLong(1, 1); + pstmt.execute(); + pstmt.setLong(1, 2); + pstmt.execute(); + } + } + + @Test + void testExceptions() throws Exception { + try (Statement stmt = connection.createStatement()) { + try { + stmt.execute("SELECT * FROM non_existent_table"); + } catch (SQLException e) { + // Expected + } + } + + try { + // note this will fail to prepare on h2 but only fail on execution on oracle + connection.prepareStatement("INVALID SQL").execute(); + } catch (SQLException e) { + // Expected + } + } +} diff --git a/agent/test/jdbc/src/test/resources/application-oracle.properties b/agent/test/jdbc/src/test/resources/application-oracle.properties new file mode 100644 index 00000000..d38c8ab0 --- /dev/null +++ b/agent/test/jdbc/src/test/resources/application-oracle.properties @@ -0,0 +1,6 @@ +spring.datasource.url=${ORACLE_URL:jdbc:oracle:thin:@localhost:1521} +spring.datasource.username=${ORACLE_USERNAME:system} +spring.datasource.password=${ORACLE_PASSWORD:oracle} +spring.datasource.driver-class-name=oracle.jdbc.OracleDriver +spring.jpa.database-platform=org.hibernate.dialect.Oracle12cDialect +spring.jpa.hibernate.ddl-auto=create-drop