/* * The contents of this file are subject to the terms of the Common Development and * Distribution License (the License). You may not use this file except in compliance with the * License. * * You can obtain a copy of the License at legal/CDDLv1.0.txt. See the License for the * specific language governing permission and limitations under the License. * * When distributing Covered Software, include this CDDL Header Notice in each file and include * the License file at legal/CDDLv1.0.txt. If applicable, add the following below the CDDL * Header, with the fields enclosed by brackets [] replaced by your own identifying * information: "Portions Copyright [year] [name of copyright owner]". * * 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.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 /** * 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 protected JdbcDatabaseContainer> getContainer() { return new PostgreSQLContainer<>("postgres:latest") .withExposedPorts(5432) .withUsername("postgres") .withPassword("password") .withDatabaseName("database_name"); } @Override protected String getContainerDockerCommand() { return "run before test: docker run --rm -it -p 5432:5432 -e POSTGRES_DB=database_name -e POSTGRES_PASSWORD=password --name postgres postgres"; } @Override protected String getBackendId() { return PgSqlTestCase.class.getSimpleName(); } @Override protected String getJdbcUrl() { 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. *
* 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. *
* 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() + "¤tSchema=" + 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(); } } } /** The schema of the case below: one no connection of this suite resolves in. */ private static final String OFF_THE_PATH = "opendj_offpath"; /** * A table this connection does not reach unqualified answers for none of this backend's, and neither * does its index (#902). *
* A table is named after the hash of its tree name and its index after the table, so both names follow * from the configuration alone: two directories sharing one database, each with a schema and a * {@code search_path} of its own - the routine way to host two of them on one server - hold a table of * the same name and an index of the same name, and nothing about either name tells the two apart. Asked * with no schema at all, as the JDBC contract reads it, the catalog answers for the neighbour's, and both * guards of {@code openTree()} then skip a create this backend needs: the table one leaves every * statement of the backend addressing a relation that is not there, and the index one - the quiet half - * leaves the {@code where k>? order by k} batches of every cursor running unindexed for the life of the * deployment, nothing failing and nothing being logged. *
* The neighbour is made by hand rather than by a second storage: what the case needs is a schema the * connections of this one do not resolve in, and the tables of a storage of this suite are made in the * schema they do. */ @Test public void testAnOpenIsAnsweredForByNoTableOfASchemaOffTheSearchPath() throws Exception { final TreeName tree = new TreeName("testOffThePath", "tree"); final JDBCStorage storage = new JDBCStorage(createBackendCfg(getBackendId() + "_offPath"), null); final String tableName = storage.getTableName(tree); final String indexName = "k_" + tableName.substring("opendj_".length()); try { // the schema an unqualified create of this storage lands in, which is where the table and the // index this case is about have to end up final String working; try (final Connection con = DriverManager.getConnection(getJdbcUrl())) { working = con.getSchema(); } try (final Connection con = DriverManager.getConnection(getJdbcUrl()); final Statement st = con.createStatement()) { st.execute("create schema if not exists " + OFF_THE_PATH); // the fixture is the whole of the case: the guards read the whole search_path // (schemaPathOf(), current_schemas(true)) rather than current_schema() alone, so a // neighbour anywhere on it - not only in the schema the connection happens to work in - // would be legitimately found and nothing created, which is not the collision this case // is about try (final ResultSet rs = st.executeQuery("select unnest(current_schemas(true))")) { while (rs.next()) { assertNotEquals(rs.getString(1), OFF_THE_PATH, "the neighbour of this case is on the search_path of the connections of this suite"); } } // 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 st.execute("create table " + OFF_THE_PATH + "." + tableName + " (h char(128),k bytea,v bytea,primary key(h,k))"); st.execute("create index " + indexName + " on " + OFF_THE_PATH + "." + tableName + " (k)"); } assertFalse(isExistsTableInSchema(working, tableName), "the case did not start with the table of this backend absent from the schema it works in"); 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 relation that is not there txn.put(tree, ByteString.valueOfUtf8("a key of this backend"), ByteString.valueOfUtf8("a value of this backend")); } }); assertTrue(isExistsTableInSchema(working, tableName), "the open took the table of a schema it does not reach unqualified for its own and created none"); assertTrue(isExistsIndexInSchema(working, indexName), "the open took the index of a schema it does not reach unqualified for its own: the cursor batches of this tree are full scans behind it"); assertEquals(rowCountInSchema(working, tableName), 1, "the write of this backend landed in a table other than the one the open made"); } finally { clearQuietly(storage); try (final Connection con = DriverManager.getConnection(getJdbcUrl()); final Statement st = con.createStatement()) { st.execute("drop schema if exists " + OFF_THE_PATH + " cascade"); } } } /** * Whether that one schema holds the index, which is the index half of the case above and the question * {@code getIndexInfo()} cannot be trusted with here: it is the very lookup under test. */ private boolean isExistsIndexInSchema(String schema, String indexName) throws SQLException { try (final Connection con = DriverManager.getConnection(getJdbcUrl()); final PreparedStatement st = con.prepareStatement( "select 1 from pg_indexes where schemaname=? and lower(indexname)=lower(?)")) { st.setString(1, schema); st.setString(2, indexName); try (final ResultSet rs = st.executeQuery()) { return rs.next(); } } } /** What the table of that one schema holds, which says which of the two tables a write went to. */ private int rowCountInSchema(String schema, String tableName) throws SQLException { try (final Connection con = DriverManager.getConnection(getJdbcUrl()); final Statement st = con.createStatement(); final ResultSet rs = st.executeQuery("select count(*) from " + schema + "." + tableName)) { return rs.next() ? rs.getInt(1) : -1; } } }