/****************************************************************************************
* Copyright (c) 2009 Daniel Svärd <daniel.svard@gmail.com> *
* Copyright (c) 2009 Nicolas Raoul <nicolas.raoul@gmail.com> *
* Copyright (c) 2009 Andrew <andrewdubya@gmail.com> *
* Copyright (c) 2011 Norbert Nagold <norbert.nagold@gmail.com> *
* *
* 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 <http://www.gnu.org/licenses/>. *
****************************************************************************************/
package com.ichi2.libanki;
import android.annotation.TargetApi;
import android.content.ContentValues;
import android.database.Cursor;
import android.database.SQLException;
import android.os.Build;
import android.widget.Toast;
import com.ichi2.anki.AnkiDroidApp;
import com.ichi2.anki.CollectionHelper;
import com.ichi2.anki.dialogs.DatabaseErrorDialog;
import com.ichi2.compat.CompatHelper;
import java.lang.reflect.InvocationTargetException;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
import io.requery.android.database.DatabaseErrorHandler;
import io.requery.android.database.sqlite.SQLiteDatabase;
import timber.log.Timber;
/**
* Database layer for AnkiDroid. Can read the native Anki format through Android's SQLite driver.
*/
public class DB {
private static final String[] MOD_SQLS = new String[] { "insert", "update", "delete" };
/**
* The deck, which is actually an SQLite database.
*/
private SQLiteDatabase mDatabase;
private boolean mMod = false;
/**
* Open a database connection to an ".anki" SQLite file.
*/
@TargetApi(Build.VERSION_CODES.HONEYCOMB)
public DB(String ankiFilename) {
// Since API 11 we can provide a custom error handler which doesn't delete the database on corruption
if (CompatHelper.isHoneycomb()) {
mDatabase = SQLiteDatabase.openDatabase(ankiFilename, null,
(SQLiteDatabase.OPEN_READWRITE + SQLiteDatabase.CREATE_IF_NECESSARY)
| SQLiteDatabase.NO_LOCALIZED_COLLATORS, new MyDbErrorHandler());
} else {
mDatabase = SQLiteDatabase.openDatabase(ankiFilename, null,
(SQLiteDatabase.OPEN_READWRITE + SQLiteDatabase.CREATE_IF_NECESSARY)
| SQLiteDatabase.NO_LOCALIZED_COLLATORS);
}
if (mDatabase != null) {
// TODO: we can remove this eventually once everyone has stopped using old AnkiDroid clients with WAL
CompatHelper.getCompat().disableDatabaseWriteAheadLogging(mDatabase);
mDatabase.rawQuery("PRAGMA synchronous = 2", null);
}
// getDatabase().beginTransactionNonExclusive();
mMod = false;
}
@TargetApi(Build.VERSION_CODES.HONEYCOMB)
public class MyDbErrorHandler implements DatabaseErrorHandler {
@Override
public void onCorruption(SQLiteDatabase db) {
Timber.e("The database has been corrupted...");
AnkiDroidApp.sendExceptionReport(new RuntimeException("Database corrupted"), "DB.MyDbErrorHandler.onCorruption", "Db has been corrupted ");
CollectionHelper.getInstance().closeCollection(false);
DatabaseErrorDialog.databaseCorruptFlag = true;
}
}
/**
* Closes a previously opened database connection.
*/
public void close() {
mDatabase.close();
Timber.d("Database %s closed = %s", mDatabase.getPath(), !mDatabase.isOpen());
}
public void commit() {
// SQLiteDatabase db = getDatabase();
// while (db.inTransaction()) {
// db.setTransactionSuccessful();
// db.endTransaction();
// }
// db.beginTransactionNonExclusive();
}
public SQLiteDatabase getDatabase() {
return mDatabase;
}
public void setMod(boolean mod) {
mMod = mod;
}
public boolean getMod() {
return mMod;
}
/**
* Convenience method for querying the database for a single integer result.
*
* @param query The raw SQL query to use.
* @return The integer result of the query.
*/
public int queryScalar(String query) {
return queryScalar(query, null);
}
public int queryScalar(String query, String[] selectionArgs) {
Cursor cursor = null;
int scalar;
try {
cursor = mDatabase.rawQuery(query, selectionArgs);
if (!cursor.moveToNext()) {
return 0;
}
scalar = cursor.getInt(0);
} finally {
if (cursor != null) {
cursor.close();
}
}
return scalar;
}
public String queryString(String query) throws SQLException {
Cursor cursor = null;
try {
cursor = mDatabase.rawQuery(query, null);
if (!cursor.moveToNext()) {
throw new SQLException("No result for query: " + query);
}
return cursor.getString(0);
} finally {
if (cursor != null) {
cursor.close();
}
}
}
public long queryLongScalar(String query) {
Cursor cursor = null;
long scalar;
try {
cursor = mDatabase.rawQuery(query, null);
if (!cursor.moveToNext()) {
return 0;
}
scalar = cursor.getLong(0);
} finally {
if (cursor != null) {
cursor.close();
}
}
return scalar;
}
/**
* Convenience method for querying the database for an entire column. The column will be returned as an ArrayList of
* the specified class. See Deck.initUndo() for a usage example.
*
* @param type The class of the column's data type. Example: int.class, String.class.
* @param query The SQL query statement.
* @param column The column id in the result set to return.
* @return An ArrayList with the contents of the specified column.
*/
public <T> ArrayList<T> queryColumn(Class<T> type, String query, int column) {
int nullExceptionCount = 0;
InvocationTargetException nullException = null; // to catch the null exception for reporting
ArrayList<T> results = new ArrayList<>();
Cursor cursor = null;
try {
cursor = mDatabase.rawQuery(query, null);
String methodName = getCursorMethodName(type.getSimpleName());
while (cursor.moveToNext()) {
try {
// The magical line. Almost as illegible as python code ;)
results.add(type.cast(Cursor.class.getMethod(methodName, int.class).invoke(cursor, column)));
} catch (InvocationTargetException e) {
if (cursor.isNull(column)) { // null value encountered
nullExceptionCount++;
if (nullExceptionCount == 1) { // Toast and error report first time only
nullException = e;
Toast.makeText(AnkiDroidApp.getInstance().getBaseContext(),
"Error report pending: unexpected null in database.", Toast.LENGTH_LONG).show();
}
} else {
throw new RuntimeException(e);
}
}
}
} catch (NoSuchMethodException | IllegalAccessException | IllegalArgumentException e) {
// This is really coding error, so it should be revealed if it ever happens
throw new RuntimeException(e);
} finally {
if (cursor != null) {
cursor.close();
}
if (nullExceptionCount > 0) {
if (nullException != null) {
StringBuilder sb = new StringBuilder();
sb.append("DB.queryColumn (column " + column + "): ");
sb.append("Exception due to null. Query: " + query);
sb.append(" Null occurrences during this query: " + nullExceptionCount);
AnkiDroidApp.sendExceptionReport(nullException, "queryColumn_encounteredNull", sb.toString());
Timber.w(sb.toString());
} else { // nullException not properly initialized
StringBuilder sb = new StringBuilder();
sb.append("DB.queryColumn(): Critical error -- ");
sb.append("unable to pass in the actual exception to error reporting.");
AnkiDroidApp.sendExceptionReport(new RuntimeException("queryColumn null"), "queryColumn_encounteredNull", sb.toString());
Timber.e(sb.toString());
}
}
}
return results;
}
/**
* Mapping of Java type names to the corresponding Cursor.get method.
*
* @param typeName The simple name of the type's class. Example: String.class.getSimpleName().
* @return The name of the Cursor method to be called.
*/
private static String getCursorMethodName(String typeName) {
if (typeName.equals("String")) {
return "getString";
} else if (typeName.equals("Long")) {
return "getLong";
} else if (typeName.equals("Integer")) {
return "getInt";
} else if (typeName.equals("Float")) {
return "getFloat";
} else if (typeName.equals("Double")) {
return "getDouble";
} else {
return null;
}
}
public void execute(String sql) {
execute(sql, null);
}
public void execute(String sql, Object[] object) {
String s = sql.trim().toLowerCase(Locale.US);
// mark modified?
for (String mo : MOD_SQLS) {
if (s.startsWith(mo)) {
mMod = true;
break;
}
}
if (object == null) {
this.getDatabase().execSQL(sql);
} else {
this.getDatabase().execSQL(sql, object);
}
}
/**
* WARNING: This is a convenience method that splits SQL scripts into separate queries with semicolons (;)
* as the delimiter. Only use this method on internal functions where we can guarantee that the script does
* not contain any non-statement-terminating semicolons.
*/
public void executeScript(String sql) {
mMod = true;
String[] queries = sql.split(";");
for(String query : queries) {
mDatabase.execSQL(query);
}
}
/** update must always be called via DB in order to mark the db as changed */
public int update(String table, ContentValues values) {
return update(table, values, null, null);
}
/** update must always be called via DB in order to mark the db as changed */
public int update(String table, ContentValues values, String whereClause, String[] whereArgs) {
mMod = true;
return getDatabase().update(table, values, whereClause, whereArgs);
}
/** insert must always be called via DB in order to mark the db as changed */
public long insert(String table, String nullColumnHack, ContentValues values) {
mMod = true;
return getDatabase().insert(table, nullColumnHack, values);
}
public void executeMany(String sql, List<Object[]> list) {
mMod = true;
mDatabase.beginTransaction();
try {
for (Object[] o : list) {
mDatabase.execSQL(sql, o);
}
mDatabase.setTransactionSuccessful();
} finally {
mDatabase.endTransaction();
}
}
/**
* @return The full path to this database file.
*/
public String getPath() {
return mDatabase.getPath();
}
}