diff --git a/donkey/src/main/java/com/mirth/connect/donkey/server/data/jdbc/JdbcDao.java b/donkey/src/main/java/com/mirth/connect/donkey/server/data/jdbc/JdbcDao.java index 1ff1034560..b69e387174 100644 --- a/donkey/src/main/java/com/mirth/connect/donkey/server/data/jdbc/JdbcDao.java +++ b/donkey/src/main/java/com/mirth/connect/donkey/server/data/jdbc/JdbcDao.java @@ -83,6 +83,7 @@ public class JdbcDao implements DonkeyDao { private String statsServerId; private boolean transactionAlteredChannels = false; private char quoteChar = '"'; + private boolean scopeTablesToCatalog = false; private Logger logger = LogManager.getLogger(this.getClass()); protected JdbcDao(Donkey donkey, Connection connection, QuerySource querySource, SerializerProvider serializerProvider, boolean encryptMessageContent, boolean encryptAttachments, boolean encryptCustomMetaData, boolean decryptData, StatisticsUpdater statisticsUpdater, Statistics currentStats, Statistics totalStats, String statsServerId) { @@ -130,6 +131,20 @@ public void setQuoteChar(char quoteChar) { this.quoteChar = quoteChar; } + /** + * Limits table lookups to the connection's own catalog. MySQL Connector/J treats a null catalog + * as every database the login can see, so without this a second database on a shared login + * matches the first one's tables. Off by default; enabled by {@link MysqlDaoFactory}. See + * OpenIntegrationEngine/engine#489. + */ + public void setScopeTablesToCatalog(boolean scopeTablesToCatalog) { + this.scopeTablesToCatalog = scopeTablesToCatalog; + } + + private String getTableCatalog() throws SQLException { + return scopeTablesToCatalog ? connection.getCatalog() : null; + } + @Override public void insertMessage(Message message) { logger.debug(message.getChannelId() + "/" + message.getMessageId() + ": inserting message"); @@ -1924,7 +1939,7 @@ public void checkAndCreateChannelTables() { try { DatabaseMetaData dbMetaData = connection.getMetaData(); - try (ResultSet rs = dbMetaData.getTables(null, null, "%", null)) { + try (ResultSet rs = dbMetaData.getTables(getTableCatalog(), null, "%", null)) { while (rs.next()) { String tableName = rs.getString("TABLE_NAME").toLowerCase(); channelTablesMap.remove(tableName); @@ -2238,10 +2253,11 @@ public boolean initTableStructure() { private boolean tableExists(String tableName) { try { DatabaseMetaData metaData = connection.getMetaData(); + String catalog = getTableCatalog(); // Databases disagree on identifier case folding, so try both spellings for (String name : new String[] { tableName, tableName.toUpperCase() }) { - try (ResultSet resultSet = metaData.getTables(null, null, name, null)) { + try (ResultSet resultSet = metaData.getTables(catalog, null, name, null)) { if (resultSet.next()) { return true; } diff --git a/donkey/src/main/java/com/mirth/connect/donkey/server/data/jdbc/MysqlDaoFactory.java b/donkey/src/main/java/com/mirth/connect/donkey/server/data/jdbc/MysqlDaoFactory.java index ab08ebf458..7a888c8320 100644 --- a/donkey/src/main/java/com/mirth/connect/donkey/server/data/jdbc/MysqlDaoFactory.java +++ b/donkey/src/main/java/com/mirth/connect/donkey/server/data/jdbc/MysqlDaoFactory.java @@ -16,6 +16,7 @@ public class MysqlDaoFactory extends JdbcDaoFactory { public JdbcDao getDao(SerializerProvider serializerProvider) { JdbcDao dao = super.getDao(serializerProvider); dao.setQuoteChar('`'); + dao.setScopeTablesToCatalog(true); return dao; } } diff --git a/donkey/src/test/java/com/mirth/connect/donkey/server/data/jdbc/JdbcDaoTest.java b/donkey/src/test/java/com/mirth/connect/donkey/server/data/jdbc/JdbcDaoTest.java index 4cf983e55f..5ac513d43a 100644 --- a/donkey/src/test/java/com/mirth/connect/donkey/server/data/jdbc/JdbcDaoTest.java +++ b/donkey/src/test/java/com/mirth/connect/donkey/server/data/jdbc/JdbcDaoTest.java @@ -10,10 +10,12 @@ package com.mirth.connect.donkey.server.data.jdbc; import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; import static org.junit.Assert.fail; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyInt; import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.isNull; import static org.mockito.Mockito.doReturn; import static org.mockito.Mockito.doThrow; import static org.mockito.Mockito.inOrder; @@ -476,4 +478,39 @@ private void setLocalChannelId(String channelId, long localChannelId) { channels.put(channelId, channel); when(donkey.getDeployedChannels()).thenReturn(channels); } + + /** + * Table lookups stay unscoped (null catalog) unless the factory enables catalog scoping, so + * only MySQL changes behaviour. See OpenIntegrationEngine/engine#489. + */ + @Test + public void testTableLookupUnscopedByDefault() throws SQLException { + DatabaseMetaData metaData = mock(DatabaseMetaData.class); + ResultSet resultSet = mock(ResultSet.class); + when(connection.getMetaData()).thenReturn(metaData); + when(connection.getCatalog()).thenReturn("oie_b"); + when(metaData.getTables(any(), any(), any(), any())).thenReturn(resultSet); + when(resultSet.next()).thenReturn(true); + + assertFalse(dao.initTableStructure()); + + verify(metaData).getTables(isNull(), isNull(), eq("d_channels"), isNull()); + verify(connection, never()).getCatalog(); + } + + /** With catalog scoping enabled (MySQL), lookups are limited to the connection's database. */ + @Test + public void testTableLookupScopedToCatalogWhenEnabled() throws SQLException { + DatabaseMetaData metaData = mock(DatabaseMetaData.class); + ResultSet resultSet = mock(ResultSet.class); + when(connection.getMetaData()).thenReturn(metaData); + when(connection.getCatalog()).thenReturn("oie_b"); + when(metaData.getTables(any(), any(), any(), any())).thenReturn(resultSet); + when(resultSet.next()).thenReturn(true); + + dao.setScopeTablesToCatalog(true); + assertFalse(dao.initTableStructure()); + + verify(metaData).getTables(eq("oie_b"), isNull(), eq("d_channels"), isNull()); + } } diff --git a/server/src/main/java/com/mirth/connect/server/util/DatabaseUtil.java b/server/src/main/java/com/mirth/connect/server/util/DatabaseUtil.java index b4f815d9e3..71dc6bbe9f 100644 --- a/server/src/main/java/com/mirth/connect/server/util/DatabaseUtil.java +++ b/server/src/main/java/com/mirth/connect/server/util/DatabaseUtil.java @@ -211,14 +211,15 @@ public static boolean tableExists(Connection connection, String tableName) { try { DatabaseMetaData metaData = connection.getMetaData(); - resultSet = metaData.getTables(null, null, tableName.toUpperCase(), new String[] { + String catalog = lookupCatalog(connection, metaData); + resultSet = metaData.getTables(catalog, null, tableName.toUpperCase(), new String[] { "TABLE" }); if (resultSet.next()) { return true; } - resultSet = metaData.getTables(null, null, tableName.toLowerCase(), new String[] { + resultSet = metaData.getTables(catalog, null, tableName.toLowerCase(), new String[] { "TABLE" }); return resultSet.next(); } catch (SQLException e) { @@ -228,6 +229,20 @@ public static boolean tableExists(Connection connection, String tableName) { } } + /** + * The catalog to pass to metadata lookups. MySQL treats a null catalog as every database the + * login can see, so scope lookups to this connection's database there. Other databases keep + * the unscoped (null) lookup. See OpenIntegrationEngine/engine#489. + */ + private static String lookupCatalog(Connection connection, DatabaseMetaData metaData) throws SQLException { + return isMySql(metaData) ? connection.getCatalog() : null; + } + + private static boolean isMySql(DatabaseMetaData metaData) throws SQLException { + String productName = metaData.getDatabaseProductName(); + return "MySQL".equalsIgnoreCase(productName) || "MariaDB".equalsIgnoreCase(productName); + } + /** * Tell whether or not the given index exists in the database */ @@ -240,7 +255,8 @@ public static boolean indexExists(Connection connection, String tableName, Strin try { DatabaseMetaData metaData = connection.getMetaData(); - resultSet = metaData.getIndexInfo(null, null, tableName.toUpperCase(), false, false); + String catalog = lookupCatalog(connection, metaData); + resultSet = metaData.getIndexInfo(catalog, null, tableName.toUpperCase(), false, false); while (resultSet.next()) { if (indexName.equalsIgnoreCase(resultSet.getString("INDEX_NAME"))) { @@ -248,7 +264,7 @@ public static boolean indexExists(Connection connection, String tableName, Strin } } - resultSet = metaData.getIndexInfo(null, null, tableName.toLowerCase(), false, false); + resultSet = metaData.getIndexInfo(catalog, null, tableName.toLowerCase(), false, false); while (resultSet.next()) { if (indexName.equalsIgnoreCase(resultSet.getString("INDEX_NAME"))) { diff --git a/server/src/test/java/com/mirth/connect/server/util/DatabaseUtilTest.java b/server/src/test/java/com/mirth/connect/server/util/DatabaseUtilTest.java new file mode 100644 index 0000000000..79c975b8c9 --- /dev/null +++ b/server/src/test/java/com/mirth/connect/server/util/DatabaseUtilTest.java @@ -0,0 +1,96 @@ +// SPDX-License-Identifier: MPL-2.0 +// SPDX-FileCopyrightText: Open Integration Engine Contributors + +package com.mirth.connect.server.util; + +import static org.junit.Assert.assertFalse; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyBoolean; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.isNull; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.sql.Connection; +import java.sql.DatabaseMetaData; +import java.sql.ResultSet; + +import org.junit.Test; + +public class DatabaseUtilTest { + + private static Connection connectionFor(String productName, DatabaseMetaData metaData, boolean tableFound) throws Exception { + Connection connection = mock(Connection.class); + ResultSet tables = mock(ResultSet.class); + ResultSet indexes = mock(ResultSet.class); + + when(connection.getMetaData()).thenReturn(metaData); + when(connection.getCatalog()).thenReturn("oie_b"); + when(metaData.getDatabaseProductName()).thenReturn(productName); + when(metaData.getTables(any(), any(), any(), any())).thenReturn(tables); + when(tables.next()).thenReturn(tableFound); + when(metaData.getIndexInfo(any(), any(), any(), anyBoolean(), anyBoolean())).thenReturn(indexes); + when(indexes.next()).thenReturn(false); + return connection; + } + + /** + * On MySQL, tableExists must scope its metadata lookup to the connection's own catalog. A null + * catalog lets Connector/J match tables in any database the login can see, so a second OIE + * database on a shared login is never migrated. See OpenIntegrationEngine/engine#489. + */ + @Test + public void tableExists_onMySql_scopesLookupToConnectionCatalog() throws Exception { + DatabaseMetaData metaData = mock(DatabaseMetaData.class); + Connection connection = connectionFor("MySQL", metaData, false); + + assertFalse(DatabaseUtil.tableExists(connection, "CONFIGURATION")); + + verify(metaData).getTables(eq("oie_b"), isNull(), eq("CONFIGURATION"), any()); + verify(metaData).getTables(eq("oie_b"), isNull(), eq("configuration"), any()); + } + + /** Other databases keep the existing unscoped (null catalog) lookup. */ + @Test + public void tableExists_onOtherDatabases_keepsNullCatalog() throws Exception { + DatabaseMetaData metaData = mock(DatabaseMetaData.class); + Connection connection = connectionFor("PostgreSQL", metaData, false); + + assertFalse(DatabaseUtil.tableExists(connection, "CONFIGURATION")); + + verify(metaData).getTables(isNull(), isNull(), eq("CONFIGURATION"), any()); + verify(metaData).getTables(isNull(), isNull(), eq("configuration"), any()); + verify(connection, never()).getCatalog(); + } + + /** + * On MySQL, indexExists must scope its index lookup the same way. Otherwise an engine that has + * lost one of its own indexes finds another database's index of the same name and is never + * offered the task that recreates it. + */ + @Test + public void indexExists_onMySql_scopesLookupToConnectionCatalog() throws Exception { + DatabaseMetaData metaData = mock(DatabaseMetaData.class); + Connection connection = connectionFor("MySQL", metaData, true); + + assertFalse(DatabaseUtil.indexExists(connection, "D_MM1", "D_MM1_INDEX3")); + + verify(metaData).getIndexInfo(eq("oie_b"), isNull(), eq("D_MM1"), eq(false), eq(false)); + verify(metaData).getIndexInfo(eq("oie_b"), isNull(), eq("d_mm1"), eq(false), eq(false)); + } + + /** Other databases keep the existing unscoped (null catalog) index lookup. */ + @Test + public void indexExists_onOtherDatabases_keepsNullCatalog() throws Exception { + DatabaseMetaData metaData = mock(DatabaseMetaData.class); + Connection connection = connectionFor("PostgreSQL", metaData, true); + + assertFalse(DatabaseUtil.indexExists(connection, "D_MM1", "D_MM1_INDEX3")); + + verify(metaData).getIndexInfo(isNull(), isNull(), eq("D_MM1"), eq(false), eq(false)); + verify(metaData).getIndexInfo(isNull(), isNull(), eq("d_mm1"), eq(false), eq(false)); + verify(connection, never()).getCatalog(); + } +}