From 2b8612f7fa2f5e00dff4ff2d8ac912c833e1f7ab Mon Sep 17 00:00:00 2001
From: Valery Kharseko <vharseko@3a-systems.ru>
Date: Wed, 09 Sep 2026 07:01:15 +0000
Subject: [PATCH] [#888] Name the trees of a JDBC backend from a catalog in the database (#893)

---
 opendj-server-legacy/src/test/java/org/opends/server/backends/jdbc/PgSqlTestCase.java |  144 +++++++++++++++++++++++++++++++++++++++++++++++
 1 files changed, 142 insertions(+), 2 deletions(-)

diff --git a/opendj-server-legacy/src/test/java/org/opends/server/backends/jdbc/PgSqlTestCase.java b/opendj-server-legacy/src/test/java/org/opends/server/backends/jdbc/PgSqlTestCase.java
index 612093e..6ff6719 100644
--- a/opendj-server-legacy/src/test/java/org/opends/server/backends/jdbc/PgSqlTestCase.java
+++ b/opendj-server-legacy/src/test/java/org/opends/server/backends/jdbc/PgSqlTestCase.java
@@ -11,17 +11,41 @@
  * 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.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.PostgreSQLContainer;
 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 static org.testng.Assert.assertEquals;
+import static org.testng.Assert.assertFalse;
+import static org.testng.Assert.assertNotEquals;
+import static org.testng.Assert.assertTrue;
+
 //docker run --rm -it -p 5432:5432 -e POSTGRES_PASSWORD=password --name postgres postgres
 
-@Test
+/**
+ * The class-level annotation governs the cases declared here, and it has to carry
+ * {@code sequential = true} of its own: {@code TestListener.enforceTestClassTypeAndAnnotations()}
+ * looks it up on the class declaring the case rather than on the one running it, so the
+ * {@code @Test(groups = ..., sequential = true)} of {@code PluggableBackendImplTestCase} answers for
+ * the inherited cases alone and a bare {@code @Test} here fails every case this class declares.
+ * {@code OracleTestCase} carries it for the same reason.
+ */
+@Test(sequential = true)
 public class PgSqlTestCase extends TestCase {
 
     @Override
@@ -48,4 +72,120 @@
         return "jdbc:postgresql://localhost:"+ ((container==null)?"5432":container.getMappedPort(5432))+"/database_name?user=postgres&password=password";
     }
 
+    /** The schema put ahead of the one this suite's tables are in, for the case below and for nothing else. */
+    private static final String AHEAD_ON_THE_PATH = "opendj_ahead";
+
+    /**
+     * A table of this backend is found where an unqualified statement of the same connection reaches it, and
+     * not only in the schema that connection happens to work in.
+     * <p>
+     * PostgreSQL resolves an unqualified reference across the whole {@code search_path} while an unqualified
+     * {@code create} lands in {@code current_schema()} alone, so the two are the same schema only as long as
+     * nothing was put in front of the one the tables were made in. Adding a schema of its own to a role is the
+     * standard remedy since PG15 took {@code CREATE} off {@code public}, and it makes them differ on an
+     * installation whose tables are already there: the backend goes on reading and writing them unqualified,
+     * and a lookup asking only about {@code current_schema()} would report every one of them absent. What that
+     * would cost is this issue over again - the clear would drop nothing and say nothing, which is #888 - and
+     * one thing worse besides: the next open would create a second, empty set of tables in the schema ahead,
+     * and from that commit on they would shadow the populated ones for every later unqualified reference.
+     * <p>
+     * The connection string carries the path rather than a role being altered, because the pools of this
+     * backend are keyed by it: a storage of another url is a storage of connections of its own, where an
+     * {@code ALTER ROLE} would leave every connection already pooled resolving the way it always did.
+     */
+    @Test
+    public void testAClearFindsATableOfAnotherSchemaOfTheSearchPath() throws Exception {
+        final TreeName tree = new TreeName("testSearchPath", "tree");
+        final String backendId = getBackendId() + "_searchPath";
+        // the tables of an installation made before anything was put in front of the schema they are in
+        final JDBCStorage created = new JDBCStorage(createBackendCfg(backendId), null);
+        final String tableName = created.getTableName(tree);
+        // the same backend, over connections resolving in a schema of its own first and in the one the
+        // tables are in behind it: what they reach unqualified is unchanged, what they create is not
+        final String aheadOfThem = getJdbcUrl() + "&currentSchema=" + AHEAD_ON_THE_PATH + ",public";
+        final JDBCStorage storage = new JDBCStorage(createBackendCfg(backendId, aheadOfThem), null);
+        try {
+            try {
+                created.open(AccessMode.READ_WRITE);
+                created.write(new WriteOperation() {
+                    @Override
+                    public void run(WriteableTransaction txn) throws Exception {
+                        txn.openTree(tree, true);
+                    }
+                });
+            } finally {
+                created.close();
+            }
+            assertTrue(isExistsTable(tableName), "the case did not make the table it is about");
+
+            try (final Connection con = DriverManager.getConnection(getJdbcUrl());
+                 final Statement st = con.createStatement()) {
+                st.execute("create schema if not exists " + AHEAD_ON_THE_PATH);
+            }
+            storage.open(AccessMode.READ_WRITE);
+            try (final Connection con = DriverManager.getConnection(aheadOfThem)) {
+                // the fixture is the whole of the case: without this the two schemas are the same one and
+                // the assertions below hold of the version this case is about as well
+                assertEquals(con.getSchema(), AHEAD_ON_THE_PATH,
+                    "the connections of this storage do not work in the schema put ahead of the tables");
+                assertNotEquals(con.getSchema(), "public", "the tables of this case are not in public after all");
+            }
+
+            assertTrue(storage.listTrees().contains(tree),
+                "a tree whose table this connection reads unqualified was named by none of them");
+
+            // the other half of what the narrowing decides, and the destructive one: openTree() creates a
+            // table where its lookup answers that there is none, and an unqualified "create table" lands in
+            // current_schema() - the schema ahead of the tables. A lookup asking about that schema alone
+            // would answer no here and leave the populated table in public orphaned behind a second, empty
+            // one, from this commit on. The clear below drops what the catalog names and would go on
+            // passing while it happened, which is why this is asserted here rather than left to it
+            storage.write(new WriteOperation() {
+                @Override
+                public void run(WriteableTransaction txn) throws Exception {
+                    txn.openTree(tree, true);
+                }
+            });
+            assertFalse(isExistsTableInSchema(AHEAD_ON_THE_PATH, tableName),
+                "the open created a second table in the schema ahead of the tables, shadowing the populated one");
+            assertTrue(isExistsTableInSchema("public", tableName),
+                "the open did not leave the populated table where it is");
+
+            storage.removeStorageFiles();
+
+            assertFalse(isExistsTable(tableName),
+                "the clear left a table it reaches unqualified standing, for living in another schema of the search path");
+        } finally {
+            // the same backend id, so this clears what either half of the case created - including the
+            // run where the clear under test drops nothing and the tables would otherwise be left for
+            // whatever case of this class runs next
+            clearQuietly(storage);
+            clearQuietly(new JDBCStorage(createBackendCfg(backendId), null));
+            try (final Connection con = DriverManager.getConnection(getJdbcUrl());
+                 final Statement st = con.createStatement()) {
+                st.execute("drop schema if exists " + AHEAD_ON_THE_PATH + " cascade");
+            }
+        }
+    }
+
+    /**
+     * Whether the table is in that one schema, which is the question the case above asks and the one
+     * {@code TestCase.isExistsTable} cannot answer: it walks every schema the connection can see, so a
+     * table created in the wrong one of the two reads there exactly like a table created in the right
+     * one. Asked of {@code information_schema} with the schema and the name bound rather than through
+     * {@code getTables()}, whose schema is a pattern - {@code opendj_ahead} would match a schema named
+     * {@code opendjXahead} as readily, {@code _} being a single-character wildcard there.
+     */
+    private boolean isExistsTableInSchema(String schema, String tableName) throws SQLException {
+        try (final Connection con = DriverManager.getConnection(getJdbcUrl());
+             final PreparedStatement st = con.prepareStatement(
+                 "select 1 from information_schema.tables where table_schema=? and lower(table_name)=lower(?)")) {
+            st.setString(1, schema);
+            st.setString(2, tableName);
+            try (final ResultSet rs = st.executeQuery()) {
+                return rs.next();
+            }
+        }
+    }
+
 }

--
Gitblit v1.10.0