1 /* 2 * Copyright (C) 2022 The Android Open Source Project 3 * 4 * Licensed under the Apache License, Version 2.0 (the "License"); 5 * you may not use this file except in compliance with the License. 6 * You may obtain a copy of the License at 7 * 8 * http://www.apache.org/licenses/LICENSE-2.0 9 * 10 * Unless required by applicable law or agreed to in writing, software 11 * distributed under the License is distributed on an "AS IS" BASIS, 12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 * See the License for the specific language governing permissions and 14 * limitations under the License. 15 */ 16 17 package com.android.adservices.data; 18 19 import android.annotation.NonNull; 20 import android.content.Context; 21 import android.database.Cursor; 22 23 import androidx.room.Room; 24 import androidx.room.RoomDatabase; 25 import androidx.room.migration.bundle.EntityBundle; 26 import androidx.room.migration.bundle.FieldBundle; 27 import androidx.room.migration.bundle.SchemaBundle; 28 import androidx.sqlite.db.SupportSQLiteDatabase; 29 import androidx.test.platform.app.InstrumentationRegistry; 30 31 import com.android.adservices.data.adselection.AdSelectionDatabase; 32 import com.android.adservices.data.adselection.SharedStorageDatabase; 33 import com.android.adservices.data.customaudience.CustomAudienceDatabase; 34 import com.android.adservices.data.customaudience.DBCustomAudience; 35 import com.android.adservices.service.common.cache.CacheDatabase; 36 37 import com.google.common.collect.ImmutableList; 38 import com.google.common.collect.ImmutableMap; 39 40 import org.jetbrains.annotations.NotNull; 41 import org.junit.After; 42 import org.junit.Before; 43 import org.junit.Ignore; 44 import org.junit.Test; 45 46 import java.io.IOException; 47 import java.lang.reflect.Field; 48 import java.util.ArrayList; 49 import java.util.Arrays; 50 import java.util.HashMap; 51 import java.util.List; 52 import java.util.Map; 53 import java.util.Objects; 54 import java.util.Optional; 55 import java.util.stream.Collectors; 56 57 /** This UT is a guardrail to schema migration managed by Room. */ 58 public class RoomSchemaMigrationGuardrailTest { 59 // Note that this is not the context of this test, but the different context whose assets folder 60 // is adservices/service-core/schemas 61 private static final Context TARGET_CONTEXT = 62 InstrumentationRegistry.getInstrumentation().getTargetContext(); 63 64 private static final List<Class<? extends RoomDatabase>> DATABASE_CLASSES = 65 Arrays.stream(RoomDatabaseRegistration.class.getDeclaredFields()) 66 .map(Field::getType) 67 .filter(aClass -> aClass.getSuperclass() == RoomDatabase.class) 68 .map(c -> (Class<? extends RoomDatabase>) c) 69 .collect(Collectors.toList()); 70 71 private static final Map<Class<? extends RoomDatabase>, List<Object>> ADDITIONAL_CONVERTOR = 72 ImmutableMap.of( 73 CustomAudienceDatabase.class, 74 ImmutableList.of(new DBCustomAudience.Converters(true, true, true))); 75 76 private static final List<DatabaseWithVersion> BYPASS_DATABASE_VERSIONS_NEW_FIELD_ONLY = 77 ImmutableList.of(new DatabaseWithVersion(CustomAudienceDatabase.class, 2)); 78 private static final List<Class<? extends RoomDatabase>> 79 BYPASS_DATABASE_CLASS_MIGRATION_TEST_ENFORCEMENT = 80 ImmutableList.of(CacheDatabase.class); 81 // TODO(b/318501421): Implement missing tests. 82 private static final List<DatabaseWithVersion> BYPASS_DATABASE_MIGRATION_TEST_FOR_VERSION = 83 ImmutableList.of( 84 new DatabaseWithVersion(CustomAudienceDatabase.class, 1), 85 new DatabaseWithVersion(CustomAudienceDatabase.class, 3), 86 new DatabaseWithVersion(CustomAudienceDatabase.class, 4), 87 new DatabaseWithVersion(AdSelectionDatabase.class, 4), 88 new DatabaseWithVersion(AdSelectionDatabase.class, 5), 89 new DatabaseWithVersion(SharedStorageDatabase.class, 2)); 90 91 private List<String> mErrors; 92 93 @Before setup()94 public void setup() { 95 mErrors = new ArrayList<>(); 96 } 97 98 @After teardown()99 public void teardown() { 100 if (!mErrors.isEmpty()) { 101 throw new RuntimeException( 102 String.format( 103 "Finish validating room databases with error \n%s", 104 String.join("\n", mErrors))); 105 } 106 } 107 108 @Test 109 @Ignore("BugId: 346751316") validateDatabaseMigrationAllowedChanges()110 public void validateDatabaseMigrationAllowedChanges() throws IOException { 111 List<DatabaseWithVersion> databaseClassesWithNewestVersion = 112 validateAndGetDatabaseClassesWithNewestVersionNumber(); 113 for (DatabaseWithVersion databaseWithVersion : databaseClassesWithNewestVersion) { 114 validateClassMatchAndNewFieldOnly(databaseWithVersion); 115 validateDatabaseMigrationTestExists(databaseWithVersion); 116 } 117 } 118 validateDatabaseMigrationTestExists(DatabaseWithVersion databaseWithVersion)119 private void validateDatabaseMigrationTestExists(DatabaseWithVersion databaseWithVersion) { 120 if (databaseWithVersion.mVersion == 1) { 121 return; 122 } 123 if (BYPASS_DATABASE_CLASS_MIGRATION_TEST_ENFORCEMENT.contains( 124 databaseWithVersion.mRoomDatabaseClass)) { 125 return; 126 } 127 128 String databaseClassName = databaseWithVersion.mRoomDatabaseClass.getCanonicalName(); 129 String testClassName = String.format("%sMigrationTest", databaseClassName); 130 try { 131 Class<?> testClass = Class.forName(testClassName); 132 for (int i = 1; i < databaseWithVersion.mVersion; i++) { 133 if (BYPASS_DATABASE_MIGRATION_TEST_FOR_VERSION.contains( 134 new DatabaseWithVersion(databaseWithVersion.mRoomDatabaseClass, i))) { 135 return; 136 } 137 String migrationTestMethodPrefix = String.format("testMigration%dTo%d", i, i + 1); 138 if (!Arrays.stream(testClass.getMethods()) 139 .anyMatch(method -> method.getName().contains(migrationTestMethodPrefix))) { 140 mErrors.add( 141 String.format( 142 "Migration test %s* missing for database %s", 143 migrationTestMethodPrefix, databaseClassName)); 144 } 145 } 146 } catch (ClassNotFoundException classNotFoundException) { 147 mErrors.add( 148 String.format("Database migration test class %s is missing.", testClassName)); 149 } 150 } 151 validateAndGetDatabaseClassesWithNewestVersionNumber()152 private List<DatabaseWithVersion> validateAndGetDatabaseClassesWithNewestVersionNumber() { 153 ImmutableList.Builder<DatabaseWithVersion> result = new ImmutableList.Builder<>(); 154 for (Class<? extends RoomDatabase> clazz : DATABASE_CLASSES) { 155 try { 156 final int newestDatabaseVersion = getNewestDatabaseVersion(clazz); 157 result.add(new DatabaseWithVersion(clazz, newestDatabaseVersion)); 158 } catch (Exception e) { 159 mErrors.add( 160 String.format( 161 "Fail to get database version for %s, with error %s.", 162 clazz.getCanonicalName(), e.getMessage())); 163 } 164 } 165 return result.build(); 166 } 167 getNewestDatabaseVersion(Class<? extends RoomDatabase> database)168 private int getNewestDatabaseVersion(Class<? extends RoomDatabase> database) 169 throws IOException, NoSuchFieldException, IllegalAccessException { 170 return database.getField("DATABASE_VERSION").getInt(null); 171 } 172 validateClassMatchAndNewFieldOnly(DatabaseWithVersion databaseWithVersion)173 private void validateClassMatchAndNewFieldOnly(DatabaseWithVersion databaseWithVersion) { 174 // Custom audience table v1 to v2 is violating the policy. Skip it. 175 if (BYPASS_DATABASE_VERSIONS_NEW_FIELD_ONLY.contains(databaseWithVersion)) { 176 return; 177 } 178 int newestDatabaseVersion = databaseWithVersion.mVersion; 179 Class<? extends RoomDatabase> roomDatabaseClass = databaseWithVersion.mRoomDatabaseClass; 180 if (databaseWithVersion.mVersion == 1) { 181 return; 182 } 183 184 SchemaBundle oldSchemaBundle; 185 SchemaBundle newSchemaBundle; 186 187 // TODO(b/346751316): Rewrite this test using MigrationTestHelper 188 /* 189 try { 190 oldSchemaBundle = loadSchema(roomDatabaseClass, newestDatabaseVersion - 1); 191 newSchemaBundle = loadSchema(roomDatabaseClass, newestDatabaseVersion); 192 } catch (IOException e) { 193 mErrors.add( 194 String.format( 195 "Database %s schema not exported or exported with error.", 196 roomDatabaseClass.getName())); 197 return; 198 } 199 */ 200 201 // TODO(b/346751316): Rewrite this test using MigrationTestHelper 202 Map<String, EntityBundle> oldTables = new HashMap<>(); 203 Map<String, EntityBundle> newTables = new HashMap<>(); 204 205 /* 206 Map<String, EntityBundle> oldTables = 207 oldSchemaBundle.getDatabase().getEntitiesByTableName(); 208 Map<String, EntityBundle> newTables = 209 newSchemaBundle.getDatabase().getEntitiesByTableName(); 210 validateSchemaBundleMatchesSchema(databaseWithVersion.mRoomDatabaseClass, newTables); 211 */ 212 213 // We don't care new table in a new DB version. So iterate through the old version. 214 for (Map.Entry<String, EntityBundle> e : oldTables.entrySet()) { 215 String tableName = e.getKey(); 216 217 // table in old version must show in new. 218 if (!newTables.containsKey(tableName)) { 219 mErrors.add( 220 String.format( 221 "New version DB is missing table %s present in old version", 222 tableName)); 223 continue; 224 } 225 226 EntityBundle oldEntityBundle = e.getValue(); 227 EntityBundle newEntityBundle = newTables.get(tableName); 228 229 for (FieldBundle oldFieldBundle : oldEntityBundle.getFields()) { 230 if (newEntityBundle.getFields().stream().noneMatch(oldFieldBundle::isSchemaEqual)) { 231 mErrors.add( 232 String.format( 233 "Table %s and field %s: Missing field in new version or" 234 + " mismatch field in new and old version.", 235 tableName, oldEntityBundle)); 236 } 237 } 238 } 239 } 240 validateSchemaBundleMatchesSchema( Class<? extends RoomDatabase> database, Map<String, EntityBundle> entityBundleByName)241 private void validateSchemaBundleMatchesSchema( 242 Class<? extends RoomDatabase> database, Map<String, EntityBundle> entityBundleByName) { 243 RoomDatabase inMemoryDatabase = getInMemoryDatabase(database); 244 Map<String, String> tables = 245 getTablesWithCreateSql(inMemoryDatabase.getOpenHelper().getReadableDatabase()); 246 for (Map.Entry<String, String> table : tables.entrySet()) { 247 String name = table.getKey(); 248 String createSqlByClass = table.getValue(); 249 EntityBundle entityBundle = entityBundleByName.getOrDefault(name, null); 250 String createSqlFromBundle = 251 Optional.ofNullable(entityBundle) 252 .map(EntityBundle::getCreateSql) 253 .map( 254 sql -> 255 sql.replace( 256 "IF NOT EXISTS `${TABLE_NAME}`", 257 "`" + name + "`")) 258 .orElse(null); 259 if (!createSqlByClass.equals(createSqlFromBundle)) { 260 mErrors.add( 261 String.format( 262 "Database %s, table %s class definition mismatches the exported" 263 + " schema, did you forget to build package locally?\n" 264 + "\tCreate sql by class: %s\n" 265 + "\tCreate sql by bundle: %s", 266 database.getCanonicalName(), 267 name, 268 createSqlByClass, 269 createSqlFromBundle)); 270 } 271 } 272 if (!tables.keySet().containsAll(entityBundleByName.keySet())) { 273 mErrors.add( 274 String.format( 275 "Database %s, table is deleted and schema json is not updated, did you" 276 + " forget to build package locally?\n" 277 + "\tTables in class are [%s]\n" 278 + "\ttables in json are [%s]. ", 279 database.getCanonicalName(), 280 String.join(",", tables.keySet()), 281 String.join(",", entityBundleByName.keySet()))); 282 } 283 } 284 285 @NotNull getInMemoryDatabase(Class<? extends RoomDatabase> database)286 private static RoomDatabase getInMemoryDatabase(Class<? extends RoomDatabase> database) { 287 RoomDatabase.Builder<? extends RoomDatabase> builder = 288 Room.inMemoryDatabaseBuilder(TARGET_CONTEXT, database); 289 if (ADDITIONAL_CONVERTOR.containsKey(database)) { 290 for (Object convertor : ADDITIONAL_CONVERTOR.get(database)) { 291 builder.addTypeConverter(convertor); 292 } 293 } 294 return builder.build(); 295 } 296 getTablesWithCreateSql(SupportSQLiteDatabase db)297 private static Map<String, String> getTablesWithCreateSql(SupportSQLiteDatabase db) { 298 Cursor c = 299 db.query( 300 "SELECT name,sql FROM sqlite_master WHERE type='table' AND name not in" 301 + " ('room_master_table', 'android_metadata', 'sqlite_sequence')"); 302 c.moveToFirst(); 303 ImmutableMap.Builder<String, String> tables = new ImmutableMap.Builder<>(); 304 do { 305 String name = c.getString(0); 306 String createSql = c.getString(1); 307 tables.put(name, createSql); 308 } while (c.moveToNext()); 309 return tables.build(); 310 } 311 312 /* 313 TODO(b/346751316): Rewrite this test using MigrationTestHelper 314 private SchemaBundle loadSchema(Class<? extends RoomDatabase> database, int version) 315 throws IOException { 316 InputStream input = 317 TARGET_CONTEXT 318 .getAssets() 319 .open(database.getCanonicalName() + "/" + version + ".json"); 320 return SchemaBundle.deserialize(input); 321 } 322 */ 323 324 private static class DatabaseWithVersion { 325 @NonNull private final Class<? extends RoomDatabase> mRoomDatabaseClass; 326 private final int mVersion; 327 DatabaseWithVersion(@onNull Class<? extends RoomDatabase> roomDatabaseClass, int version)328 DatabaseWithVersion(@NonNull Class<? extends RoomDatabase> roomDatabaseClass, int version) { 329 mRoomDatabaseClass = roomDatabaseClass; 330 mVersion = version; 331 } 332 333 @Override equals(Object o)334 public boolean equals(Object o) { 335 if (this == o) return true; 336 if (!(o instanceof DatabaseWithVersion)) return false; 337 DatabaseWithVersion that = (DatabaseWithVersion) o; 338 return mVersion == that.mVersion && mRoomDatabaseClass.equals(that.mRoomDatabaseClass); 339 } 340 341 @Override hashCode()342 public int hashCode() { 343 return Objects.hash(mRoomDatabaseClass, mVersion); 344 } 345 346 @Override toString()347 public String toString() { 348 return "DatabaseWithVersion{" 349 + "mRoomDatabaseClass=" 350 + mRoomDatabaseClass 351 + ", mVersion=" 352 + mVersion 353 + '}'; 354 } 355 } 356 } 357