diff --git a/fe/fe-core/src/main/java/org/apache/doris/catalog/View.java b/fe/fe-core/src/main/java/org/apache/doris/catalog/View.java index a27f23cd3c9ecd..5ce745e69d3f76 100644 --- a/fe/fe-core/src/main/java/org/apache/doris/catalog/View.java +++ b/fe/fe-core/src/main/java/org/apache/doris/catalog/View.java @@ -43,6 +43,7 @@ import java.io.StringReader; import java.lang.ref.SoftReference; import java.util.List; +import java.util.regex.Pattern; /** * Table metadata representing a catalog view or a local view from a WITH clause. @@ -274,8 +275,10 @@ public void resetIdsForRestore(Env env) { public void resetViewDefForRestore(String srcDbName, String dbName) { // the source db name is not setted in old BackupMeta, keep compatible with the old one. if (srcDbName != null) { - // replace dbName with a regular expression - inlineViewDef = inlineViewDef.replaceAll("(?<=`internal`\\.`)([^`]+)(?=`\\.`)", dbName); + // Only replace the source database name, preserve cross-database references + // Pattern: `internal`.`srcDbName`.`table` -> `internal`.`dbName`.`table` + String pattern = "(?<=`internal`\\.`)" + Pattern.quote(srcDbName) + "(?=`\\.`)"; + inlineViewDef = inlineViewDef.replaceAll(pattern, dbName); } } diff --git a/fe/fe-core/src/test/java/org/apache/doris/catalog/CreateViewTest.java b/fe/fe-core/src/test/java/org/apache/doris/catalog/CreateViewTest.java index 01c8a4f0ab9b20..963b3e7b446d30 100644 --- a/fe/fe-core/src/test/java/org/apache/doris/catalog/CreateViewTest.java +++ b/fe/fe-core/src/test/java/org/apache/doris/catalog/CreateViewTest.java @@ -200,4 +200,28 @@ public void testResetViewDefForRestore() { Assert.assertEquals("SELECT `internal`.`test1`.`test`.`k2` AS `k1`, " + "FROM `internal`.`test1`.`test`;", view.getInlineViewDef()); } + + @Test + public void testResetViewDefForRestoreWithCrossDbReference() { + // Test that cross-database references are preserved + View view = new View(); + // View in db_b references tables from both db_a and db_b + view.setInlineViewDefWithSqlMode( + "SELECT t1.k1, t2.k2 " + + "FROM `internal`.`db_a`.`table1` t1 " + + "JOIN `internal`.`db_b`.`table2` t2 " + + "ON t1.id = t2.id;", + 0L); + + // Restore db_b to db_b_new + view.resetViewDefForRestore("db_b", "db_b_new"); + + // db_a reference should be preserved, only db_b should be changed to db_b_new + Assert.assertEquals( + "SELECT t1.k1, t2.k2 " + + "FROM `internal`.`db_a`.`table1` t1 " + + "JOIN `internal`.`db_b_new`.`table2` t2 " + + "ON t1.id = t2.id;", + view.getInlineViewDef()); + } } diff --git a/regression-test/suites/backup_restore/test_backup_restore_with_view.groovy b/regression-test/suites/backup_restore/test_backup_restore_with_view.groovy index 91328355eca6e4..a9a92ab68101ff 100644 --- a/regression-test/suites/backup_restore/test_backup_restore_with_view.groovy +++ b/regression-test/suites/backup_restore/test_backup_restore_with_view.groovy @@ -128,6 +128,159 @@ suite("test_backup_restore_with_view", "backup_restore") { def res = sql "SHOW VIEW FROM ${dbName1}.${tableName}" assertTrue(res.size() > 0) + // Test cross-database view references preservation + logger.info("========== Testing cross-database view references ==========") + + String baseDbName = "${suiteName}_base_db" + String viewDbName = "${suiteName}_view_db" + String restoreDbName = "${suiteName}_restore_db" + String baseTableName = "base_table" + String localTableName = "local_table" + String crossDbViewName = "cross_db_view" + String mixedViewName = "mixed_view" + + try { + // Create base database with base table + sql "DROP DATABASE IF EXISTS ${baseDbName} FORCE" + sql "DROP DATABASE IF EXISTS ${viewDbName} FORCE" + sql "DROP DATABASE IF EXISTS ${restoreDbName} FORCE" + + sql "CREATE DATABASE ${baseDbName}" + sql "CREATE DATABASE ${viewDbName}" + + sql """ + CREATE TABLE ${baseDbName}.${baseTableName} ( + id INT, + name VARCHAR(100), + value INT + ) + DUPLICATE KEY(id) + DISTRIBUTED BY HASH(id) BUCKETS 2 + PROPERTIES ("replication_num" = "1") + """ + + sql """ + CREATE TABLE ${viewDbName}.${localTableName} ( + id INT, + category VARCHAR(100) + ) + DUPLICATE KEY(id) + DISTRIBUTED BY HASH(id) BUCKETS 2 + PROPERTIES ("replication_num" = "1") + """ + + sql """ + INSERT INTO ${baseDbName}.${baseTableName} VALUES + (1, 'Alice', 100), + (2, 'Bob', 200), + (3, 'Charlie', 300) + """ + + sql """ + INSERT INTO ${viewDbName}.${localTableName} VALUES + (1, 'TypeA'), + (2, 'TypeB'), + (3, 'TypeC') + """ + + // Create cross-database view (references base_db only) + sql """ + CREATE VIEW ${viewDbName}.${crossDbViewName} AS + SELECT id, name, value + FROM `internal`.`${baseDbName}`.`${baseTableName}` + WHERE value > 100 + """ + + // Create mixed view (references both base_db and view_db) + sql """ + CREATE VIEW ${viewDbName}.${mixedViewName} AS + SELECT + t1.id, + t1.name, + t1.value, + t2.category + FROM `internal`.`${baseDbName}`.`${baseTableName}` t1 + JOIN `internal`.`${viewDbName}`.`${localTableName}` t2 + ON t1.id = t2.id + """ + + // Verify original views work + def crossDbResult = sql "SELECT * FROM ${viewDbName}.${crossDbViewName} ORDER BY id" + assertTrue(crossDbResult.size() == 2) + assertTrue(crossDbResult[0][0] == 2) + assertTrue(crossDbResult[0][1] == "Bob") + + def mixedResult = sql "SELECT * FROM ${viewDbName}.${mixedViewName} ORDER BY id" + assertTrue(mixedResult.size() == 3) + + // Backup view_db + String crossDbSnapshot = "${suiteName}_cross_db_snapshot" + sql """ + BACKUP SNAPSHOT ${viewDbName}.${crossDbSnapshot} + TO `${repoName}` + """ + + syncer.waitSnapshotFinish(viewDbName) + def crossDbSnapshotTs = syncer.getSnapshotTimestamp(repoName, crossDbSnapshot) + assertTrue(crossDbSnapshotTs != null) + logger.info("Cross-DB snapshot timestamp: ${crossDbSnapshotTs}") + + // Create target database before restore (FIX: prevent database not exist error) + sql "CREATE DATABASE IF NOT EXISTS ${restoreDbName}" + + // Restore to different database + sql """ + RESTORE SNAPSHOT ${restoreDbName}.${crossDbSnapshot} + FROM `${repoName}` + PROPERTIES + ( + "backup_timestamp" = "${crossDbSnapshotTs}", + "reserve_replica" = "true" + ) + """ + + syncer.waitAllRestoreFinish(restoreDbName) + + // Verify restore success + def restoreResult = sql_return_maparray """ SHOW RESTORE FROM ${restoreDbName} WHERE Label = "${crossDbSnapshot}" """ + logger.info("Cross-DB restore result: ${restoreResult}") + assertTrue(restoreResult.last().State == "FINISHED") + + // Critical verification: Check view definitions + def crossDbViewDef = sql "SHOW CREATE VIEW ${restoreDbName}.${crossDbViewName}" + logger.info("Cross-DB view definition after restore: ${crossDbViewDef[0][1]}") + + // Cross-DB view should still reference base_db, not restore_db + assertTrue(crossDbViewDef[0][1].contains("`${baseDbName}`"), + "Cross-DB view should preserve base_db reference") + + // Mixed view should preserve base_db reference but update view_db to restore_db + def mixedViewDef = sql "SHOW CREATE VIEW ${restoreDbName}.${mixedViewName}" + logger.info("Mixed view definition after restore: ${mixedViewDef[0][1]}") + + assertTrue(mixedViewDef[0][1].contains("`${baseDbName}`"), + "Mixed view should preserve base_db reference") + assertTrue(mixedViewDef[0][1].contains("`${restoreDbName}`"), + "Mixed view should reference restore_db for local tables") + + // Verify views still work after restore + def restoredCrossDbResult = sql "SELECT * FROM ${restoreDbName}.${crossDbViewName} ORDER BY id" + assertTrue(restoredCrossDbResult.size() == 2) + assertTrue(restoredCrossDbResult[0][0] == 2) + assertTrue(restoredCrossDbResult[0][1] == "Bob") + + def restoredMixedResult = sql "SELECT * FROM ${restoreDbName}.${mixedViewName} ORDER BY id" + assertTrue(restoredMixedResult.size() == 3) + assertTrue(restoredMixedResult[0][0] == 1) + assertTrue(restoredMixedResult[0][1] == "Alice") + } finally { + // Clean up cross-DB test resources + sql "DROP DATABASE IF EXISTS ${baseDbName} FORCE" + sql "DROP DATABASE IF EXISTS ${viewDbName} FORCE" + sql "DROP DATABASE IF EXISTS ${restoreDbName} FORCE" + } + + // Clean up original test resources sql "DROP TABLE ${dbName}.${tableName} FORCE" sql "DROP VIEW ${dbName}.${viewName}" sql "DROP DATABASE ${dbName} FORCE"