From 690e262d1ff05911fdad8b29f00065cffbce633a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rafa=C5=82=20Rzepecki?= Date: Fri, 16 Jan 2026 16:16:09 +0100 Subject: [PATCH 1/2] feat: Enhanced JDBC hooks with PreparedStatement and batch support This commit refactors the SqlQuery hooks to provide comprehensive support for JDBC operations, particularly PreparedStatements and batch operations. Key changes: - Refactored prepareStatement/prepareCall hooks to capture at METHOD_RETURN and associate SQL strings with the returned statement objects using WeakHashMaps for proper prepared statement support - Added comprehensive batch operation tracking (addBatch, clearBatch, executeBatch, executeLargeBatch) - Added support for executeLargeUpdate (JDBC 4.2) - Consolidated overloaded methods using @ArgumentArray to reduce code duplication and handle all method signatures uniformly - Added proper exception handling hooks for all execute methods - Implemented caching of database names using WeakHashMap to avoid repeated metadata lookups - Removed nativeSQL hooks (doesn't actually execute SQL, just transforms it) - Fixed potential NoClassDefFoundError by changing exception handling from SQLException to Throwable and removing direct class references The SQLException fix addresses an issue in environments with custom classloaders (e.g., Oracle UCP) where java.sql.SQLException might not be visible to the hook class's classloader. Co-Authored-By: Claude Sonnet 4.5 --- .../appmap/process/hooks/SqlQuery.java | 377 ++++++++---------- .../SqlQuerySQLExceptionAvailabilityTest.java | 121 ++++++ 2 files changed, 279 insertions(+), 219 deletions(-) create mode 100644 agent/src/test/java/com/appland/appmap/process/hooks/SqlQuerySQLExceptionAvailabilityTest.java 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(); + } + } + } +} From 1daadd50f355a941b0edb89985b77bcd7dda6054 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rafa=C5=82=20Rzepecki?= Date: Fri, 16 Jan 2026 16:16:25 +0100 Subject: [PATCH 2/2] test: Add comprehensive JDBC tests with Oracle and H2 support This commit adds extensive test infrastructure for validating JDBC hook behavior across multiple database systems. Key additions: - PureJDBCTests: Comprehensive unit tests covering all JDBC operations including Statement, PreparedStatement, CallableStatement, batch operations, and exception handling - OracleRepositoryTests: Spring Data JPA integration tests for Oracle - Test infrastructure supporting both H2 (in-memory) and Oracle databases - Docker Compose configuration for running Oracle database in CI - BATS test harness improvements and helper scripts for snapshot testing - Snapshot-based test validation with expected SQL output for both databases - CI integration: GitHub Actions workflow now includes Oracle database service - Build configuration updated to include Oracle JDBC driver Test utilities: - helper.bash: Common test functions and database connection helpers - regenerate_jdbc_snapshots.sh: Script to regenerate expected SQL snapshots - Snapshot files for both H2 and Oracle to validate SQL generation Also adds *.log pattern to .gitignore to prevent accidental commit of debug logs. Co-Authored-By: Claude Sonnet 4.5 --- .github/workflows/build-and-test.yml | 19 ++ .gitignore | 3 + agent/test/jdbc/build.gradle | 23 +- agent/test/jdbc/docker-compose.yml | 10 + agent/test/jdbc/helper.bash | 66 ++++ agent/test/jdbc/jdbc.bats | 76 ++++- agent/test/jdbc/regenerate_jdbc_snapshots.sh | 57 ++++ ...cessingdatajpa_PureJDBCTests_testBatch.sql | 6 + ...pa_PureJDBCTests_testCallableStatement.sql | 6 + ...ngdatajpa_PureJDBCTests_testExceptions.sql | 1 + ...datajpa_PureJDBCTests_testExecuteQuery.sql | 1 + ...atajpa_PureJDBCTests_testExecuteUpdate.sql | 8 + ...ingdatajpa_PureJDBCTests_testNativeSQL.sql | 0 ...jpa_PureJDBCTests_testPrepareStatement.sql | 12 + ...reJDBCTests_testPreparedStatementBatch.sql | 4 + ...jpa_PureJDBCTests_testStatementExecute.sql | 4 + ...cessingdatajpa_PureJDBCTests_testBatch.sql | 6 + ...pa_PureJDBCTests_testCallableStatement.sql | 6 + ...ngdatajpa_PureJDBCTests_testExceptions.sql | 2 + ...datajpa_PureJDBCTests_testExecuteQuery.sql | 1 + ...atajpa_PureJDBCTests_testExecuteUpdate.sql | 8 + ...ingdatajpa_PureJDBCTests_testNativeSQL.sql | 0 ...jpa_PureJDBCTests_testPrepareStatement.sql | 12 + ...reJDBCTests_testPreparedStatementBatch.sql | 4 + ...jpa_PureJDBCTests_testStatementExecute.sql | 4 + .../OracleRepositoryTests.java | 41 +++ .../accessingdatajpa/PureJDBCTests.java | 303 ++++++++++++++++++ .../resources/application-oracle.properties | 6 + 28 files changed, 674 insertions(+), 15 deletions(-) create mode 100644 agent/test/jdbc/docker-compose.yml create mode 100644 agent/test/jdbc/helper.bash mode change 100644 => 100755 agent/test/jdbc/jdbc.bats create mode 100755 agent/test/jdbc/regenerate_jdbc_snapshots.sh create mode 100644 agent/test/jdbc/snapshots/h2/com_example_accessingdatajpa_PureJDBCTests_testBatch.sql create mode 100644 agent/test/jdbc/snapshots/h2/com_example_accessingdatajpa_PureJDBCTests_testCallableStatement.sql create mode 100644 agent/test/jdbc/snapshots/h2/com_example_accessingdatajpa_PureJDBCTests_testExceptions.sql create mode 100644 agent/test/jdbc/snapshots/h2/com_example_accessingdatajpa_PureJDBCTests_testExecuteQuery.sql create mode 100644 agent/test/jdbc/snapshots/h2/com_example_accessingdatajpa_PureJDBCTests_testExecuteUpdate.sql create mode 100644 agent/test/jdbc/snapshots/h2/com_example_accessingdatajpa_PureJDBCTests_testNativeSQL.sql create mode 100644 agent/test/jdbc/snapshots/h2/com_example_accessingdatajpa_PureJDBCTests_testPrepareStatement.sql create mode 100644 agent/test/jdbc/snapshots/h2/com_example_accessingdatajpa_PureJDBCTests_testPreparedStatementBatch.sql create mode 100644 agent/test/jdbc/snapshots/h2/com_example_accessingdatajpa_PureJDBCTests_testStatementExecute.sql create mode 100644 agent/test/jdbc/snapshots/oracle/com_example_accessingdatajpa_PureJDBCTests_testBatch.sql create mode 100644 agent/test/jdbc/snapshots/oracle/com_example_accessingdatajpa_PureJDBCTests_testCallableStatement.sql create mode 100644 agent/test/jdbc/snapshots/oracle/com_example_accessingdatajpa_PureJDBCTests_testExceptions.sql create mode 100644 agent/test/jdbc/snapshots/oracle/com_example_accessingdatajpa_PureJDBCTests_testExecuteQuery.sql create mode 100644 agent/test/jdbc/snapshots/oracle/com_example_accessingdatajpa_PureJDBCTests_testExecuteUpdate.sql create mode 100644 agent/test/jdbc/snapshots/oracle/com_example_accessingdatajpa_PureJDBCTests_testNativeSQL.sql create mode 100644 agent/test/jdbc/snapshots/oracle/com_example_accessingdatajpa_PureJDBCTests_testPrepareStatement.sql create mode 100644 agent/test/jdbc/snapshots/oracle/com_example_accessingdatajpa_PureJDBCTests_testPreparedStatementBatch.sql create mode 100644 agent/test/jdbc/snapshots/oracle/com_example_accessingdatajpa_PureJDBCTests_testStatementExecute.sql create mode 100644 agent/test/jdbc/src/test/java/com/example/accessingdatajpa/OracleRepositoryTests.java create mode 100644 agent/test/jdbc/src/test/java/com/example/accessingdatajpa/PureJDBCTests.java create mode 100644 agent/test/jdbc/src/test/resources/application-oracle.properties 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/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