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