From ea06fd66806102fb9790b336afc9770091de3767 Mon Sep 17 00:00:00 2001
From: Valery Kharseko <vharseko@3a-systems.ru>
Date: Sun, 27 Sep 2026 08:56:36 +0000
Subject: [PATCH] [#1075] Test the JDBC guards against a neighbouring MySQL database and Oracle schema (#1108)

---
 opendj-server-legacy/src/test/java/org/opends/server/backends/jdbc/OracleTestCase.java       |  129 +++++++++++++++++++++
 opendj-server-legacy/src/test/java/org/opends/server/backends/jdbc/TestCase.java             |   18 +++
 opendj-server-legacy/src/test/java/org/opends/server/backends/jdbc/JDBCStorageRetryTest.java |   85 ++++++++++++++
 opendj-server-legacy/src/test/java/org/opends/server/backends/jdbc/MySqlTestCase.java        |  123 ++++++++++++++++++++
 4 files changed, 352 insertions(+), 3 deletions(-)

diff --git a/opendj-server-legacy/src/test/java/org/opends/server/backends/jdbc/JDBCStorageRetryTest.java b/opendj-server-legacy/src/test/java/org/opends/server/backends/jdbc/JDBCStorageRetryTest.java
index 45b0ebb..14dc2cc 100644
--- a/opendj-server-legacy/src/test/java/org/opends/server/backends/jdbc/JDBCStorageRetryTest.java
+++ b/opendj-server-legacy/src/test/java/org/opends/server/backends/jdbc/JDBCStorageRetryTest.java
@@ -1136,6 +1136,91 @@
   }
 
   /**
+   * On mysql the guards of {@code openTree()} ask in the database of the connection. A table name carries no
+   * database, so two directories on one server - the stock backend id in two databases - hold the same table
+   * and the same index, and Connector/J reads a null catalog as "any database": asked that way, the index of
+   * the neighbour answers for this one and the create behind it is skipped for good, leaving every
+   * {@code where k>? order by k} batch of every cursor a full scan (#1075). The catalog passed here is one of
+   * two layers - {@code TableScope.covers()} reads the database of every row besides, as the case below pins -
+   * and a live server cannot tell a loss of either one from their both holding, since each covers for the
+   * other: what is pinned here is the question put to the driver.
+   */
+  @Test
+  public void testTheMySqlGuardsAskInTheDatabaseOfTheConnection() throws Exception
+  {
+    final JDBCStorage storage = storageOverAnEngine(mysqlConnection.class, true);
+    when(engineConnection.getCatalog()).thenReturn(THIS_DATABASE);
+    final DatabaseMetaData metaData = engineConnection.getMetaData();
+
+    storage.write(txn -> txn.openTree(TREE, true));
+
+    verify(metaData, atLeastOnce()).getTables(eq(THIS_DATABASE), any(), eq(storage.getTableName(TREE)), any());
+    verify(metaData).getIndexInfo(eq(THIS_DATABASE), any(), eq(storage.getTableName(TREE)), anyBoolean(),
+        anyBoolean());
+  }
+
+  /**
+   * The other layer of the two: an index a driver reports in another database is not this backend's, however
+   * it came to be listed - a driver ignoring the catalog it is given, or a caller no longer passing one. Found
+   * there, it must leave the create of this database's own index to go ahead (#1075).
+   */
+  @Test
+  public void testAnIndexOfAnotherDatabaseAnswersForNoneOfThisOne() throws Exception
+  {
+    final JDBCStorage storage = storageOverAnEngine(mysqlConnection.class, true);
+    when(engineConnection.getCatalog()).thenReturn(THIS_DATABASE);
+    answerTheIndexFrom(engineConnection.getMetaData(), storage, NEIGHBOUR_DATABASE, null);
+
+    storage.write(txn -> txn.openTree(TREE, true));
+
+    verify(engineConnection).prepareStatement(startsWith("create index k_"));
+  }
+
+  /**
+   * And the same under {@code databaseTerm=SCHEMA}, the setting of Connector/J that names the database a
+   * schema: the connection then names no catalog, the lookup is asked of the whole server, and every row comes
+   * back under the catalog {@code def} with the database in its schema - measured against mysql 9.2 with the
+   * Connector/J this backend ships (#1075). The schema path {@code TableScope} reads off the connection is the
+   * only thing left to tell the neighbour's index from this one's; {@code MySqlTestCase} pins how the driver
+   * lists such a row against a live server.
+   */
+  @Test
+  public void testUnderDatabaseTermSchemaAnIndexOfAnotherDatabaseAnswersForNoneOfThisOne() throws Exception
+  {
+    final JDBCStorage storage = storageOverAnEngine(mysqlConnection.class, true);
+    when(engineConnection.getSchema()).thenReturn(THIS_DATABASE);
+    answerTheIndexFrom(engineConnection.getMetaData(), storage, "def", NEIGHBOUR_DATABASE);
+
+    storage.write(txn -> txn.openTree(TREE, true));
+
+    verify(engineConnection).prepareStatement(startsWith("create index k_"));
+  }
+
+  /** The database of the connection of the three cases above, and that of the directory next to it. */
+  private static final String THIS_DATABASE = "this_directory";
+  private static final String NEIGHBOUR_DATABASE = "neighbour_directory";
+
+  /**
+   * Has the index lookup of the fixture report the {@code k_} index of the table of {@link #TREE} as the one of
+   * the given catalog and schema, the way a driver lists the index of another database of the server.
+   */
+  private static void answerTheIndexFrom(DatabaseMetaData metaData, JDBCStorage storage, String catalog,
+      String schema) throws SQLException
+  {
+    final String tableName = storage.getTableName(TREE);
+    // stubbed over the answer of the fixture with doAnswer(): when() would call that answer, which stubs a
+    // result set of its own in the middle of this stubbing
+    doAnswer(invocation -> {
+      final ResultSet indexes = mock(ResultSet.class);
+      when(indexes.next()).thenReturn(true, false);
+      when(indexes.getString("INDEX_NAME")).thenReturn("k_" + tableName.substring("opendj_".length()));
+      when(indexes.getString("TABLE_CAT")).thenReturn(catalog);
+      when(indexes.getString("TABLE_SCHEM")).thenReturn(schema);
+      return indexes;
+    }).when(metaData).getIndexInfo(any(), any(), any(), anyBoolean(), anyBoolean());
+  }
+
+  /**
    * postgresql runs DDL inside the transaction, so a create index the engine rolled back has committed nothing:
    * {@code write()} rolls the attempt back whole and replays it. Raising the flag in front of the statement -
    * which is what mysql and oracle need, since they commit before a DDL of their own accord - would turn a
diff --git a/opendj-server-legacy/src/test/java/org/opends/server/backends/jdbc/MySqlTestCase.java b/opendj-server-legacy/src/test/java/org/opends/server/backends/jdbc/MySqlTestCase.java
index a3d325c..edf6fc3 100644
--- a/opendj-server-legacy/src/test/java/org/opends/server/backends/jdbc/MySqlTestCase.java
+++ b/opendj-server-legacy/src/test/java/org/opends/server/backends/jdbc/MySqlTestCase.java
@@ -15,12 +15,20 @@
  */
 package org.opends.server.backends.jdbc;
 
+import org.forgerock.opendj.ldap.ByteString;
+import org.opends.server.backends.pluggable.spi.AccessMode;
+import org.opends.server.backends.pluggable.spi.TreeName;
+import org.opends.server.backends.pluggable.spi.WriteOperation;
+import org.opends.server.backends.pluggable.spi.WriteableTransaction;
 import org.testcontainers.containers.JdbcDatabaseContainer;
 import org.testcontainers.containers.MySQLContainer;
+import org.testng.annotations.DataProvider;
 import org.testng.annotations.Test;
 
 import java.sql.Connection;
 import java.sql.DriverManager;
+import java.sql.PreparedStatement;
+import java.sql.ResultSet;
 import java.sql.SQLException;
 import java.sql.Statement;
 
@@ -174,6 +182,121 @@
         throw new AssertionError("an account granted one query an hour must be refused within four connects");
     }
 
+    /** The database of the case below: a directory next to the one of this suite, on the same server. */
+    private static final String NEIGHBOUR = "opendj_neighbour1075";
+
+    @Override
+    protected void dropStaleNeighbours() throws SQLException {
+        grant(getJdbcUrl(), "drop database if exists " + NEIGHBOUR);
+    }
+
+    @DataProvider
+    public Object[][] databaseTerms() {
+        return new Object[][] {
+            // the default of Connector/J: the database is the catalog, and the lookups are asked in it
+            { "catalog", "" },
+            // the database is the schema: the connection names no catalog, the lookups span the server,
+            // and only the schema path read off the connection tells the neighbour apart
+            { "schema", "?databaseTerm=SCHEMA" },
+        };
+    }
+
+    /**
+     * A table and an index of another database of this server answer for none of this backend's, however
+     * the connection names its database (#1075) - the mysql twin of the postgres case of #902.
+     * <p>
+     * A table is named after its tree and an index after its table, so two directories on one server - the
+     * stock backend id in two databases - hold the same table and the same index. Connector/J asked with no
+     * catalog lists the tables of every database, and both guards of {@code openTree()} would then skip a
+     * create this backend needs: the table one leaves every statement addressing a table that is not there,
+     * and the index one - the quiet half - leaves the {@code where k>? order by k} batches of every cursor
+     * a full scan for the life of the deployment.
+     * <p>
+     * Under the default {@code databaseTerm} the guards hold by two layers at once - the catalog they pass
+     * and the database {@code TableScope.covers()} reads off every row - and this case goes red only when
+     * both give way; the unit cases of {@code JDBCStorageRetryTest} pin each of them. Under
+     * {@code databaseTerm=SCHEMA} the connection names no catalog and the schema path is the one layer
+     * there is, which is what this case exists for: what it rests on is how the driver lists a table of
+     * another database, and only a live server answers that.
+     */
+    @Test(dataProvider = "databaseTerms")
+    public void testAnOpenIsAnsweredForByNoTableOfAnotherDatabase(String term, String urlOptions) throws Exception {
+        final TreeName tree = new TreeName("testAnotherDatabase", "tree");
+        final JDBCStorage storage =
+            new JDBCStorage(createBackendCfg(getBackendId() + "_" + term, getJdbcUrl() + urlOptions), null);
+        final String tableName = storage.getTableName(tree);
+        final String indexName = "k_" + tableName.substring("opendj_".length());
+        try {
+            // the neighbouring directory: the same table and the same index, in a database this storage
+            // reaches through no unqualified name of its own. Spelled out rather than opened by a storage,
+            // so that the fixture is the collision and nothing else
+            grant(getJdbcUrl(), "drop database if exists " + NEIGHBOUR,
+                "create database " + NEIGHBOUR,
+                "create table " + NEIGHBOUR + "." + tableName
+                    + " (h char(128),k varbinary(255),v longblob,primary key(h,k))",
+                "create index " + indexName + " on " + NEIGHBOUR + "." + tableName + " (k)");
+            assertFalse(isExistsIn(DATABASE, tableName, null),
+                "the case did not start with the table of this backend absent from its database");
+
+            storage.open(AccessMode.READ_WRITE);
+            storage.write(new WriteOperation() {
+                @Override
+                public void run(WriteableTransaction txn) throws Exception {
+                    txn.openTree(tree, true);
+                    // the destructive half of the table guard, and the reason it is loud: found abroad, the
+                    // table is created nowhere and this statement addresses a table that is not there
+                    txn.put(tree, ByteString.valueOfUtf8("a key of this backend"),
+                        ByteString.valueOfUtf8("a value of this backend"));
+                }
+            });
+
+            assertTrue(isExistsIn(DATABASE, tableName, null),
+                "the open took the table of another database for its own and created none");
+            assertTrue(isExistsIn(DATABASE, tableName, indexName),
+                "the open took the index of another database for its own: the cursor batches of this tree are full scans behind it");
+            assertEquals(rowCountIn(DATABASE, tableName), 1,
+                "the write of this backend landed in a table other than the one the open made");
+            assertEquals(rowCountIn(NEIGHBOUR, tableName), 0,
+                "the write of this backend landed in the table of the neighbouring database");
+        } finally {
+            clearQuietly(storage);
+            grant(getJdbcUrl(), "drop database if exists " + NEIGHBOUR);
+        }
+    }
+
+    /** The database of the connections of this suite. */
+    private static final String DATABASE = "database_name";
+
+    /**
+     * Whether that one database holds the table - or, given an index name, that index of it - asked of
+     * information_schema by name rather than through the catalog lookups under test.
+     */
+    private boolean isExistsIn(String database, String tableName, String indexName) throws SQLException {
+        final String query = indexName == null
+            ? "select 1 from information_schema.tables where table_schema=? and table_name=?"
+            : "select 1 from information_schema.statistics where table_schema=? and table_name=? and index_name=?";
+        try (final Connection con = DriverManager.getConnection(getJdbcUrl());
+             final PreparedStatement st = con.prepareStatement(query)) {
+            st.setString(1, database);
+            st.setString(2, tableName);
+            if (indexName != null) {
+                st.setString(3, indexName);
+            }
+            try (final ResultSet rs = st.executeQuery()) {
+                return rs.next();
+            }
+        }
+    }
+
+    /** What the table of that one database holds, which says which of the two tables a write went to. */
+    private int rowCountIn(String database, String tableName) throws SQLException {
+        try (final Connection con = DriverManager.getConnection(getJdbcUrl());
+             final Statement st = con.createStatement();
+             final ResultSet rs = st.executeQuery("select count(*) from " + database + "." + tableName)) {
+            return rs.next() ? rs.getInt(1) : -1;
+        }
+    }
+
     /** The account of the test is made and unmade on the connection of the suite's own credentials. */
     private static void grant(String url, String... statements) throws SQLException {
         try (final Connection admin = DriverManager.getConnection(url);
diff --git a/opendj-server-legacy/src/test/java/org/opends/server/backends/jdbc/OracleTestCase.java b/opendj-server-legacy/src/test/java/org/opends/server/backends/jdbc/OracleTestCase.java
index 0b9547d..69b923a 100644
--- a/opendj-server-legacy/src/test/java/org/opends/server/backends/jdbc/OracleTestCase.java
+++ b/opendj-server-legacy/src/test/java/org/opends/server/backends/jdbc/OracleTestCase.java
@@ -11,17 +11,32 @@
  * Header, with the fields enclosed by brackets [] replaced by your own identifying
  * information: "Portions Copyright [year] [name of copyright owner]".
  *
- * Copyright 2025 3A Systems, LLC.
+ * Copyright 2025-2026 3A Systems, LLC.
  */
 package org.opends.server.backends.jdbc;
 
+import org.forgerock.opendj.ldap.ByteString;
+import org.opends.server.backends.pluggable.spi.AccessMode;
+import org.opends.server.backends.pluggable.spi.TreeName;
+import org.opends.server.backends.pluggable.spi.WriteOperation;
+import org.opends.server.backends.pluggable.spi.WriteableTransaction;
 import org.testcontainers.containers.JdbcDatabaseContainer;
 import org.testcontainers.oracle.OracleContainer;
 import org.testng.annotations.Test;
 
+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 java.time.Duration;
 
-//docker run --rm --name oracle-db -p 1521:1521 -e APP_USER=opendj -e ORACLE_DATABASE=database_name -e APP_USER_PASSWORD=password gvenzl/oracle-free:23.26.2-slim-faststart
+import static org.testng.Assert.assertEquals;
+import static org.testng.Assert.assertFalse;
+import static org.testng.Assert.assertTrue;
+
+//docker run --rm --name oracle-db -p 1521:1521 -e APP_USER=opendj -e ORACLE_DATABASE=database_name -e APP_USER_PASSWORD=password -e ORACLE_PASSWORD=password gvenzl/oracle-free:23.26.2-slim-faststart
 
 @Test(sequential = true)
 public class OracleTestCase extends TestCase {
@@ -39,7 +54,7 @@
 
     @Override
     protected String getContainerDockerCommand() {
-        return "run before test: docker run --rm --name oracle-db -p 1521:1521 -e APP_USER=opendj -e ORACLE_DATABASE=database_name -e APP_USER_PASSWORD=password gvenzl/oracle-free:23.26.2-slim-faststart";
+        return "run before test: docker run --rm --name oracle-db -p 1521:1521 -e APP_USER=opendj -e ORACLE_DATABASE=database_name -e APP_USER_PASSWORD=password -e ORACLE_PASSWORD=password gvenzl/oracle-free:23.26.2-slim-faststart";
     }
 
     @Override
@@ -52,6 +67,114 @@
         return "jdbc:oracle:thin:opendj/password@localhost: " + ((container==null)?"1521":container.getMappedPort(1521))  + "/database_name";
     }
 
+    /** The schema of the case below: another user of this database, holding a directory of its own. */
+    private static final String NEIGHBOUR = "opendj_neighbour1075";
+
+    /**
+     * The administrator of the database, who makes the neighbour and grants this suite a look at it: the
+     * container is given one password for both accounts, and the user of the suite may create no other.
+     */
+    private String getSystemJdbcUrl() {
+        return getJdbcUrl().replace("opendj/password", "system/password");
+    }
+
+    @Override
+    protected void dropStaleNeighbours() throws SQLException {
+        administer("drop user if exists " + NEIGHBOUR + " cascade");
+    }
+
+    /**
+     * A table and an index of another schema answer for none of this backend's (#1075) - the oracle twin
+     * of the postgres case of #902.
+     * <p>
+     * A table is named after its tree and an index after its table, so two directories in two schemas of
+     * one database hold the same table and the same index. Oracle names no catalog, so a lookup of
+     * {@code openTree()} is asked of every schema the user may see, and the schema {@code TableScope}
+     * reads off the connection is the one thing telling the neighbour's index from this one's: found
+     * abroad, the table guard leaves every statement of the backend addressing a table that is not there,
+     * and the index one leaves the {@code where k>? order by k} batches of every cursor a full scan.
+     * <p>
+     * The grant is the whole of the fixture: the dictionary views the driver reads list another user's
+     * table only to a user granted something on it, and without it the lookup finds nothing either way.
+     */
+    @Test
+    public void testAnOpenIsAnsweredForByNoTableOfAnotherSchema() throws Exception {
+        final TreeName tree = new TreeName("testAnotherSchema", "tree");
+        final JDBCStorage storage = new JDBCStorage(createBackendCfg(getBackendId() + "_anotherSchema"), null);
+        final String tableName = storage.getTableName(tree);
+        final String indexName = "k_" + tableName.substring("opendj_".length());
+        try {
+            // the neighbouring directory: the same table and the same index, in a schema this storage
+            // reaches through no unqualified name of its own. Spelled out rather than opened by a storage,
+            // so that the fixture is the collision and nothing else
+            administer("drop user if exists " + NEIGHBOUR + " cascade",
+                "create user " + NEIGHBOUR + " identified by password quota unlimited on users",
+                "create table " + NEIGHBOUR + "." + tableName + " (h char(128),k raw(2000),v blob,primary key(h,k))",
+                "create index " + NEIGHBOUR + "." + indexName + " on " + NEIGHBOUR + "." + tableName + " (k)",
+                "grant select on " + NEIGHBOUR + "." + tableName + " to opendj");
+            assertFalse(isExistsOwn("select 1 from user_tables where table_name=upper(?)", tableName),
+                "the case did not start with the table of this backend absent from its schema");
+
+            storage.open(AccessMode.READ_WRITE);
+            storage.write(new WriteOperation() {
+                @Override
+                public void run(WriteableTransaction txn) throws Exception {
+                    txn.openTree(tree, true);
+                    // the destructive half of the table guard, and the reason it is loud: found abroad, the
+                    // table is created nowhere and this statement addresses a table that is not there
+                    txn.put(tree, ByteString.valueOfUtf8("a key of this backend"),
+                        ByteString.valueOfUtf8("a value of this backend"));
+                }
+            });
+
+            assertTrue(isExistsOwn("select 1 from user_tables where table_name=upper(?)", tableName),
+                "the open took the table of another schema for its own and created none");
+            assertTrue(isExistsOwn("select 1 from user_indexes where index_name=upper(?)", indexName),
+                "the open took the index of another schema for its own: the cursor batches of this tree are full scans behind it");
+            assertEquals(rowCount(tableName), 1,
+                "the write of this backend landed in a table other than the one the open made");
+            assertEquals(rowCount(NEIGHBOUR + "." + tableName), 0,
+                "the write of this backend landed in the table of the neighbouring schema");
+        } finally {
+            clearQuietly(storage);
+            administer("drop user if exists " + NEIGHBOUR + " cascade");
+        }
+    }
+
+    /** Runs the given statements as the administrator of the database. */
+    private void administer(String... statements) throws SQLException {
+        try (final Connection con = DriverManager.getConnection(getSystemJdbcUrl());
+             final Statement st = con.createStatement()) {
+            for (final String statement : statements) {
+                st.execute(statement);
+            }
+        }
+    }
+
+    /**
+     * Whether the dictionary of the user of this suite lists the named object, asked by name rather than
+     * through the catalog lookups under test.
+     */
+    private boolean isExistsOwn(String query, String name) throws SQLException {
+        try (final Connection con = DriverManager.getConnection(getJdbcUrl());
+             final PreparedStatement st = con.prepareStatement(query)) {
+            st.setString(1, name);
+            try (final ResultSet rs = st.executeQuery()) {
+                return rs.next();
+            }
+        }
+    }
+
+    /** What the named table holds, read as the administrator, who reaches both schemas. */
+    private int rowCount(String qualified) throws SQLException {
+        final String table = qualified.contains(".") ? qualified : "opendj." + qualified;
+        try (final Connection con = DriverManager.getConnection(getSystemJdbcUrl());
+             final Statement st = con.createStatement();
+             final ResultSet rs = st.executeQuery("select count(*) from " + table)) {
+            return rs.next() ? rs.getInt(1) : -1;
+        }
+    }
+
     @Override
     @Test(skipFailedInvocations = true) //ORA UPSERT error
     public void test_issue_496_2() {
diff --git a/opendj-server-legacy/src/test/java/org/opends/server/backends/jdbc/TestCase.java b/opendj-server-legacy/src/test/java/org/opends/server/backends/jdbc/TestCase.java
index f3b3500..d22681d 100644
--- a/opendj-server-legacy/src/test/java/org/opends/server/backends/jdbc/TestCase.java
+++ b/opendj-server-legacy/src/test/java/org/opends/server/backends/jdbc/TestCase.java
@@ -97,6 +97,12 @@
 				throw new SkipException(getContainerDockerCommand());
 			}
 		}
+		try {
+			dropStaleNeighbours();
+		} catch (SQLException e) {
+			// nothing left behind to drop, or an account the suite cannot log in with: either way the
+			// listing below decides, and it fails only where such a leftover is actually there
+		}
 		try(Connection con = DriverManager.getConnection(createBackendCfg().getDBDirectory())){
 			dropStaleTrees(con);
 		} catch (Exception e) {
@@ -135,6 +141,18 @@
 		}
 	}
 
+	/**
+	 * Drops what a case of a suite made outside the database or the schema of its own connections - a
+	 * neighbouring directory of the same table names - where a run killed in the middle of that case left
+	 * it behind. It goes before {@link #dropStaleTrees}, whose listing reaches such a table and whose drop
+	 * then fails - unqualified on mysql, where the table is in another database, and without the privilege
+	 * on oracle, where it is another user's - skipping the whole class on every run after it. A container
+	 * the suite starts is a fresh one; the database of {@link #getContainerDockerCommand()}, which a run
+	 * without docker falls back on, outlives every run made against it.
+	 */
+	protected void dropStaleNeighbours() throws SQLException {
+	}
+
 	@Override
 	protected Backend createBackend() {
 		return new Backend();

--
Gitblit v1.10.0