Skip to content

Commit

Permalink
Added automatic migration, tests for automatic migration. It will be …
Browse files Browse the repository at this point in the history
…applied when database version is lower than specified database version after executing migrations from files.
  • Loading branch information
dkostyrev committed Nov 21, 2014
1 parent 08c6335 commit e1a2ed7
Show file tree
Hide file tree
Showing 16 changed files with 717 additions and 16 deletions.
4 changes: 3 additions & 1 deletion src/com/activeandroid/Cache.java
Original file line number Diff line number Diff line change
Expand Up @@ -107,7 +107,9 @@ public static synchronized SQLiteDatabase openDatabase() {
}

public static synchronized void closeDatabase() {
sDatabaseHelper.close();
if (sDatabaseHelper != null) {
sDatabaseHelper.close();
}
}

// Context access
Expand Down
4 changes: 4 additions & 0 deletions src/com/activeandroid/DatabaseHelper.java
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@
import android.database.sqlite.SQLiteOpenHelper;
import android.text.TextUtils;

import com.activeandroid.automigration.AutoMigration;
import com.activeandroid.util.IOUtils;
import com.activeandroid.util.Log;
import com.activeandroid.util.NaturalOrderComparator;
Expand Down Expand Up @@ -83,6 +84,9 @@ public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) {
executePragmas(db);
executeCreate(db);
executeMigrations(db, oldVersion, newVersion);
if (db.needUpgrade(newVersion)) {
AutoMigration.migrate(db, newVersion);
}
}

//////////////////////////////////////////////////////////////////////////////////////
Expand Down
2 changes: 1 addition & 1 deletion src/com/activeandroid/ModelInfo.java
Original file line number Diff line number Diff line change
Expand Up @@ -30,10 +30,10 @@
import android.content.Context;

import com.activeandroid.serializer.CalendarSerializer;
import com.activeandroid.serializer.FileSerializer;
import com.activeandroid.serializer.SqlDateSerializer;
import com.activeandroid.serializer.TypeSerializer;
import com.activeandroid.serializer.UtilDateSerializer;
import com.activeandroid.serializer.FileSerializer;
import com.activeandroid.util.Log;
import com.activeandroid.util.ReflectionUtils;
import dalvik.system.DexFile;
Expand Down
141 changes: 141 additions & 0 deletions src/com/activeandroid/automigration/AutoMigration.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
package com.activeandroid.automigration;

import java.util.Random;

import android.content.ContentValues;
import android.database.Cursor;
import android.database.sqlite.SQLiteDatabase;

import com.activeandroid.Cache;
import com.activeandroid.TableInfo;
import com.activeandroid.util.Log;
import com.activeandroid.util.SQLiteUtils;
import com.activeandroid.util.SQLiteUtils.SQLiteType;

public class AutoMigration {

public static class IncompatibleColumnTypesException extends RuntimeException {
private static final long serialVersionUID = -6200636421142104030L;

public IncompatibleColumnTypesException(String table, String column, SQLiteType typeA, SQLiteType typeB) {
super("Failed to match column " + column + " type " + typeA + " to " + typeB + " in " + table + " table");
}
}

public static void migrate(SQLiteDatabase db, int newVersion) {
db.beginTransaction();
try {
for (TableInfo tableInfo : Cache.getTableInfos()) {
processTableInfo(db, tableInfo);
}
db.execSQL("PRAGMA user_version = " + newVersion);
Log.v("Automatic migration successfull, schemas updated to version " + newVersion);
db.setTransactionSuccessful();
} finally {
db.endTransaction();
}
}

private static void processTableInfo(SQLiteDatabase db, TableInfo tableInfo) {
SQLTableInfo sqlTableInfo = getSqlTableInfo(db, tableInfo);
if (sqlTableInfo != null) {
TableDifference tableDifference = new TableDifference(tableInfo, sqlTableInfo);
if (tableDifference.isEmpty() == false) {
applyDifference(db, tableDifference);
} else {
Log.v("Table " + tableInfo.getTableName() + " does not have any difference, skipping it");
}
} else {
Log.v("Table " + tableInfo.getTableName() + " does not exist. Creating new");
db.execSQL(SQLiteUtils.createTableDefinition(tableInfo));
}
}

private static void applyDifference(SQLiteDatabase db, TableDifference tableDifference) {
TableInfo tableInfo = tableDifference.getTableInfo();
if (Log.isEnabled()) {
Log.v("Migrating table " + tableInfo.getTableName() +
" from schema '" + tableDifference.getSqlTableInfo().getSchema() +
"' to schema '" + SQLiteUtils.createTableDefinition(tableInfo) + "'");
}

if (tableDifference.isOnlyAdd()) {
Log.v("Table " + tableInfo.getTableName() + " has added columns without primary / unique keys, no existing columns affected");
for (SQLColumnInfo columnInfo : tableDifference.getDifferences().keySet()) {
addColumnToTable(db, tableDifference, columnInfo);
Log.v("Added " + columnInfo.getName() + " column to " + tableInfo.getTableName());
}
} else {
Log.v("Table " + tableInfo.getTableName() + " has modified existing columns, moving data to newly created table");

String temporaryTableName = "TEMP_" + (tableInfo.getTableName() + "_" + new Random().nextInt(1000));
db.execSQL("ALTER TABLE " + tableInfo.getTableName() + " RENAME TO " + temporaryTableName);
Log.v("Renamed " + tableInfo.getTableName() + " to " + temporaryTableName);

db.execSQL(SQLiteUtils.createTableDefinition(tableInfo));
Log.v("Created new table " + tableInfo.getTableName() + " with new schema");

transferColumns(db, temporaryTableName, tableDifference);
Log.v("Rows from temporary table " + temporaryTableName + " transferred to newly created table with new schema " + tableInfo.getTableName());

db.execSQL("DROP TABLE " + temporaryTableName);
Log.v("Dropped temporary table " + temporaryTableName);
}
}

private static void addColumnToTable(SQLiteDatabase db, TableDifference tableDifference, SQLColumnInfo columnInfo) {
db.execSQL("ALTER TABLE " + tableDifference.getTableInfo().getTableName() + " ADD COLUMN " + columnInfo.getColumnDefinition());
}

private static void transferColumns(SQLiteDatabase db, String sourceTable, TableDifference tableDifference) {
Cursor sourceCursor = db.query(sourceTable, null, null, null, null, null, null);
ContentValues contentValues = new ContentValues();
try {

while (sourceCursor.moveToNext()) {
contentValues.clear();
for (SQLColumnInfo columnInfo : tableDifference.getNewSchemaColumnInfos()) {
if (tableDifference.getDifferences().containsKey(columnInfo)) {
SQLColumnInfo mappedColumnInfo = tableDifference.getDifferences().get(columnInfo);
if (mappedColumnInfo != null) {
putValueFromCursor(contentValues, sourceCursor, mappedColumnInfo, columnInfo);
}
} else {
putValueFromCursor(contentValues, sourceCursor, columnInfo, columnInfo);
}
}
db.insert(tableDifference.getTableInfo().getTableName(), null, contentValues);
}
} finally {
sourceCursor.close();
}
}

private static void putValueFromCursor(ContentValues contentValues, Cursor cursor, SQLColumnInfo sourceColumnInfo, SQLColumnInfo targetColumnInfo) {
switch (sourceColumnInfo.getType()) {
case INTEGER:
contentValues.put(targetColumnInfo.getName(), cursor.getInt(cursor.getColumnIndex(sourceColumnInfo.getName())));
break;

case TEXT:
contentValues.put(targetColumnInfo.getName(), cursor.getString(cursor.getColumnIndex(sourceColumnInfo.getName())));
break;

case REAL:
contentValues.put(targetColumnInfo.getName(), cursor.getDouble(cursor.getColumnIndex(sourceColumnInfo.getName())));
break;

case BLOB:
contentValues.put(targetColumnInfo.getName(), cursor.getBlob(cursor.getColumnIndex(sourceColumnInfo.getName())));
break;
}
}

private static SQLTableInfo getSqlTableInfo(SQLiteDatabase db, TableInfo tableInfo) {
Cursor cursor = db.query("sqlite_master", new String[] { "sql" }, "tbl_name = ?", new String[] { tableInfo.getTableName() }, null, null, null);
if (cursor.moveToNext()) {
return new SQLTableInfo(cursor.getString(0));
}
return null;
}
}
53 changes: 53 additions & 0 deletions src/com/activeandroid/automigration/SQLColumnInfo.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
package com.activeandroid.automigration;

import java.util.ArrayList;
import java.util.Locale;

import com.activeandroid.util.SQLiteUtils.SQLiteType;

import android.text.TextUtils;

public class SQLColumnInfo {

private String mColumnDefinition;
private String mName;
private SQLiteType mType;

public SQLColumnInfo(String columnDefinition) {
ArrayList<String> tokens = new ArrayList<String>();
for (String token : columnDefinition.split(" ")) {
if (TextUtils.isEmpty(token) == false)
tokens.add(token);
}

if (tokens.size() < 2)
throw new IllegalArgumentException("Failed to parse '" + columnDefinition + "' as sql column definition");

this.mColumnDefinition = TextUtils.join(" ", tokens.subList(1, tokens.size()));

this.mName = tokens.get(0);
this.mType = SQLiteType.valueOf(tokens.get(1).toUpperCase(Locale.US));

}

public String getName() {
return mName;
}

public SQLiteType getType() {
return mType;
}

public String getColumnDefinition() {
return mName + " " + mColumnDefinition;
}

public boolean isPrimaryKey() {
return mColumnDefinition.toUpperCase(Locale.US).contains("PRIMARY KEY");
}

public boolean isUnique() {
return mColumnDefinition.toUpperCase(Locale.US).contains("UNIQUE");
}

}
75 changes: 75 additions & 0 deletions src/com/activeandroid/automigration/SQLTableInfo.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
package com.activeandroid.automigration;

import java.util.ArrayList;
import java.util.List;
import java.util.Locale;

import android.text.TextUtils;

public final class SQLTableInfo {

public static String constructSchema(String tableName, List<SQLColumnInfo> columns) {
String schema = "CREATE TABLE " + tableName + "(%s);";
List<String> tokens = new ArrayList<String>();
for (SQLColumnInfo column : columns) {
tokens.add(column.getColumnDefinition());
}
return String.format(schema, TextUtils.join(", ", tokens));
}

//Note that this class does not validate SQL syntax

private String mTableName;
private SQLColumnInfo mIdColumnInfo;
private List<SQLColumnInfo> mColumns;
private String mSchema;

public SQLTableInfo(String sqlSchema) {

if (TextUtils.isEmpty(sqlSchema))
throw new IllegalArgumentException("Cannot construct SqlTableInfo from empty sqlSchema");

sqlSchema = sqlSchema.replaceAll("\\s+", " ");
this.mSchema = new String(sqlSchema);

if (!sqlSchema.toUpperCase(Locale.US).startsWith("CREATE TABLE") || !sqlSchema.contains("(") || !sqlSchema.contains(")"))
throw new IllegalArgumentException("sqlSchema doesn't appears to be valid");
mColumns = new ArrayList<SQLColumnInfo>();

sqlSchema = sqlSchema.replaceAll("(?i)CREATE TABLE ", "");
mTableName = sqlSchema.substring(0, sqlSchema.indexOf('(')).replace("\"", "");

String columnDefinitions = sqlSchema.substring(sqlSchema.indexOf('(') + 1, sqlSchema.lastIndexOf(')'));
processColumnsDefinitions(columnDefinitions.split(","));
}

private void processColumnsDefinitions(String[] columns) {
for (String columnDef : columns) {
SQLColumnInfo columnInfo = new SQLColumnInfo(columnDef);
if (columnInfo.isPrimaryKey()) {
if (mIdColumnInfo == null)
mIdColumnInfo = columnInfo;
else
throw new IllegalArgumentException("sqlSchema contains multiple primary keys");
}

mColumns.add(columnInfo);
}
}

public String getSchema() {
return mSchema;
}

public SQLColumnInfo getIdColumnInfo() {
return mIdColumnInfo;
}

public List<SQLColumnInfo> getColumns() {
return mColumns;
}

public String getTableName() {
return mTableName;
}
}
79 changes: 79 additions & 0 deletions src/com/activeandroid/automigration/TableDifference.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
package com.activeandroid.automigration;

import java.lang.reflect.Field;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;

import com.activeandroid.TableInfo;
import com.activeandroid.automigration.AutoMigration.IncompatibleColumnTypesException;
import com.activeandroid.util.SQLiteUtils;

class TableDifference {


private TableInfo mTableInfo;
private SQLTableInfo mSqlTableInfo;
private Map<SQLColumnInfo, SQLColumnInfo> mDifferences;
private List<SQLColumnInfo> mCurrentVersionTableDefinitions;

public TableDifference(TableInfo tableInfo, SQLTableInfo sqlTableInfo) {
this.mTableInfo = tableInfo;
this.mSqlTableInfo = sqlTableInfo;
this.mDifferences = new HashMap<SQLColumnInfo, SQLColumnInfo>();
this.mCurrentVersionTableDefinitions = new ArrayList<SQLColumnInfo>();

for (Field field : tableInfo.getFields()) {
SQLColumnInfo sqlColumnInfo = new SQLColumnInfo(SQLiteUtils.createColumnDefinition(tableInfo, field));
mCurrentVersionTableDefinitions.add(sqlColumnInfo);

boolean found = false;
for (SQLColumnInfo existingColumnInfo : sqlTableInfo.getColumns()) {
if (existingColumnInfo.getName().equalsIgnoreCase(sqlColumnInfo.getName()) == false)
continue;

found = true;

if (existingColumnInfo.getColumnDefinition().equalsIgnoreCase(sqlColumnInfo.getColumnDefinition()) == false) {
if (existingColumnInfo.getType() == sqlColumnInfo.getType()) {
mDifferences.put(sqlColumnInfo, existingColumnInfo);
} else {
throw new IncompatibleColumnTypesException(tableInfo.getTableName(), existingColumnInfo.getName(), existingColumnInfo.getType(), sqlColumnInfo.getType());
}
}
break;
}
if (!found)
mDifferences.put(sqlColumnInfo, null);
}
}

public boolean isOnlyAdd() {
for (SQLColumnInfo sqlColumnInfo : mDifferences.keySet()) {
if (mDifferences.get(sqlColumnInfo) != null || sqlColumnInfo.isPrimaryKey() || sqlColumnInfo.isUnique())
return false;
}
return true;
}

public boolean isEmpty() {
return mDifferences.size() == 0;
}

public Map<SQLColumnInfo, SQLColumnInfo> getDifferences() {
return mDifferences;
}

public List<SQLColumnInfo> getNewSchemaColumnInfos() {
return mCurrentVersionTableDefinitions;
}

public TableInfo getTableInfo() {
return mTableInfo;
}

public SQLTableInfo getSqlTableInfo() {
return mSqlTableInfo;
}
}
Binary file added tests/libs/android-support-v4.jar
Binary file not shown.
Loading

0 comments on commit e1a2ed7

Please sign in to comment.