Skip to content

Commit

Permalink
Fix table drop order during backup import.
Browse files Browse the repository at this point in the history
Fixes #12671
  • Loading branch information
greyson-signal committed Dec 12, 2022
1 parent a6a1850 commit bd11ed9
Show file tree
Hide file tree
Showing 3 changed files with 239 additions and 23 deletions.
Expand Up @@ -5,11 +5,11 @@
import android.content.ContentValues;
import android.content.Context;
import android.content.SharedPreferences;
import android.database.Cursor;
import android.net.Uri;
import android.util.Pair;

import androidx.annotation.NonNull;
import androidx.annotation.VisibleForTesting;

import net.zetetic.database.sqlcipher.SQLiteDatabase;

Expand Down Expand Up @@ -42,29 +42,24 @@
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.LinkedList;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Queue;
import java.util.Set;
import java.util.stream.Collectors;

public class FullBackupImporter extends FullBackupBase {

@SuppressWarnings("unused")
private static final String TAG = Log.tag(FullBackupImporter.class);

private static final String[] TABLES_TO_DROP_FIRST = {
"distribution_list_member",
"distribution_list",
"message_send_log_recipients",
"msl_recipient",
"msl_message",
"reaction",
"notification_profile_schedule",
"notification_profile_allowed_members",
"story_sends"
};

public static void importFile(@NonNull Context context, @NonNull AttachmentSecret attachmentSecret,
@NonNull SQLiteDatabase db, @NonNull Uri uri, @NonNull String passphrase)
throws IOException
Expand Down Expand Up @@ -268,21 +263,69 @@ private static void processPreference(@NonNull Context context, SharedPreference
}

private static void dropAllTables(@NonNull SQLiteDatabase db) {
for (String name : TABLES_TO_DROP_FIRST) {
db.execSQL("DROP TABLE IF EXISTS " + name);
for (String trigger : SqlUtil.getAllTriggers(db)) {
Log.i(TAG, "Dropping trigger: " + trigger);
db.execSQL("DROP TRIGGER IF EXISTS " + trigger);
}
for (String table : getTablesToDropInOrder(db)) {
Log.i(TAG, "Dropping table: " + table);
db.execSQL("DROP TABLE IF EXISTS " + table);
}
}

/**
* Returns the list of tables we should drop, in the order they should be dropped in.
* The order is chosen to ensure we won't violate any foreign key constraints when we import them.
*/
private static List<String> getTablesToDropInOrder(@NonNull SQLiteDatabase input) {
List<String> tables = SqlUtil.getAllTables(input)
.stream()
.filter(table -> !table.startsWith("sqlite_"))
.sorted()
.collect(Collectors.toList());


Map<String, Set<String>> dependsOn = new LinkedHashMap<>();
for (String table : tables) {
dependsOn.put(table, SqlUtil.getForeignKeyDependencies(input, table));
}

for (String table : tables) {
Set<String> dependsOnTable = dependsOn.keySet().stream().filter(t -> dependsOn.get(t).contains(table)).collect(Collectors.toSet());
Log.i(TAG, "Tables that depend on " + table + ": " + dependsOnTable);
}

try (Cursor cursor = db.rawQuery("SELECT name, type FROM sqlite_master", null)) {
while (cursor != null && cursor.moveToNext()) {
String name = cursor.getString(0);
String type = cursor.getString(1);
return computeTableDropOrder(dependsOn);
}

@VisibleForTesting
static List<String> computeTableDropOrder(@NonNull Map<String, Set<String>> dependsOn) {
List<String> rootNodes = dependsOn.keySet()
.stream()
.filter(table -> {
boolean nothingDependsOnIt = dependsOn.values().stream().noneMatch(it -> it.contains(table));
return nothingDependsOnIt;
})
.sorted()
.collect(Collectors.toList());

LinkedHashSet<String> dropOrder = new LinkedHashSet<>();

Queue<String> processOrder = new LinkedList<>(rootNodes);

if ("table".equals(type) && !name.startsWith("sqlite_")) {
Log.i(TAG, "Dropping table: " + name);
db.execSQL("DROP TABLE IF EXISTS " + name);
}
while (!processOrder.isEmpty()) {
String head = processOrder.remove();

dropOrder.remove(head);
dropOrder.add(head);

Set<String> dependencies = dependsOn.get(head);
if (dependencies != null) {
processOrder.addAll(dependencies);
}
}

return new ArrayList<>(dropOrder);
}

public static class DatabaseDowngradeException extends IOException {
Expand Down
@@ -0,0 +1,162 @@
package org.thoughtcrime.securesms.backup

import org.junit.Assert.assertEquals
import org.junit.Test

class FullBackupImporterTest {

@Test
fun `computeTableOrder - empty`() {
val order = FullBackupImporter.computeTableDropOrder(mapOf())

assertEquals(listOf<String>(), order)
}

/**
* A B C
*/
@Test
fun `computeTableOrder - no dependencies`() {
val order = FullBackupImporter.computeTableDropOrder(
mapOf(
"A" to setOf(),
"B" to setOf(),
"C" to setOf(),
)
)

assertEquals(listOf("A", "B", "C"), order)
}

/**
* A
* |
* B
* |
* C
*/
@Test
fun `computeTableOrder - single chain`() {
val order = FullBackupImporter.computeTableDropOrder(
mapOf(
"A" to setOf("B"),
"B" to setOf("C"),
)
)

assertEquals(listOf("A", "B", "C"), order)
}

/**
* ┌──A──┐ B C
* ┌─D ┌─E─┐
* F G H
*/
@Test
fun `computeTableOrder - complex 1`() {
val order = FullBackupImporter.computeTableDropOrder(
mapOf(
"A" to setOf("D", "E"),
"B" to setOf(),
"C" to setOf(),
"D" to setOf("F"),
"E" to setOf("G", "H"),
"F" to setOf(),
"G" to setOf(),
"H" to setOf(),
)
)

assertEquals(listOf("A", "B", "C", "D", "E", "F", "G", "H"), order)
}

/**
* ┌────A────┐
* │ | │
* ┌─B─┐ C ┌─D─┐
* │ │ | │ │
* E F G H I
*/
@Test
fun `computeTableOrder - complex 2`() {
val order = FullBackupImporter.computeTableDropOrder(
mapOf(
"A" to setOf("B", "C", "D"),
"B" to setOf("E", "F"),
"C" to setOf("G"),
"D" to setOf("H", "I"),
"E" to setOf(),
"F" to setOf(),
"G" to setOf(),
"H" to setOf(),
"I" to setOf(),
)
)

assertEquals(listOf("A", "B", "C", "D", "E", "F", "G", "H", "I"), order)
}

/**
* ┌─A─┐ B ┌─C─┐
* │ │ | │ │
* D E F G H
*/
@Test
fun `computeTableOrder - multiple roots`() {
val order = FullBackupImporter.computeTableDropOrder(
mapOf(
"A" to setOf("D", "E"),
"B" to setOf("F"),
"C" to setOf("G", "H"),
"D" to setOf(),
"E" to setOf(),
"F" to setOf(),
"G" to setOf(),
"H" to setOf(),
)
)

assertEquals(listOf("A", "B", "C", "D", "E", "F", "G", "H"), order)
}

/**
* ┌─A─┐ B ┌─C─┐
* │ │ | │ │
* D E D D E
*/
@Test
fun `computeTableOrder - multiple roots, dupes across graphs`() {
val order = FullBackupImporter.computeTableDropOrder(
mapOf(
"A" to setOf("D", "E"),
"B" to setOf("D"),
"C" to setOf("D", "E"),
"D" to setOf(),
"E" to setOf(),
)
)

assertEquals(listOf("A", "B", "C", "D", "E"), order)
}

/**
* A B
* │ │
* D C
* │
* D
*/
@Test
fun `computeTableOrder - multiple roots, new dependencies across roots`() {
val order = FullBackupImporter.computeTableDropOrder(
mapOf(
"A" to setOf("D"),
"B" to setOf("C"),
"C" to setOf("D"),
"D" to setOf(),
)
)

assertEquals(listOf("A", "B", "C", "D"), order)
}
}
11 changes: 11 additions & 0 deletions core-util/src/main/java/org/signal/core/util/SqlUtil.kt
Expand Up @@ -33,6 +33,17 @@ object SqlUtil {
return tables
}

@JvmStatic
fun getAllTriggers(db: SupportSQLiteDatabase): List<String> {
val tables: MutableList<String> = LinkedList()
db.query("SELECT name FROM sqlite_master WHERE type=?", arrayOf("trigger")).use { cursor ->
while (cursor.moveToNext()) {
tables.add(cursor.getString(0))
}
}
return tables
}

/**
* Given a table, this will return a set of tables that it has a foreign key dependency on.
*/
Expand Down

0 comments on commit bd11ed9

Please sign in to comment.