diff --git a/README.md b/README.md index 6b8a974..ccf5937 100644 --- a/README.md +++ b/README.md @@ -89,6 +89,38 @@ After that, connect your Android device to the same Wi-Fi network as your comput ./gradlew wrapper --gradle-version x.x.x --distribution-type all ``` +### Database Migration 🔀 + +When adding a new database `@Entity` (or modifying an existing one), you need to prepare a database +migration (SQL script) in +[`app/src/main/java/ai/elimu/content_provider/room/db/RoomDb.java`](app/src/main/java/ai/elimu/content_provider/room/db/RoomDb.java). + +Follow these steps: + +1. Add the new/modified `@Entity` to [`app/src/main/java/ai/elimu/content_provider/room/entity/`](app/src/main/java/ai/elimu/content_provider/room/entity/) +1. Add the entity's DAO interface to [`app/src/main/java/ai/elimu/content_provider/room/dao/`](app/src/main/java/ai/elimu/content_provider/room/dao/) +1. Include the DAO interface in [`app/src/main/java/ai/elimu/content_provider/room/db/RoomDb.java`](app/src/main/java/ai/elimu/content_provider/room/db/RoomDb.java) +1. Include the entity in the `entities` section of the `@Database` in [`app/src/main/java/ai/elimu/content_provider/room/db/RoomDb.java`](app/src/main/java/ai/elimu/content_provider/room/db/RoomDb.java) +1. Bump the `@Database` version in [`app/src/main/java/ai/elimu/content_provider/room/db/RoomDb.java`](app/src/main/java/ai/elimu/content_provider/room/db/RoomDb.java) +1. Build the code with `./gradlew clean build` +1. Open the new database schema generated at `app/schemas/ai.elimu.content_provider.room.db.RoomDb/.json` +- Under `entities`, find the matching `tableName` and copy its SQL script from the `createSql` property. +1. Open `RoomDb.java` and add a new method for the latest migration +- Paste the SQL script from the above JSON schema, and replace `${TABLE_NAME}` with the name of the table you created/modified. +- Include the migration in the `getDatabase` method in `RoomDb.java`. +1. To run the database migration, launch the application on your device. + +**Tip #1:** To verify that your database migration ran successfully, look at the Logcat output and +ensure that there are no RoomDb errors: +``` +2023-11-27 11:46:50.662 6124-13233 ai.elimu.c....RoomDb$18 ai.elimu.content_provider.debug I migrate (23 --> 24) +2023-11-27 11:46:50.663 6124-13233 ai.elimu.c....RoomDb$18 ai.elimu.content_provider.debug I sql: CREATE TABLE IF NOT EXISTS `LetterSound` (`revisionNumber` INTEGER NOT NULL, `usageCount` INTEGER, `id` INTEGER, PRIMARY KEY(`id`)) +``` + +**Tip #2:** You can also use Android Studio's _Database Inspector_ to verify that the database +migration succeeded: + +![](https://github.com/elimu-ai/content-provider/assets/1451036/4c462813-bac0-4d4c-9f62-8c4aa12252d9) ## Release 📦 diff --git a/app/schemas/ai.elimu.content_provider.room.db.RoomDb/24.json b/app/schemas/ai.elimu.content_provider.room.db.RoomDb/24.json new file mode 100644 index 0000000..42b5964 --- /dev/null +++ b/app/schemas/ai.elimu.content_provider.room.db.RoomDb/24.json @@ -0,0 +1,629 @@ +{ + "formatVersion": 1, + "database": { + "version": 24, + "identityHash": "272858d2cd99837310e007914ec3306b", + "entities": [ + { + "tableName": "Letter", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`text` TEXT, `diacritic` INTEGER, `revisionNumber` INTEGER NOT NULL, `usageCount` INTEGER, `id` INTEGER, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "text", + "columnName": "text", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "diacritic", + "columnName": "diacritic", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "revisionNumber", + "columnName": "revisionNumber", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "usageCount", + "columnName": "usageCount", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "Sound", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`valueIpa` TEXT, `diacritic` INTEGER, `revisionNumber` INTEGER NOT NULL, `usageCount` INTEGER, `id` INTEGER, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "valueIpa", + "columnName": "valueIpa", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "diacritic", + "columnName": "diacritic", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "revisionNumber", + "columnName": "revisionNumber", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "usageCount", + "columnName": "usageCount", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "LetterSound", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`revisionNumber` INTEGER NOT NULL, `usageCount` INTEGER, `id` INTEGER, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "revisionNumber", + "columnName": "revisionNumber", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "usageCount", + "columnName": "usageCount", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "Word", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`text` TEXT NOT NULL, `wordType` TEXT, `revisionNumber` INTEGER NOT NULL, `usageCount` INTEGER, `id` INTEGER, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "text", + "columnName": "text", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "wordType", + "columnName": "wordType", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "revisionNumber", + "columnName": "revisionNumber", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "usageCount", + "columnName": "usageCount", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "Number", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`value` INTEGER NOT NULL, `symbol` TEXT, `revisionNumber` INTEGER NOT NULL, `usageCount` INTEGER, `id` INTEGER, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "value", + "columnName": "value", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "symbol", + "columnName": "symbol", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "revisionNumber", + "columnName": "revisionNumber", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "usageCount", + "columnName": "usageCount", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "Emoji", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`glyph` TEXT NOT NULL, `unicodeVersion` REAL NOT NULL, `unicodeEmojiVersion` REAL NOT NULL, `revisionNumber` INTEGER NOT NULL, `usageCount` INTEGER, `id` INTEGER, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "glyph", + "columnName": "glyph", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "unicodeVersion", + "columnName": "unicodeVersion", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "unicodeEmojiVersion", + "columnName": "unicodeEmojiVersion", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "revisionNumber", + "columnName": "revisionNumber", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "usageCount", + "columnName": "usageCount", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "Emoji_Word", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`Emoji_id` INTEGER NOT NULL, `words_id` INTEGER NOT NULL, PRIMARY KEY(`Emoji_id`, `words_id`))", + "fields": [ + { + "fieldPath": "Emoji_id", + "columnName": "Emoji_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "words_id", + "columnName": "words_id", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "Emoji_id", + "words_id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "Image", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`title` TEXT NOT NULL, `imageFormat` TEXT NOT NULL, `revisionNumber` INTEGER NOT NULL, `usageCount` INTEGER, `id` INTEGER, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "imageFormat", + "columnName": "imageFormat", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "revisionNumber", + "columnName": "revisionNumber", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "usageCount", + "columnName": "usageCount", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "Image_Word", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`Image_id` INTEGER NOT NULL, `words_id` INTEGER NOT NULL, PRIMARY KEY(`Image_id`, `words_id`))", + "fields": [ + { + "fieldPath": "Image_id", + "columnName": "Image_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "words_id", + "columnName": "words_id", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "Image_id", + "words_id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "Audio", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`title` TEXT NOT NULL, `transcription` TEXT NOT NULL, `audioFormat` TEXT NOT NULL, `revisionNumber` INTEGER NOT NULL, `usageCount` INTEGER, `id` INTEGER, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "transcription", + "columnName": "transcription", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "audioFormat", + "columnName": "audioFormat", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "revisionNumber", + "columnName": "revisionNumber", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "usageCount", + "columnName": "usageCount", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "StoryBook", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`title` TEXT NOT NULL, `description` TEXT, `coverImageId` INTEGER NOT NULL, `readingLevel` TEXT, `revisionNumber` INTEGER NOT NULL, `usageCount` INTEGER, `id` INTEGER, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "coverImageId", + "columnName": "coverImageId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "readingLevel", + "columnName": "readingLevel", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "revisionNumber", + "columnName": "revisionNumber", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "usageCount", + "columnName": "usageCount", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "StoryBookChapter", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`storyBookId` INTEGER NOT NULL, `sortOrder` INTEGER NOT NULL, `imageId` INTEGER NOT NULL, `id` INTEGER, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "storyBookId", + "columnName": "storyBookId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "sortOrder", + "columnName": "sortOrder", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "imageId", + "columnName": "imageId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "StoryBookParagraph", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`storyBookChapterId` INTEGER NOT NULL, `sortOrder` INTEGER NOT NULL, `originalText` TEXT NOT NULL, `id` INTEGER, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "storyBookChapterId", + "columnName": "storyBookChapterId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "sortOrder", + "columnName": "sortOrder", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "originalText", + "columnName": "originalText", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "StoryBookParagraph_Word", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`StoryBookParagraph_id` INTEGER NOT NULL, `words_id` INTEGER NOT NULL, `words_ORDER` INTEGER NOT NULL, PRIMARY KEY(`StoryBookParagraph_id`, `words_ORDER`))", + "fields": [ + { + "fieldPath": "StoryBookParagraph_id", + "columnName": "StoryBookParagraph_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "words_id", + "columnName": "words_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "words_ORDER", + "columnName": "words_ORDER", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "StoryBookParagraph_id", + "words_ORDER" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "Video", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`title` TEXT NOT NULL, `videoFormat` TEXT NOT NULL, `revisionNumber` INTEGER NOT NULL, `usageCount` INTEGER, `id` INTEGER, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "videoFormat", + "columnName": "videoFormat", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "revisionNumber", + "columnName": "revisionNumber", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "usageCount", + "columnName": "usageCount", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + } + ], + "views": [], + "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, '272858d2cd99837310e007914ec3306b')" + ] + } +} \ No newline at end of file diff --git a/app/src/main/java/ai/elimu/content_provider/room/dao/LetterSoundDao.java b/app/src/main/java/ai/elimu/content_provider/room/dao/LetterSoundDao.java new file mode 100644 index 0000000..aa4ef33 --- /dev/null +++ b/app/src/main/java/ai/elimu/content_provider/room/dao/LetterSoundDao.java @@ -0,0 +1,13 @@ +package ai.elimu.content_provider.room.dao; + +import androidx.room.Dao; +import androidx.room.Insert; + +import ai.elimu.content_provider.room.entity.LetterSound; + +@Dao +public interface LetterSoundDao { + + @Insert + void insert(LetterSound letterSound); +} diff --git a/app/src/main/java/ai/elimu/content_provider/room/db/RoomDb.java b/app/src/main/java/ai/elimu/content_provider/room/db/RoomDb.java index 1ae30bc..7562f12 100644 --- a/app/src/main/java/ai/elimu/content_provider/room/db/RoomDb.java +++ b/app/src/main/java/ai/elimu/content_provider/room/db/RoomDb.java @@ -13,6 +13,7 @@ import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; +import ai.elimu.content_provider.room.dao.LetterSoundDao; import ai.elimu.content_provider.room.dao.SoundDao; import ai.elimu.content_provider.room.dao.AudioDao; import ai.elimu.content_provider.room.dao.EmojiDao; @@ -27,6 +28,7 @@ import ai.elimu.content_provider.room.dao.StoryBookParagraph_WordDao; import ai.elimu.content_provider.room.dao.VideoDao; import ai.elimu.content_provider.room.dao.WordDao; +import ai.elimu.content_provider.room.entity.LetterSound; import ai.elimu.content_provider.room.entity.Sound; import ai.elimu.content_provider.room.entity.Audio; import ai.elimu.content_provider.room.entity.Emoji; @@ -42,7 +44,7 @@ import ai.elimu.content_provider.room.entity.Video; import ai.elimu.content_provider.room.entity.Word; -@Database(version = 23, entities = {Letter.class, Sound.class, Word.class, Number.class, Emoji.class, Emoji_Word.class, Image.class, Image_Word.class, Audio.class, StoryBook.class, StoryBookChapter.class, StoryBookParagraph.class, StoryBookParagraph_Word.class, Video.class}) +@Database(version = 24, entities = {Letter.class, Sound.class, LetterSound.class, Word.class, Number.class, Emoji.class, Emoji_Word.class, Image.class, Image_Word.class, Audio.class, StoryBook.class, StoryBookChapter.class, StoryBookParagraph.class, StoryBookParagraph_Word.class, Video.class}) @TypeConverters({Converters.class}) public abstract class RoomDb extends RoomDatabase { @@ -50,6 +52,8 @@ public abstract class RoomDb extends RoomDatabase { public abstract SoundDao soundDao(); + public abstract LetterSoundDao letterSoundDao(); + public abstract WordDao wordDao(); public abstract NumberDao numberDao(); @@ -105,7 +109,8 @@ public static RoomDb getDatabase(final Context context) { MIGRATION_19_20, MIGRATION_20_21, MIGRATION_21_22, - MIGRATION_22_23 + MIGRATION_22_23, + MIGRATION_23_24 ) .build(); } @@ -314,4 +319,15 @@ public void migrate(SupportSQLiteDatabase database) { database.execSQL(sql); } }; + + private static final Migration MIGRATION_23_24 = new Migration(23, 24) { + @Override + public void migrate(SupportSQLiteDatabase database) { + Log.i(getClass().getName(), "migrate (23 --> 24)"); + + String sql = "CREATE TABLE IF NOT EXISTS `LetterSound` (`revisionNumber` INTEGER NOT NULL, `usageCount` INTEGER, `id` INTEGER, PRIMARY KEY(`id`))"; + Log.i(getClass().getName(), "sql: " + sql); + database.execSQL(sql); + } + }; }