From 212b32f6327e413cdba80188fc0ac1c36837b3d8 Mon Sep 17 00:00:00 2001 From: Erik Johansson Date: Wed, 24 Jan 2018 21:39:22 +0100 Subject: [PATCH] Introduce new database version (2) - Change the unique constraint for measurements to include userId in addition to datetime. This way two users can now have measurements with the same date and time (hour and minute). - Enforce that the userId references an existing user. --- .../2.json | 195 ++++++++++++++++++ .../openscale/DatabaseMigrationTest.java | 110 ++++++++++ .../com/health/openscale/core/OpenScale.java | 15 +- .../openscale/core/database/AppDatabase.java | 46 ++++- .../core/datatypes/ScaleMeasurement.java | 9 +- 5 files changed, 372 insertions(+), 3 deletions(-) create mode 100644 android_app/app/schemas/com.health.openscale.core.database.AppDatabase/2.json create mode 100644 android_app/app/src/androidTest/java/com/health/openscale/DatabaseMigrationTest.java diff --git a/android_app/app/schemas/com.health.openscale.core.database.AppDatabase/2.json b/android_app/app/schemas/com.health.openscale.core.database.AppDatabase/2.json new file mode 100644 index 00000000..1b3a5e70 --- /dev/null +++ b/android_app/app/schemas/com.health.openscale.core.database.AppDatabase/2.json @@ -0,0 +1,195 @@ +{ + "formatVersion": 1, + "database": { + "version": 2, + "identityHash": "166a2a83c723c4117edaf1d107ac5194", + "entities": [ + { + "tableName": "scaleMeasurements", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `userId` INTEGER NOT NULL, `enabled` INTEGER NOT NULL, `datetime` INTEGER, `weight` REAL NOT NULL, `fat` REAL NOT NULL, `water` REAL NOT NULL, `muscle` REAL NOT NULL, `lbw` REAL NOT NULL, `waist` REAL NOT NULL, `hip` REAL NOT NULL, `bone` REAL NOT NULL, `comment` TEXT, FOREIGN KEY(`userId`) REFERENCES `scaleUsers`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "enabled", + "columnName": "enabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "dateTime", + "columnName": "datetime", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "weight", + "columnName": "weight", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "fat", + "columnName": "fat", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "water", + "columnName": "water", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "muscle", + "columnName": "muscle", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "lbw", + "columnName": "lbw", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "waist", + "columnName": "waist", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "hip", + "columnName": "hip", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "bone", + "columnName": "bone", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "comment", + "columnName": "comment", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": true + }, + "indices": [ + { + "name": "index_scaleMeasurements_datetime_userId", + "unique": true, + "columnNames": [ + "datetime", + "userId" + ], + "createSql": "CREATE UNIQUE INDEX `index_scaleMeasurements_datetime_userId` ON `${TABLE_NAME}` (`datetime`, `userId`)" + } + ], + "foreignKeys": [ + { + "table": "scaleUsers", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "scaleUsers", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `username` TEXT, `birthday` INTEGER, `bodyHeight` INTEGER NOT NULL, `scaleUnit` INTEGER NOT NULL, `gender` INTEGER NOT NULL, `initialWeight` REAL NOT NULL, `goalWeight` REAL NOT NULL, `goalDate` INTEGER)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "userName", + "columnName": "username", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "birthday", + "columnName": "birthday", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "bodyHeight", + "columnName": "bodyHeight", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "scaleUnit", + "columnName": "scaleUnit", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "gender", + "columnName": "gender", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "initialWeight", + "columnName": "initialWeight", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "goalWeight", + "columnName": "goalWeight", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "goalDate", + "columnName": "goalDate", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": true + }, + "indices": [], + "foreignKeys": [] + } + ], + "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, \"166a2a83c723c4117edaf1d107ac5194\")" + ] + } +} \ No newline at end of file diff --git a/android_app/app/src/androidTest/java/com/health/openscale/DatabaseMigrationTest.java b/android_app/app/src/androidTest/java/com/health/openscale/DatabaseMigrationTest.java new file mode 100644 index 00000000..562c14fc --- /dev/null +++ b/android_app/app/src/androidTest/java/com/health/openscale/DatabaseMigrationTest.java @@ -0,0 +1,110 @@ +/* Copyright (C) 2018 Erik Johansson +* +* This program is free software: you can redistribute it and/or modify +* it under the terms of the GNU General Public License as published by +* the Free Software Foundation, either version 3 of the License, or +* (at your option) any later version. +* +* This program is distributed in the hope that it will be useful, +* but WITHOUT ANY WARRANTY; without even the implied warranty of +* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +* GNU General Public License for more details. +* +* You should have received a copy of the GNU General Public License +* along with this program. If not, see +*/ + +package com.health.openscale; + +import android.arch.persistence.db.SupportSQLiteDatabase; +import android.arch.persistence.db.SupportSQLiteOpenHelper; +import android.arch.persistence.db.framework.FrameworkSQLiteOpenHelperFactory; +import android.arch.persistence.room.testing.MigrationTestHelper; +import android.content.ContentValues; +import android.database.Cursor; +import android.database.sqlite.SQLiteDatabase; +import android.support.test.InstrumentationRegistry; +import android.support.test.runner.AndroidJUnit4; + +import com.health.openscale.core.database.AppDatabase; + +import org.junit.Rule; +import org.junit.Test; +import org.junit.runner.RunWith; + +import static junit.framework.Assert.assertEquals; +import static junit.framework.Assert.assertNotSame; +import static junit.framework.Assert.assertTrue; + +@RunWith(AndroidJUnit4.class) +public class DatabaseMigrationTest { + private static final String TEST_DB = "migration-test"; + + @Rule + public MigrationTestHelper helper; + + public DatabaseMigrationTest() { + helper = new MigrationTestHelper( + InstrumentationRegistry.getInstrumentation(), + AppDatabase.class.getCanonicalName(), + new FrameworkSQLiteOpenHelperFactory()); + } + + @Test + public void migrate1To2() throws Exception { + SupportSQLiteDatabase db = helper.createDatabase(TEST_DB, 1); + + ContentValues users = new ContentValues(); + for (int i = 1; i < 4; ++i) { + users.put("id", i); + users.put("username", String.format("test%d", i)); + users.put("bodyHeight", i * 50); + users.put("scaleUnit", 0); + users.put("gender", 0); + users.put("initialWeight", i * 25); + users.put("goalWeight", i * 20); + assertNotSame(-1, db.insert("scaleUsers", SQLiteDatabase.CONFLICT_ABORT, users)); + } + + ContentValues measurement = new ContentValues(); + for (int i = 2; i < 5; ++i) { + for (int j = 0; j < 2; ++j) { + measurement.put("userId", i); + measurement.put("enabled", j); + measurement.put("comment", "a string"); + for (String type : new String[]{"weight", "fat", "water", "muscle", "lbw", "waist", "hip", "bone"}) { + measurement.put(type, i * j); + } + + assertNotSame(-1, db.insert("scaleMeasurements", SQLiteDatabase.CONFLICT_ABORT, measurement)); + } + } + + // Prepare for the next version. + db.close(); + + // Re-open the database with version 2 and provide MIGRATION_1_2 as the migration process. + db = helper.runMigrationsAndValidate(TEST_DB, 2, true, AppDatabase.MIGRATION_1_2); + + // MigrationTestHelper automatically verifies the schema changes. + + Cursor cursor = db.query("SELECT * FROM scaleMeasurements ORDER BY id, userId"); + assertEquals(2 * 2, cursor.getCount()); + + cursor.moveToFirst(); + for (int i = 2; i < 4; ++i) { + for (int j = 0; j < 2; ++j) { + assertEquals(i, cursor.getInt(cursor.getColumnIndex("userId"))); + assertEquals(j, cursor.getInt(cursor.getColumnIndex("enabled"))); + assertEquals("a string", cursor.getString(cursor.getColumnIndex("comment"))); + for (String type : new String[]{"weight", "fat", "water", "muscle", "lbw", "waist", "hip", "bone"}) { + assertEquals((float) i * j, cursor.getFloat(cursor.getColumnIndex(type))); + } + + cursor.moveToNext(); + } + } + + assertTrue(cursor.isAfterLast()); + } +} diff --git a/android_app/app/src/main/java/com/health/openscale/core/OpenScale.java b/android_app/app/src/main/java/com/health/openscale/core/OpenScale.java index 36d04808..8f7cc229 100644 --- a/android_app/app/src/main/java/com/health/openscale/core/OpenScale.java +++ b/android_app/app/src/main/java/com/health/openscale/core/OpenScale.java @@ -16,11 +16,14 @@ package com.health.openscale.core; +import android.arch.persistence.db.SupportSQLiteDatabase; import android.arch.persistence.room.Room; +import android.arch.persistence.room.RoomDatabase; import android.content.Context; import android.content.SharedPreferences; import android.os.Handler; import android.preference.PreferenceManager; +import android.support.annotation.NonNull; import android.support.v4.app.Fragment; import android.text.format.DateFormat; import android.util.Log; @@ -81,7 +84,17 @@ public class OpenScale { alarmHandler = new AlarmHandler(); btCom = null; fragmentList = new ArrayList<>(); - appDB = Room.databaseBuilder(context, AppDatabase.class, "openScale.db").allowMainThreadQueries().build(); + appDB = Room.databaseBuilder(context, AppDatabase.class, "openScale.db") + .allowMainThreadQueries() + .addCallback(new RoomDatabase.Callback() { + @Override + public void onOpen(@NonNull SupportSQLiteDatabase db) { + super.onOpen(db); + db.setForeignKeyConstraintsEnabled(true); + } + }) + .addMigrations(AppDatabase.MIGRATION_1_2) + .build(); measurementDAO = appDB.measurementDAO(); userDAO = appDB.userDAO(); diff --git a/android_app/app/src/main/java/com/health/openscale/core/database/AppDatabase.java b/android_app/app/src/main/java/com/health/openscale/core/database/AppDatabase.java index c4f68c7b..e6c8b607 100644 --- a/android_app/app/src/main/java/com/health/openscale/core/database/AppDatabase.java +++ b/android_app/app/src/main/java/com/health/openscale/core/database/AppDatabase.java @@ -16,18 +16,62 @@ package com.health.openscale.core.database; +import android.arch.persistence.db.SupportSQLiteDatabase; import android.arch.persistence.room.Database; import android.arch.persistence.room.RoomDatabase; import android.arch.persistence.room.TypeConverters; +import android.arch.persistence.room.migration.Migration; +import android.support.annotation.NonNull; import com.health.openscale.core.datatypes.ScaleMeasurement; import com.health.openscale.core.datatypes.ScaleUser; import com.health.openscale.core.utils.Converters; -@Database(entities = {ScaleMeasurement.class, ScaleUser.class}, version = 1) +@Database(entities = {ScaleMeasurement.class, ScaleUser.class}, version = 2) @TypeConverters({Converters.class}) public abstract class AppDatabase extends RoomDatabase { public abstract ScaleMeasurementDAO measurementDAO(); public abstract ScaleUserDAO userDAO(); + + public static final Migration MIGRATION_1_2 = new Migration(1, 2) { + @Override + public void migrate(@NonNull SupportSQLiteDatabase database) { + database.beginTransaction(); + try { + // Drop old index on datetime only + database.execSQL("DROP INDEX index_scaleMeasurements_datetime"); + + // Rename old table + database.execSQL("ALTER TABLE scaleMeasurements RENAME TO scaleMeasurementsOld"); + + // Create new table with foreign key + database.execSQL("CREATE TABLE scaleMeasurements" + + " (id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL," + + " userId INTEGER NOT NULL, enabled INTEGER NOT NULL," + + " datetime INTEGER, weight REAL NOT NULL, fat REAL NOT NULL," + + " water REAL NOT NULL, muscle REAL NOT NULL, lbw REAL NOT NULL," + + " waist REAL NOT NULL, hip REAL NOT NULL, bone REAL NOT NULL," + + " comment TEXT, FOREIGN KEY(userId) REFERENCES scaleUsers(id)" + + " ON UPDATE NO ACTION ON DELETE CASCADE)"); + + // Create new index on datetime + userId + database.execSQL("CREATE UNIQUE INDEX index_scaleMeasurements_datetime_userId" + + " ON scaleMeasurements (datetime, userId)"); + + // Copy data from the old table, ignoring those with invalid userId (if any) + database.execSQL("INSERT INTO scaleMeasurements" + + " SELECT * FROM scaleMeasurementsOld" + + " WHERE userId IN (SELECT id from scaleUsers)"); + + // Delete old table + database.execSQL("DROP TABLE scaleMeasurementsOld"); + + database.setTransactionSuccessful(); + } + finally { + database.endTransaction(); + } + } + }; } diff --git a/android_app/app/src/main/java/com/health/openscale/core/datatypes/ScaleMeasurement.java b/android_app/app/src/main/java/com/health/openscale/core/datatypes/ScaleMeasurement.java index f797ef90..ce5cb277 100644 --- a/android_app/app/src/main/java/com/health/openscale/core/datatypes/ScaleMeasurement.java +++ b/android_app/app/src/main/java/com/health/openscale/core/datatypes/ScaleMeasurement.java @@ -18,6 +18,7 @@ package com.health.openscale.core.datatypes; import android.arch.persistence.room.ColumnInfo; import android.arch.persistence.room.Entity; +import android.arch.persistence.room.ForeignKey; import android.arch.persistence.room.Index; import android.arch.persistence.room.PrimaryKey; @@ -26,7 +27,13 @@ import com.j256.simplecsv.common.CsvColumn; import java.util.Date; -@Entity(tableName = "scaleMeasurements", indices = {@Index(value = {"datetime"}, unique = true)}) +@Entity(tableName = "scaleMeasurements", + indices = {@Index(value = {"datetime", "userId"}, unique = true)}, + foreignKeys = @ForeignKey( + entity = ScaleUser.class, + parentColumns = "id", + childColumns = "userId", + onDelete = ForeignKey.CASCADE)) public class ScaleMeasurement implements Cloneable { @PrimaryKey(autoGenerate = true)