Foreign keys step 3, verification
Adds foreign key verification to the migration helper and
also adds the foreign key information into the exported
bundle.
The conversions between the exported bundle and the
TableInfo is looking unnecssary but keeping it for now
since it gives us the flexibility between the compile time
representation of the schema and runtime representation
(which is limited).
Bug: 36602348
Test: MigrationTest, TableInfoTest
Change-Id: If40fe520c9930493502cddac3e6c747ef26610df
diff --git a/room/common/src/main/java/com/android/support/room/Entity.java b/room/common/src/main/java/com/android/support/room/Entity.java
index 77daab2..06d57f7 100644
--- a/room/common/src/main/java/com/android/support/room/Entity.java
+++ b/room/common/src/main/java/com/android/support/room/Entity.java
@@ -16,8 +16,6 @@
package com.android.support.room;
-import android.support.annotation.RestrictTo;
-
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
@@ -104,8 +102,6 @@
* List of {@link ForeignKey} constraints on this entity.
*
* @return The list of {@link ForeignKey} constraints on this entity.
- * @hide
*/
- @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
ForeignKey[] foreignKeys() default {};
}
diff --git a/room/common/src/main/java/com/android/support/room/ForeignKey.java b/room/common/src/main/java/com/android/support/room/ForeignKey.java
index ab2db0f..66fdfb7 100644
--- a/room/common/src/main/java/com/android/support/room/ForeignKey.java
+++ b/room/common/src/main/java/com/android/support/room/ForeignKey.java
@@ -17,7 +17,6 @@
package com.android.support.room;
import android.support.annotation.IntDef;
-import android.support.annotation.RestrictTo;
/**
* Declares a foreign key on another {@link Entity}.
@@ -43,9 +42,7 @@
* <p>
* Please refer to the SQLite <a href="https://sqlite.org/foreignkeys.html>foreign keys</a>
* documentation for details.
- * @hide
*/
-@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
public @interface ForeignKey {
/**
* The parent Entity to reference. It must be a class annotated with {@link Entity} and
diff --git a/room/compiler/src/main/kotlin/com/android/support/room/ext/javapoet_ext.kt b/room/compiler/src/main/kotlin/com/android/support/room/ext/javapoet_ext.kt
index 52061e3..b5b05a9 100644
--- a/room/compiler/src/main/kotlin/com/android/support/room/ext/javapoet_ext.kt
+++ b/room/compiler/src/main/kotlin/com/android/support/room/ext/javapoet_ext.kt
@@ -73,6 +73,8 @@
ClassName.get("com.android.support.room.util", "TableInfo")
val TABLE_INFO_COLUMN : ClassName =
ClassName.get("com.android.support.room.util", "TableInfo.Column")
+ val TABLE_INFO_FOREIGN_KEY : ClassName =
+ ClassName.get("com.android.support.room.util", "TableInfo.ForeignKey")
}
object LifecyclesTypeNames {
diff --git a/room/compiler/src/main/kotlin/com/android/support/room/vo/Entity.kt b/room/compiler/src/main/kotlin/com/android/support/room/vo/Entity.kt
index 4254049..a9fa995 100644
--- a/room/compiler/src/main/kotlin/com/android/support/room/vo/Entity.kt
+++ b/room/compiler/src/main/kotlin/com/android/support/room/vo/Entity.kt
@@ -60,7 +60,8 @@
createTableQuery(BundleUtil.TABLE_NAME_PLACEHOLDER),
fields.map {it.toBundle()},
primaryKey.toBundle(),
- indices.map { it.toBundle() })
+ indices.map { it.toBundle() },
+ foreignKeys.map { it.toBundle() })
fun isUnique(columns: List<String>) : Boolean {
return if (primaryKey.columnNames.size == columns.size
diff --git a/room/compiler/src/main/kotlin/com/android/support/room/vo/ForeignKey.kt b/room/compiler/src/main/kotlin/com/android/support/room/vo/ForeignKey.kt
index 87d58a6..d166fbb 100644
--- a/room/compiler/src/main/kotlin/com/android/support/room/vo/ForeignKey.kt
+++ b/room/compiler/src/main/kotlin/com/android/support/room/vo/ForeignKey.kt
@@ -16,6 +16,8 @@
package com.android.support.room.vo
+import com.android.support.room.migration.bundle.ForeignKeyBundle
+
/**
* Keeps information about a foreign key.
*/
@@ -42,4 +44,10 @@
}
private fun joinEscaped(values: Iterable<String>) = values.joinToString(", ") { "`$it`" }
+
+ fun toBundle(): ForeignKeyBundle = ForeignKeyBundle(
+ parentTable, onDelete.sqlName, onUpdate.sqlName,
+ childFields.map { it.columnName },
+ parentColumns
+ )
}
diff --git a/room/compiler/src/main/kotlin/com/android/support/room/writer/TableInfoValidationWriter.kt b/room/compiler/src/main/kotlin/com/android/support/room/writer/TableInfoValidationWriter.kt
index b5e9cfb..f80f8fe 100644
--- a/room/compiler/src/main/kotlin/com/android/support/room/writer/TableInfoValidationWriter.kt
+++ b/room/compiler/src/main/kotlin/com/android/support/room/writer/TableInfoValidationWriter.kt
@@ -29,7 +29,9 @@
import com.squareup.javapoet.ParameterSpec
import com.squareup.javapoet.ParameterizedTypeName
import stripNonJava
+import java.util.Arrays
import java.util.HashMap
+import java.util.HashSet
class TableInfoValidationWriter(val entity : Entity) {
fun write(dbParam : ParameterSpec, scope : CodeGenScope) {
@@ -39,6 +41,7 @@
val columnListVar = scope.getTmpVar("_columns$suffix")
val columnListType = ParameterizedTypeName.get(HashMap::class.typeName(),
CommonTypeNames.STRING, RoomTypeNames.TABLE_INFO_COLUMN)
+
addStatement("final $T $L = new $T($L)", columnListType, columnListVar,
columnListType, entity.fields.size)
entity.fields.forEachIndexed { index, field ->
@@ -48,9 +51,32 @@
/*type*/ field.affinity?.name ?: SQLTypeAffinity.TEXT.name,
/*pkeyPos*/ entity.primaryKey.fields.indexOf(field) + 1)
}
- addStatement("final $T $L = new $T($S, $L)",
+
+ val foreignKeySetVar = scope.getTmpVar("_foreignKeys$suffix")
+ val foreignKeySetType = ParameterizedTypeName.get(HashSet::class.typeName(),
+ RoomTypeNames.TABLE_INFO_FOREIGN_KEY)
+ addStatement("final $T $L = new $T($L)", foreignKeySetType, foreignKeySetVar,
+ foreignKeySetType, entity.foreignKeys.size)
+ entity.foreignKeys.forEach {
+ val myColumnNames = it.childFields
+ .joinToString(",") { "\"${it.columnName}\"" }
+ val refColumnNames = it.parentColumns
+ .joinToString(",") { "\"$it\"" }
+ addStatement("$L.add(new $T($S, $S, $S," +
+ "$T.asList($L), $T.asList($L)))", foreignKeySetVar,
+ RoomTypeNames.TABLE_INFO_FOREIGN_KEY,
+ /*parent table*/ it.parentTable,
+ /*on delete*/ it.onDelete.sqlName,
+ /*on update*/ it.onUpdate.sqlName,
+ Arrays::class.typeName(),
+ /*parent names*/ myColumnNames,
+ Arrays::class.typeName(),
+ /*parent column names*/ refColumnNames)
+ }
+
+ addStatement("final $T $L = new $T($S, $L, $L)",
RoomTypeNames.TABLE_INFO, expectedInfoVar, RoomTypeNames.TABLE_INFO,
- entity.tableName, columnListVar)
+ entity.tableName, columnListVar, foreignKeySetVar)
val existingVar = scope.getTmpVar("_existing$suffix")
addStatement("final $T $L = $T.read($N, $S)",
diff --git a/room/compiler/src/test/data/databasewriter/output/ComplexDatabase.java b/room/compiler/src/test/data/databasewriter/output/ComplexDatabase.java
index 04ab0b6..03949d0 100644
--- a/room/compiler/src/test/data/databasewriter/output/ComplexDatabase.java
+++ b/room/compiler/src/test/data/databasewriter/output/ComplexDatabase.java
@@ -10,10 +10,12 @@
import com.android.support.room.RoomOpenHelper.Delegate;
import com.android.support.room.util.TableInfo;
import com.android.support.room.util.TableInfo.Column;
+import com.android.support.room.util.TableInfo.ForeignKey;
import java.lang.IllegalStateException;
import java.lang.Override;
import java.lang.String;
import java.util.HashMap;
+import java.util.HashSet;
public class ComplexDatabase_Impl extends ComplexDatabase {
private volatile ComplexDao _complexDao;
@@ -41,7 +43,8 @@
_columnsUser.put("name", new TableInfo.Column("name", "TEXT", 0));
_columnsUser.put("lastName", new TableInfo.Column("lastName", "TEXT", 0));
_columnsUser.put("ageColumn", new TableInfo.Column("ageColumn", "INTEGER", 0));
- final TableInfo _infoUser = new TableInfo("User", _columnsUser);
+ final HashSet<TableInfo.ForeignKey> _foreignKeysUser = new HashSet<TableInfo.ForeignKey>(0);
+ final TableInfo _infoUser = new TableInfo("User", _columnsUser, _foreignKeysUser);
final TableInfo _existingUser = TableInfo.read(_db, "User");
if (! _infoUser.equals(_existingUser)) {
throw new IllegalStateException("Migration didn't properly handle User(foo.bar.User).\n"
diff --git a/room/integration-tests/testapp/schemas/com.android.support.room.integration.testapp.migration.MigrationDb/7.json b/room/integration-tests/testapp/schemas/com.android.support.room.integration.testapp.migration.MigrationDb/7.json
new file mode 100644
index 0000000..33a7d1f
--- /dev/null
+++ b/room/integration-tests/testapp/schemas/com.android.support.room.integration.testapp.migration.MigrationDb/7.json
@@ -0,0 +1,111 @@
+{
+ "formatVersion": 1,
+ "database": {
+ "version": 7,
+ "identityHash": "885b872dd8718be5726ae37479ad74e0",
+ "entities": [
+ {
+ "tableName": "Entity1",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER, `name` TEXT, PRIMARY KEY(`id`))",
+ "fields": [
+ {
+ "fieldPath": "id",
+ "columnName": "id",
+ "affinity": "INTEGER"
+ },
+ {
+ "fieldPath": "name",
+ "columnName": "name",
+ "affinity": "TEXT"
+ }
+ ],
+ "primaryKey": {
+ "columnNames": [
+ "id"
+ ],
+ "autoGenerate": false
+ },
+ "indices": [
+ {
+ "name": "index_Entity1_name",
+ "unique": true,
+ "columnNames": [
+ "name"
+ ],
+ "createSql": "CREATE UNIQUE INDEX `index_Entity1_name` ON `${TABLE_NAME}` (`name`)"
+ }
+ ],
+ "foreignKeys": []
+ },
+ {
+ "tableName": "Entity2",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER, `addedInV3` TEXT, `name` TEXT, PRIMARY KEY(`id`))",
+ "fields": [
+ {
+ "fieldPath": "id",
+ "columnName": "id",
+ "affinity": "INTEGER"
+ },
+ {
+ "fieldPath": "addedInV3",
+ "columnName": "addedInV3",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "name",
+ "columnName": "name",
+ "affinity": "TEXT"
+ }
+ ],
+ "primaryKey": {
+ "columnNames": [
+ "id"
+ ],
+ "autoGenerate": false
+ },
+ "indices": [],
+ "foreignKeys": []
+ },
+ {
+ "tableName": "Entity4",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER, `name` TEXT, PRIMARY KEY(`id`), FOREIGN KEY(`name`) REFERENCES `Entity1`(`name`) ON UPDATE NO ACTION ON DELETE NO ACTION DEFERRABLE INITIALLY DEFERRED)",
+ "fields": [
+ {
+ "fieldPath": "id",
+ "columnName": "id",
+ "affinity": "INTEGER"
+ },
+ {
+ "fieldPath": "name",
+ "columnName": "name",
+ "affinity": "TEXT"
+ }
+ ],
+ "primaryKey": {
+ "columnNames": [
+ "id"
+ ],
+ "autoGenerate": false
+ },
+ "indices": [],
+ "foreignKeys": [
+ {
+ "table": "Entity1",
+ "onDelete": "NO ACTION",
+ "onUpdate": "NO ACTION",
+ "columns": [
+ "name"
+ ],
+ "referencedColumns": [
+ "name"
+ ]
+ }
+ ]
+ }
+ ],
+ "setupQueries": [
+ "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
+ "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, \"885b872dd8718be5726ae37479ad74e0\")"
+ ]
+ }
+}
\ No newline at end of file
diff --git a/room/integration-tests/testapp/src/androidTest/java/com/android/support/room/integration/testapp/migration/MigrationDb.java b/room/integration-tests/testapp/src/androidTest/java/com/android/support/room/integration/testapp/migration/MigrationDb.java
index 43ee591..f3a68b9 100644
--- a/room/integration-tests/testapp/src/androidTest/java/com/android/support/room/integration/testapp/migration/MigrationDb.java
+++ b/room/integration-tests/testapp/src/androidTest/java/com/android/support/room/integration/testapp/migration/MigrationDb.java
@@ -22,7 +22,9 @@
import com.android.support.room.Dao;
import com.android.support.room.Database;
import com.android.support.room.Entity;
+import com.android.support.room.ForeignKey;
import com.android.support.room.Ignore;
+import com.android.support.room.Index;
import com.android.support.room.Insert;
import com.android.support.room.PrimaryKey;
import com.android.support.room.Query;
@@ -32,11 +34,12 @@
@SuppressWarnings("WeakerAccess")
@Database(version = MigrationDb.LATEST_VERSION,
- entities = {MigrationDb.Entity1.class, MigrationDb.Entity2.class})
+ entities = {MigrationDb.Entity1.class, MigrationDb.Entity2.class,
+ MigrationDb.Entity4.class})
public abstract class MigrationDb extends RoomDatabase {
- static final int LATEST_VERSION = 6;
+ static final int LATEST_VERSION = 7;
abstract MigrationDao dao();
- @Entity
+ @Entity(indices = {@Index(value = "name", unique = true)})
static class Entity1 {
public static final String TABLE_NAME = "Entity1";
@PrimaryKey
@@ -63,6 +66,18 @@
public String name;
}
+ @Entity(foreignKeys = {
+ @ForeignKey(entity = Entity1.class,
+ parentColumns = "name",
+ childColumns = "name",
+ deferred = true)})
+ static class Entity4 {
+ public static final String TABLE_NAME = "Entity4";
+ @PrimaryKey
+ public int id;
+ public String name;
+ }
+
@Dao
interface MigrationDao {
@Query("SELECT * from Entity1 ORDER BY id ASC")
diff --git a/room/integration-tests/testapp/src/androidTest/java/com/android/support/room/integration/testapp/migration/MigrationTest.java b/room/integration-tests/testapp/src/androidTest/java/com/android/support/room/integration/testapp/migration/MigrationTest.java
index 32a365a..ba77c85 100644
--- a/room/integration-tests/testapp/src/androidTest/java/com/android/support/room/integration/testapp/migration/MigrationTest.java
+++ b/room/integration-tests/testapp/src/androidTest/java/com/android/support/room/integration/testapp/migration/MigrationTest.java
@@ -16,6 +16,7 @@
package com.android.support.room.integration.testapp.migration;
+import static org.hamcrest.CoreMatchers.containsString;
import static org.hamcrest.CoreMatchers.instanceOf;
import static org.hamcrest.CoreMatchers.is;
import static org.hamcrest.CoreMatchers.nullValue;
@@ -175,6 +176,37 @@
assertThat(info.columns.size(), is(2));
}
+ @Test
+ public void failedForeignKey() throws IOException {
+ final SupportSQLiteDatabase db = helper.createDatabase(TEST_DB, 6);
+ db.close();
+ Throwable throwable = null;
+ try {
+ helper.runMigrationsAndValidate(TEST_DB,
+ 7, false, new Migration(6, 7) {
+ @Override
+ public void migrate(SupportSQLiteDatabase database) {
+ database.execSQL("CREATE TABLE Entity4 (`id` INTEGER, `name` TEXT,"
+ + " PRIMARY KEY(`id`))");
+ }
+ });
+ } catch (Throwable t) {
+ throwable = t;
+ }
+ assertThat(throwable, instanceOf(IllegalStateException.class));
+ //noinspection ConstantConditions
+ assertThat(throwable.getMessage(), containsString("Migration failed"));
+ }
+
+ @Test
+ public void newTableWithForeignKey() throws IOException {
+ helper.createDatabase(TEST_DB, 6);
+ final SupportSQLiteDatabase db = helper.runMigrationsAndValidate(TEST_DB,
+ 7, false, MIGRATION_6_7);
+ final TableInfo info = TableInfo.read(db, MigrationDb.Entity4.TABLE_NAME);
+ assertThat(info.foreignKeys.size(), is(1));
+ }
+
private void testFailure(int startVersion, int endVersion) throws IOException {
final SupportSQLiteDatabase db = helper.createDatabase(TEST_DB, startVersion);
db.close();
@@ -186,6 +218,7 @@
throwable = t;
}
assertThat(throwable, instanceOf(IllegalStateException.class));
+ assertThat(throwable.getMessage(), containsString("Migration failed"));
}
static final Migration MIGRATION_1_2 = new Migration(1, 2) {
@@ -231,8 +264,18 @@
}
};
+ static final Migration MIGRATION_6_7 = new Migration(6, 7) {
+ @Override
+ public void migrate(SupportSQLiteDatabase database) {
+ database.execSQL("CREATE TABLE IF NOT EXISTS " + MigrationDb.Entity4.TABLE_NAME
+ + " (`id` INTEGER, `name` TEXT, PRIMARY KEY(`id`),"
+ + " FOREIGN KEY(`name`) REFERENCES `Entity1`(`name`)"
+ + " ON UPDATE NO ACTION ON DELETE NO ACTION DEFERRABLE INITIALLY DEFERRED)");
+ }
+ };
+
private static final Migration[] ALL_MIGRATIONS = new Migration[]{MIGRATION_1_2,
- MIGRATION_2_3, MIGRATION_3_4, MIGRATION_4_5, MIGRATION_5_6};
+ MIGRATION_2_3, MIGRATION_3_4, MIGRATION_4_5, MIGRATION_5_6, MIGRATION_6_7};
static final class EmptyMigration extends Migration {
EmptyMigration(int startVersion, int endVersion) {
diff --git a/room/migration/src/main/java/com/android/support/room/migration/bundle/EntityBundle.java b/room/migration/src/main/java/com/android/support/room/migration/bundle/EntityBundle.java
index d32f9a8..2505557 100644
--- a/room/migration/src/main/java/com/android/support/room/migration/bundle/EntityBundle.java
+++ b/room/migration/src/main/java/com/android/support/room/migration/bundle/EntityBundle.java
@@ -49,6 +49,8 @@
private PrimaryKeyBundle mPrimaryKey;
@SerializedName("indices")
private List<IndexBundle> mIndices;
+ @SerializedName("foreignKeys")
+ private List<ForeignKeyBundle> mForeignKeys;
private transient String mNewTableName;
private transient Map<String, FieldBundle> mFieldsByColumnName;
@@ -60,17 +62,20 @@
* @param createSql Create query with the table name placeholder.
* @param fields The list of fields.
* @param primaryKey The primary key.
- * @param indices The list of indices.
+ * @param indices The list of indices
+ * @param foreignKeys The list of foreign keys
*/
public EntityBundle(String tableName, String createSql,
List<FieldBundle> fields,
PrimaryKeyBundle primaryKey,
- List<IndexBundle> indices) {
+ List<IndexBundle> indices,
+ List<ForeignKeyBundle> foreignKeys) {
mTableName = tableName;
mCreateSql = createSql;
mFields = fields;
mPrimaryKey = primaryKey;
mIndices = indices;
+ mForeignKeys = foreignKeys;
}
/**
@@ -132,6 +137,13 @@
}
/**
+ * @return List of foreign keys.
+ */
+ public List<ForeignKeyBundle> getForeignKeys() {
+ return mForeignKeys;
+ }
+
+ /**
* @return Create table SQL query that uses the actual table name.
*/
public String createTable() {
diff --git a/room/migration/src/main/java/com/android/support/room/migration/bundle/ForeignKeyBundle.java b/room/migration/src/main/java/com/android/support/room/migration/bundle/ForeignKeyBundle.java
new file mode 100644
index 0000000..8f7e52c
--- /dev/null
+++ b/room/migration/src/main/java/com/android/support/room/migration/bundle/ForeignKeyBundle.java
@@ -0,0 +1,100 @@
+/*
+ * Copyright (C) 2017 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.support.room.migration.bundle;
+
+import com.google.gson.annotations.SerializedName;
+
+import java.util.List;
+
+/**
+ * Holds the information about a foreign key reference.
+ */
+public class ForeignKeyBundle {
+ @SerializedName("table")
+ private String mTable;
+ @SerializedName("onDelete")
+ private String mOnDelete;
+ @SerializedName("onUpdate")
+ private String mOnUpdate;
+ @SerializedName("columns")
+ private List<String> mColumns;
+ @SerializedName("referencedColumns")
+ private List<String> mReferencedColumns;
+
+ /**
+ * Creates a foreign key bundle with the given parameters.
+ *
+ * @param table The target table
+ * @param onDelete OnDelete action
+ * @param onUpdate OnUpdate action
+ * @param columns The list of columns in the current table
+ * @param referencedColumns The list of columns in the referenced table
+ */
+ public ForeignKeyBundle(String table, String onDelete, String onUpdate,
+ List<String> columns, List<String> referencedColumns) {
+ mTable = table;
+ mOnDelete = onDelete;
+ mOnUpdate = onUpdate;
+ mColumns = columns;
+ mReferencedColumns = referencedColumns;
+ }
+
+ /**
+ * Returns the table name
+ *
+ * @return Returns the table name
+ */
+ public String getTable() {
+ return mTable;
+ }
+
+ /**
+ * Returns the SQLite foreign key action that will be performed when referenced row is deleted.
+ *
+ * @return The SQLite on delete action
+ */
+ public String getOnDelete() {
+ return mOnDelete;
+ }
+
+ /**
+ * Returns the SQLite foreign key action that will be performed when referenced row is updated.
+ *
+ * @return The SQLite on update action
+ */
+ public String getOnUpdate() {
+ return mOnUpdate;
+ }
+
+ /**
+ * Returns the ordered list of columns in the current table.
+ *
+ * @return The list of columns in the current entity.
+ */
+ public List<String> getColumns() {
+ return mColumns;
+ }
+
+ /**
+ * Returns the ordered list of columns in the referenced table.
+ *
+ * @return The list of columns in the referenced entity.
+ */
+ public List<String> getReferencedColumns() {
+ return mReferencedColumns;
+ }
+}
diff --git a/room/runtime/src/androidTest/java/com/android/support/room/migration/TableInfoTest.java b/room/runtime/src/androidTest/java/com/android/support/room/migration/TableInfoTest.java
index 35e2a21..a77e5ab 100644
--- a/room/runtime/src/androidTest/java/com/android/support/room/migration/TableInfoTest.java
+++ b/room/runtime/src/androidTest/java/com/android/support/room/migration/TableInfoTest.java
@@ -17,9 +17,13 @@
package com.android.support.room.migration;
+import static org.hamcrest.CoreMatchers.equalTo;
import static org.hamcrest.CoreMatchers.is;
import static org.hamcrest.MatcherAssert.assertThat;
+import static java.util.Arrays.asList;
+import static java.util.Collections.singletonList;
+
import android.support.test.InstrumentationRegistry;
import android.support.test.filters.SmallTest;
import android.support.test.runner.AndroidJUnit4;
@@ -34,8 +38,11 @@
import org.junit.runner.RunWith;
import java.io.IOException;
+import java.util.Collections;
import java.util.HashMap;
+import java.util.HashSet;
import java.util.Map;
+import java.util.Set;
@SuppressWarnings("ArraysAsListWithZeroOrOneArgument")
@RunWith(AndroidJUnit4.class)
@@ -51,7 +58,8 @@
TableInfo info = TableInfo.read(mDb, "foo");
assertThat(info, is(new TableInfo("foo",
toMap(new TableInfo.Column("id", "INTEGER", 1),
- new TableInfo.Column("name", "TEXT", 0)))));
+ new TableInfo.Column("name", "TEXT", 0)),
+ Collections.<TableInfo.ForeignKey>emptySet())));
}
@Test
@@ -62,8 +70,8 @@
TableInfo info = TableInfo.read(mDb, "foo");
assertThat(info, is(new TableInfo("foo",
toMap(new TableInfo.Column("id", "INTEGER", 2),
- new TableInfo.Column("name", "TEXT", 1))
- )));
+ new TableInfo.Column("name", "TEXT", 1)),
+ Collections.<TableInfo.ForeignKey>emptySet())));
}
@Test
@@ -76,8 +84,8 @@
assertThat(info, is(new TableInfo("foo",
toMap(new TableInfo.Column("id", "INTEGER", 0),
new TableInfo.Column("name", "TEXT", 1),
- new TableInfo.Column("added", "REAL", 0))
- )));
+ new TableInfo.Column("added", "REAL", 0)),
+ Collections.<TableInfo.ForeignKey>emptySet())));
}
@Test
@@ -86,8 +94,8 @@
"CREATE TABLE foo (name TEXT NOT NULL)");
TableInfo info = TableInfo.read(mDb, "foo");
assertThat(info, is(new TableInfo("foo",
- toMap(new TableInfo.Column("name", "TEXT", 0))
- )));
+ toMap(new TableInfo.Column("name", "TEXT", 0)),
+ Collections.<TableInfo.ForeignKey>emptySet())));
}
@Test
@@ -97,8 +105,68 @@
TableInfo info = TableInfo.read(mDb, "foo");
assertThat(info, is(new TableInfo(
"foo",
- toMap(new TableInfo.Column("name", "TEXT", 0))
- )));
+ toMap(new TableInfo.Column("name", "TEXT", 0)),
+ Collections.<TableInfo.ForeignKey>emptySet())));
+ }
+
+ @Test
+ public void foreignKey() {
+ mDb = createDatabase(
+ "CREATE TABLE foo (name TEXT)",
+ "CREATE TABLE bar(barName TEXT, FOREIGN KEY(barName) REFERENCES foo(name))"
+ );
+ TableInfo info = TableInfo.read(mDb, "bar");
+ assertThat(info.foreignKeys.size(), is(1));
+ final TableInfo.ForeignKey foreignKey = info.foreignKeys.iterator().next();
+ assertThat(foreignKey.columnNames, is(singletonList("barName")));
+ assertThat(foreignKey.referenceColumnNames, is(singletonList("name")));
+ assertThat(foreignKey.onDelete, is("NO ACTION"));
+ assertThat(foreignKey.onUpdate, is("NO ACTION"));
+ assertThat(foreignKey.referenceTable, is("foo"));
+ }
+
+ @Test
+ public void multipleForeignKeys() {
+ mDb = createDatabase(
+ "CREATE TABLE foo (name TEXT, lastName TEXT)",
+ "CREATE TABLE foo2 (name TEXT, lastName TEXT)",
+ "CREATE TABLE bar(barName TEXT, barLastName TEXT, "
+ + " FOREIGN KEY(barName) REFERENCES foo(name) ON UPDATE SET NULL,"
+ + " FOREIGN KEY(barLastName) REFERENCES foo2(lastName) ON DELETE CASCADE)");
+ TableInfo info = TableInfo.read(mDb, "bar");
+ assertThat(info.foreignKeys.size(), is(2));
+ Set<TableInfo.ForeignKey> expected = new HashSet<>();
+ expected.add(new TableInfo.ForeignKey("foo2", // table
+ "CASCADE", // on delete
+ "NO ACTION", // on update
+ singletonList("barLastName"), // my
+ singletonList("lastName")) // ref
+ );
+ expected.add(new TableInfo.ForeignKey("foo", // table
+ "NO ACTION", // on delete
+ "SET NULL", // on update
+ singletonList("barName"), // mine
+ singletonList("name")/*ref*/));
+ assertThat(info.foreignKeys, equalTo(expected));
+ }
+
+ @Test
+ public void compositeForeignKey() {
+ mDb = createDatabase(
+ "CREATE TABLE foo (name TEXT, lastName TEXT)",
+ "CREATE TABLE bar(barName TEXT, barLastName TEXT, "
+ + " FOREIGN KEY(barName, barLastName) REFERENCES foo(name, lastName)"
+ + " ON UPDATE cascade ON DELETE RESTRICT)");
+ TableInfo info = TableInfo.read(mDb, "bar");
+ assertThat(info.foreignKeys.size(), is(1));
+ TableInfo.ForeignKey expected = new TableInfo.ForeignKey(
+ "foo", // table
+ "RESTRICT", // on delete
+ "CASCADE", // on update
+ asList("barName", "barLastName"), // my columns
+ asList("name", "lastName") // ref columns
+ );
+ assertThat(info.foreignKeys.iterator().next(), is(expected));
}
private static Map<String, TableInfo.Column> toMap(TableInfo.Column... columns) {
diff --git a/room/runtime/src/main/java/com/android/support/room/util/TableInfo.java b/room/runtime/src/main/java/com/android/support/room/util/TableInfo.java
index ba18998..1f50d37 100644
--- a/room/runtime/src/main/java/com/android/support/room/util/TableInfo.java
+++ b/room/runtime/src/main/java/com/android/support/room/util/TableInfo.java
@@ -17,13 +17,18 @@
package com.android.support.room.util;
import android.database.Cursor;
+import android.support.annotation.NonNull;
import android.support.annotation.RestrictTo;
import com.android.support.db.SupportSQLiteDatabase;
+import java.util.ArrayList;
import java.util.Collections;
import java.util.HashMap;
+import java.util.HashSet;
+import java.util.List;
import java.util.Map;
+import java.util.Set;
/**
* A data class that holds the information about a table.
@@ -33,10 +38,11 @@
* documentation for more details.
* <p>
* Even though SQLite column names are case insensitive, this class uses case sensitive matching.
+ *
* @hide
*/
@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
-@SuppressWarnings({"WeakerAccess", "unused"})
+@SuppressWarnings({"WeakerAccess", "unused", "TryFinallyCanBeTryWithResources"})
// if you change this class, you must change TableInfoWriter.kt
public class TableInfo {
/**
@@ -48,10 +54,13 @@
*/
public final Map<String, Column> columns;
+ public final Set<ForeignKey> foreignKeys;
+
@SuppressWarnings("unused")
- public TableInfo(String name, Map<String, Column> columns) {
+ public TableInfo(String name, Map<String, Column> columns, Set<ForeignKey> foreignKeys) {
this.name = name;
this.columns = Collections.unmodifiableMap(columns);
+ this.foreignKeys = Collections.unmodifiableSet(foreignKeys);
}
/**
@@ -63,30 +72,96 @@
*/
@SuppressWarnings("SameParameterValue")
public static TableInfo read(SupportSQLiteDatabase database, String tableName) {
- Cursor cursor = database.rawQuery("PRAGMA table_info(`" + tableName + "`)",
+ Map<String, Column> columns = readColumns(database, tableName);
+ Set<ForeignKey> foreignKeys = readForeignKeys(database, tableName);
+ return new TableInfo(tableName, columns, foreignKeys);
+ }
+
+ private static Set<ForeignKey> readForeignKeys(SupportSQLiteDatabase database,
+ String tableName) {
+ Set<ForeignKey> foreignKeys = new HashSet<>();
+ // this seems to return everything in order but it is not documented so better be safe
+ Cursor cursor = database.rawQuery("PRAGMA foreign_key_list(`" + tableName + "`)",
StringUtil.EMPTY_STRING_ARRAY);
- //noinspection TryFinallyCanBeTryWithResources
try {
- Map<String, Column> columns = extractColumns(cursor);
- return new TableInfo(tableName, columns);
+ final int idColumnIndex = cursor.getColumnIndex("id");
+ final int seqColumnIndex = cursor.getColumnIndex("seq");
+ final int tableColumnIndex = cursor.getColumnIndex("table");
+ final int onDeleteColumnIndex = cursor.getColumnIndex("on_delete");
+ final int onUpdateColumnIndex = cursor.getColumnIndex("on_update");
+
+ final List<ForeignKeyWithSequence> ordered = readForeignKeyFieldMappings(cursor);
+ final int count = cursor.getCount();
+ for (int position = 0; position < count; position++) {
+ cursor.moveToPosition(position);
+ final int seq = cursor.getInt(seqColumnIndex);
+ if (seq != 0) {
+ continue;
+ }
+ final int id = cursor.getInt(idColumnIndex);
+ List<String> myColumns = new ArrayList<>();
+ List<String> refColumns = new ArrayList<>();
+ for (ForeignKeyWithSequence key : ordered) {
+ if (key.mId == id) {
+ myColumns.add(key.mFrom);
+ refColumns.add(key.mTo);
+ }
+ }
+ foreignKeys.add(new ForeignKey(
+ cursor.getString(tableColumnIndex),
+ cursor.getString(onDeleteColumnIndex),
+ cursor.getString(onUpdateColumnIndex),
+ myColumns,
+ refColumns
+ ));
+ }
} finally {
cursor.close();
}
+ return foreignKeys;
}
- private static Map<String, Column> extractColumns(Cursor cursor) {
- Map<String, Column> columns = new HashMap<>();
- if (cursor.getColumnCount() > 0) {
- int nameIndex = cursor.getColumnIndex("name");
- int typeIndex = cursor.getColumnIndex("type");
- int pkIndex = cursor.getColumnIndex("pk");
+ private static List<ForeignKeyWithSequence> readForeignKeyFieldMappings(Cursor cursor) {
+ final int idColumnIndex = cursor.getColumnIndex("id");
+ final int seqColumnIndex = cursor.getColumnIndex("seq");
+ final int fromColumnIndex = cursor.getColumnIndex("from");
+ final int toColumnIndex = cursor.getColumnIndex("to");
+ final int count = cursor.getCount();
+ List<ForeignKeyWithSequence> result = new ArrayList<>();
+ for (int i = 0; i < count; i++) {
+ cursor.moveToPosition(i);
+ result.add(new ForeignKeyWithSequence(
+ cursor.getInt(idColumnIndex),
+ cursor.getInt(seqColumnIndex),
+ cursor.getString(fromColumnIndex),
+ cursor.getString(toColumnIndex)
+ ));
+ }
+ Collections.sort(result);
+ return result;
+ }
- while (cursor.moveToNext()) {
- final String name = cursor.getString(nameIndex);
- final String type = cursor.getString(typeIndex);
- final int primaryKeyPosition = cursor.getInt(pkIndex);
- columns.put(name, new Column(name, type, primaryKeyPosition));
+ private static Map<String, Column> readColumns(SupportSQLiteDatabase database,
+ String tableName) {
+ Cursor cursor = database.rawQuery("PRAGMA table_info(`" + tableName + "`)",
+ StringUtil.EMPTY_STRING_ARRAY);
+ //noinspection TryFinallyCanBeTryWithResources
+ Map<String, Column> columns = new HashMap<>();
+ try {
+ if (cursor.getColumnCount() > 0) {
+ int nameIndex = cursor.getColumnIndex("name");
+ int typeIndex = cursor.getColumnIndex("type");
+ int pkIndex = cursor.getColumnIndex("pk");
+
+ while (cursor.moveToNext()) {
+ final String name = cursor.getString(nameIndex);
+ final String type = cursor.getString(typeIndex);
+ final int primaryKeyPosition = cursor.getInt(pkIndex);
+ columns.put(name, new Column(name, type, primaryKeyPosition));
+ }
}
+ } finally {
+ cursor.close();
}
return columns;
}
@@ -98,21 +173,27 @@
TableInfo tableInfo = (TableInfo) o;
+ if (!name.equals(tableInfo.name)) return false;
//noinspection SimplifiableIfStatement
- if (name != null ? !name.equals(tableInfo.name) : tableInfo.name != null) return false;
- return columns != null ? columns.equals(tableInfo.columns) : tableInfo.columns == null;
+ if (!columns.equals(tableInfo.columns)) return false;
+ return foreignKeys.equals(tableInfo.foreignKeys);
}
@Override
public int hashCode() {
- int result = name != null ? name.hashCode() : 0;
- result = 31 * result + (columns != null ? columns.hashCode() : 0);
+ int result = name.hashCode();
+ result = 31 * result + columns.hashCode();
+ result = 31 * result + foreignKeys.hashCode();
return result;
}
@Override
public String toString() {
- return "TableInfo{name='" + name + '\'' + ", columns=" + columns + '}';
+ return "TableInfo{"
+ + "name='" + name + '\''
+ + ", columns=" + columns
+ + ", foreignKeys=" + foreignKeys
+ + '}';
}
/**
@@ -171,4 +252,99 @@
+ '}';
}
}
+
+ /**
+ * Holds the information about an SQLite foreign key
+ *
+ * @hide
+ */
+ @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
+ public static class ForeignKey {
+ @NonNull
+ public final String referenceTable;
+ @NonNull
+ public final String onDelete;
+ @NonNull
+ public final String onUpdate;
+ @NonNull
+ public final List<String> columnNames;
+ @NonNull
+ public final List<String> referenceColumnNames;
+
+ public ForeignKey(@NonNull String referenceTable, @NonNull String onDelete,
+ @NonNull String onUpdate,
+ @NonNull List<String> columnNames, @NonNull List<String> referenceColumnNames) {
+ this.referenceTable = referenceTable;
+ this.onDelete = onDelete;
+ this.onUpdate = onUpdate;
+ this.columnNames = Collections.unmodifiableList(columnNames);
+ this.referenceColumnNames = Collections.unmodifiableList(referenceColumnNames);
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ if (this == o) return true;
+ if (o == null || getClass() != o.getClass()) return false;
+
+ ForeignKey that = (ForeignKey) o;
+
+ if (!referenceTable.equals(that.referenceTable)) return false;
+ if (!onDelete.equals(that.onDelete)) return false;
+ if (!onUpdate.equals(that.onUpdate)) return false;
+ //noinspection SimplifiableIfStatement
+ if (!columnNames.equals(that.columnNames)) return false;
+ return referenceColumnNames.equals(that.referenceColumnNames);
+ }
+
+ @Override
+ public int hashCode() {
+ int result = referenceTable.hashCode();
+ result = 31 * result + onDelete.hashCode();
+ result = 31 * result + onUpdate.hashCode();
+ result = 31 * result + columnNames.hashCode();
+ result = 31 * result + referenceColumnNames.hashCode();
+ return result;
+ }
+
+ @Override
+ public String toString() {
+ return "ForeignKey{"
+ + "referenceTable='" + referenceTable + '\''
+ + ", onDelete='" + onDelete + '\''
+ + ", onUpdate='" + onUpdate + '\''
+ + ", columnNames=" + columnNames
+ + ", referenceColumnNames=" + referenceColumnNames
+ + '}';
+ }
+ }
+
+ /**
+ * Temporary data holder for a foreign key row in the pragma result. We need this to ensure
+ * sorting in the generated foreign key object.
+ * @hide
+ */
+ @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
+ static class ForeignKeyWithSequence implements Comparable<ForeignKeyWithSequence> {
+ final int mId;
+ final int mSequence;
+ final String mFrom;
+ final String mTo;
+
+ ForeignKeyWithSequence(int id, int sequence, String from, String to) {
+ mId = id;
+ mSequence = sequence;
+ mFrom = from;
+ mTo = to;
+ }
+
+ @Override
+ public int compareTo(ForeignKeyWithSequence o) {
+ final int idCmp = mId - o.mId;
+ if (idCmp == 0) {
+ return mSequence - o.mSequence;
+ } else {
+ return idCmp;
+ }
+ }
+ }
}
diff --git a/room/testing/src/main/java/com/android/support/room/testing/MigrationTestHelper.java b/room/testing/src/main/java/com/android/support/room/testing/MigrationTestHelper.java
index cfdecc5..8c1d02d 100644
--- a/room/testing/src/main/java/com/android/support/room/testing/MigrationTestHelper.java
+++ b/room/testing/src/main/java/com/android/support/room/testing/MigrationTestHelper.java
@@ -30,6 +30,7 @@
import com.android.support.room.migration.bundle.DatabaseBundle;
import com.android.support.room.migration.bundle.EntityBundle;
import com.android.support.room.migration.bundle.FieldBundle;
+import com.android.support.room.migration.bundle.ForeignKeyBundle;
import com.android.support.room.migration.bundle.SchemaBundle;
import com.android.support.room.util.TableInfo;
@@ -41,9 +42,12 @@
import java.io.InputStream;
import java.lang.ref.WeakReference;
import java.util.ArrayList;
+import java.util.Collections;
import java.util.HashMap;
+import java.util.HashSet;
import java.util.List;
import java.util.Map;
+import java.util.Set;
/**
* A class that can be used in your Instrumentation tests that can create the database in an
@@ -220,7 +224,22 @@
}
private static TableInfo toTableInfo(EntityBundle entityBundle) {
- return new TableInfo(entityBundle.getTableName(), toColumnMap(entityBundle));
+ return new TableInfo(entityBundle.getTableName(), toColumnMap(entityBundle),
+ toForeignKeys(entityBundle.getForeignKeys()));
+ }
+
+ private static Set<TableInfo.ForeignKey> toForeignKeys(
+ List<ForeignKeyBundle> bundles) {
+ if (bundles == null) {
+ return Collections.emptySet();
+ }
+ Set<TableInfo.ForeignKey> result = new HashSet<>(bundles.size());
+ for (ForeignKeyBundle bundle : bundles) {
+ result.add(new TableInfo.ForeignKey(bundle.getTable(),
+ bundle.getOnDelete(), bundle.getOnUpdate(),
+ bundle.getColumns(), bundle.getReferencedColumns()));
+ }
+ return result;
}
private static Map<String, TableInfo.Column> toColumnMap(EntityBundle entity) {
@@ -283,7 +302,8 @@
while (cursor.moveToNext()) {
final String tableName = cursor.getString(0);
if (!tables.containsKey(tableName)) {
- throw new IllegalStateException("unexpected table " + tableName);
+ throw new IllegalStateException("Migration failed. Unexpected table "
+ + tableName);
}
}
} finally {