package com.gaiagps.iburn.database; import android.content.ContentValues; import android.content.Context; import android.database.DatabaseUtils; import android.database.sqlite.SQLiteOpenHelper; import android.support.annotation.NonNull; import android.support.annotation.Nullable; import com.gaiagps.iburn.Constants; import com.gaiagps.iburn.PrefsHelper; import com.squareup.sqlbrite.BriteDatabase; import com.squareup.sqlbrite.SqlBrite; import java.lang.reflect.InvocationTargetException; import java.lang.reflect.Method; import java.util.ArrayDeque; import java.util.ArrayList; import java.util.Collections; import java.util.HashSet; import java.util.List; import java.util.Set; import java.util.concurrent.atomic.AtomicBoolean; import rx.Observable; import rx.functions.Func1; import rx.schedulers.Schedulers; import timber.log.Timber; /** * Class for interaction with our database via Reactive streams. * This is intended as an experiment to replace our use of {@link android.content.ContentProvider} * as it does not meet all of our needs (e.g: Complex UNION queries not possible with Schematic's * generated version, and I believe manually writing a ContentProvider is too burdensome and error-prone) * <p> * Created by davidbrodsky on 6/22/15. */ public class DataProvider { public interface QueryInterceptor { String onQueryIntercepted(@NonNull String query, @NonNull Iterable<String> tables); } /** * Computed column indicating type for queries that union results across tables */ public static final String VirtualType = "vtype"; /** * Version of database schema */ public static final long BUNDLED_DATABASE_VERSION = 1; /** * Version of database data and mbtiles. This is basically the unix time at which bundled data was provided to this build. */ public static final long RESOURCES_VERSION = 1472093065000L; // Unix time of creation /** * If true, use a bundled pre-populated database (see {@link DBWrapper}. Else start with a fresh database. */ private static final boolean USE_BUNDLED_DB = true; private static DataProvider provider; private BriteDatabase db; private QueryInterceptor interceptor; private final AtomicBoolean upgradeLock = new AtomicBoolean(false); private ArrayDeque<BriteDatabase.Transaction> transactionStack = new ArrayDeque<>(); public static Observable<DataProvider> getInstance(@NonNull Context context) { if (provider != null) return Observable.just(provider); final PrefsHelper prefs = new PrefsHelper(context); SQLiteOpenHelper openHelper = USE_BUNDLED_DB ? new DBWrapper(context) : com.gaiagps.iburn.database.generated.PlayaDatabase.getInstance(context); return Observable.just(openHelper) .subscribeOn(Schedulers.io()) .doOnNext(database -> { prefs.setDatabaseVersion(BUNDLED_DATABASE_VERSION); prefs.setBaseResourcesVersion(RESOURCES_VERSION); }) .map(sqLiteOpenHelper -> SqlBrite.create().wrapDatabaseHelper(sqLiteOpenHelper, Schedulers.io())) // .doOnNext(sqlBrite1 -> sqlBrite1.setLoggingEnabled(true)) .map(sqlBrite -> new DataProvider(sqlBrite, new Embargo(prefs))) .doOnNext(dataProvider -> provider = dataProvider); } public static String makeProjectionString(String[] projection) { StringBuilder builder = new StringBuilder(); for (String column : projection) { builder.append(column); builder.append(','); } // Remove the last comma return builder.substring(0, builder.length() - 1); } private DataProvider(BriteDatabase db, @Nullable QueryInterceptor interceptor) { this.db = db; this.interceptor = interceptor; } public BriteDatabase getDb() { return db; } public void beginUpgrade() { upgradeLock.set(true); } public void endUpgrade() { upgradeLock.set(false); // Trigger all SqlBrite observers via reflection (uses private method) try { Method method = db.getClass().getDeclaredMethod("sendTableTrigger", Set.class); method.setAccessible(true); method.invoke(db, new HashSet<>(PlayaDatabase.ALL_TABLES)); } catch (SecurityException | NoSuchMethodException | InvocationTargetException | IllegalAccessException e) { Timber.w(e, "Failed to notify observers on endUpgrade"); } } public Observable<SqlBrite.Query> createEmbargoExemptQuery(@NonNull final String table, @NonNull String sql, @NonNull String... args) { return db.createQuery(table, sql, args); } public Observable<SqlBrite.Query> createQuery(@NonNull final String table, @NonNull String sql, @NonNull String... args) { return db.createQuery(table, interceptQuery(sql, table), args); } public Observable<SqlBrite.Query> createQuery(@NonNull final Iterable<String> tables, @NonNull String sql, @NonNull String... args) { return db.createQuery(tables, interceptQuery(sql, tables), args); } public int delete(@NonNull String table, @Nullable String whereClause, @Nullable String... whereArgs) { return db.delete(table, whereClause, whereArgs); } public int update(@NonNull String table, @NonNull ContentValues values, @Nullable String whereClause, @Nullable String... whereArgs) { return db.update(table, values, whereClause, whereArgs); } public void beginTransaction() { BriteDatabase.Transaction t = db.newTransaction(); transactionStack.push(t); } public void setTransactionSuccessful() { if (transactionStack.size() == 0) { return; } BriteDatabase.Transaction t = transactionStack.peekLast(); t.markSuccessful(); } public void endTransaction() { if (transactionStack.size() == 0) { return; } // Don't allow this call to proceed without prior call to beginTransaction BriteDatabase.Transaction t = transactionStack.pop(); t.end(); } public long insert(@NonNull String table, @NonNull ContentValues values) { return db.insert(table, values); } public Observable<SqlBrite.Query> observeTable(@NonNull String table, @Nullable String[] projection) { return observeTable(table, projection, null); } public Observable<SqlBrite.Query> observeTable(@NonNull String table, @Nullable String[] projection, @Nullable String whereClause) { String sql = interceptQuery("SELECT " + (projection == null ? "*" : makeProjectionString(projection)) + " FROM " + table, table); if (whereClause != null) { sql += " WHERE " + whereClause; } if (table.equals(PlayaDatabase.EVENTS)) { sql += " ORDER BY " + EventTable.startTime + " ASC"; } else { sql += " ORDER BY " + PlayaItemTable.name + " ASC"; } return db.createQuery(table, sql) .subscribeOn(Schedulers.computation()) .skipWhile(query -> upgradeLock.get()); } public Observable<SqlBrite.Query> observeEventsOnDayOfTypes(@Nullable String day, @Nullable ArrayList<String> types, @Nullable String[] projection) { List<String> args = new ArrayList<>((types == null ? 0 : types.size()) + (day == null ? 0 : 1)); StringBuilder sql = new StringBuilder(); sql.append("SELECT "); sql.append(projection == null ? "*" : makeProjectionString(projection)); sql.append(" FROM "); sql.append(PlayaDatabase.EVENTS); if (day != null || (types != null && types.size() > 0)) sql.append(" WHERE "); if (types != null) { for (int x = 0; x < types.size(); x++) { sql.append('(') .append(EventTable.eventType) .append("= ?)"); args.add(types.get(x)); if (x < types.size() - 1) sql.append(" OR "); } } if (day != null) { if (types != null && types.size() > 0) sql.append(" AND "); sql.append(EventTable.startTimePrint) .append(" LIKE ") .append("?"); args.add('%' + day + '%'); } sql.append(" ORDER BY "); sql.append(EventTable.allDay); sql.append(", "); sql.append(EventTable.startTime); sql.append(" ASC"); Timber.d("Event filter query " + sql.toString()); return db.createQuery(PlayaDatabase.EVENTS, interceptQuery(sql.toString(), PlayaDatabase.EVENTS), args.toArray(new String[args.size()])) .subscribeOn(Schedulers.computation()) .skipWhile(query -> upgradeLock.get()); } /** * Observe all favorites. * <p> * Note: This query automatically adds in Event.startTime (and 0 values for all non-events), * since we always want to show this data for an event. * * @param projection a projection of columns from {@link PlayaItemTable}. Columns exclusive to a * particular table must not be submitted. */ public Observable<SqlBrite.Query> observeFavorites(@Nullable String[] projection) { StringBuilder sql = new StringBuilder(); int tableIdx = 0; for (String table : PlayaDatabase.ALL_TABLES) { tableIdx++; sql.append("SELECT ") .append(projection == null ? "*" : makeProjectionString(projection)) .append(", ") .append(tableIdx) .append(" as ") .append(VirtualType); addEventColsToMultitableQuery(table, sql); sql.append(" FROM ") .append(table) .append(" WHERE ") .append(PlayaItemTable.favorite) .append(" = 1 "); if (tableIdx < PlayaDatabase.ALL_TABLES.size()) sql.append(" UNION "); } sql.append(" ORDER BY ") .append(VirtualType) .append(", ") .append(EventTable.startTime); return db.createQuery(PlayaDatabase.ALL_TABLES, interceptQuery(sql.toString(), PlayaDatabase.ALL_TABLES)) .subscribeOn(Schedulers.computation()) .skipWhile(query -> upgradeLock.get()); } /** * Observe all results for a name query. * <p> * Note: This query automatically adds in Event.startTime (and 0 values for all non-events), * since we always want to show this data for an event. * * @param projection a projection of columns from {@link PlayaItemTable}. Columns exclusive to a * particular table must not be submitted. */ public Observable<SqlBrite.Query> observeNameQuery(@NonNull String query, @Nullable String[] projection) { query = '%' + query + '%'; StringBuilder sql = new StringBuilder(); int tableIdx = 0; String[] params = new String[PlayaDatabase.ALL_TABLES.size()]; for (String table : PlayaDatabase.ALL_TABLES) { params[tableIdx] = query; tableIdx++; sql.append("SELECT ") .append(projection == null ? "*" : makeProjectionString(projection)) .append(", ") .append(tableIdx) .append(" as ") .append(VirtualType); addEventColsToMultitableQuery(table, sql); sql.append(" FROM ") .append(table) .append(" WHERE ") .append(PlayaItemTable.name) .append(" LIKE ?") .append(" GROUP BY ") .append(PlayaItemTable.name); if (tableIdx < PlayaDatabase.ALL_TABLES.size()) sql.append(" UNION "); } sql.append(" ORDER BY ") .append(VirtualType); return db.createQuery(PlayaDatabase.ALL_TABLES, interceptQuery(sql.toString(), PlayaDatabase.ALL_TABLES), params) .subscribeOn(Schedulers.computation()) .skipWhile(queryResp -> upgradeLock.get()); } public Observable<SqlBrite.Query> observeAllTables(@NonNull String whereClause, @Nullable String[] projection) { whereClause = DatabaseUtils.sqlEscapeString(whereClause); StringBuilder sql = new StringBuilder(); int tableIdx = 0; for (String table : PlayaDatabase.ALL_TABLES) { tableIdx++; sql.append("SELECT ") .append(projection == null ? "*" : makeProjectionString(projection)) .append(", ") .append(tableIdx) .append(" as ") .append(VirtualType) .append(" FROM ") .append(table) .append(" WHERE ") .append(whereClause); if (tableIdx < PlayaDatabase.ALL_TABLES.size()) sql.append(" UNION "); } return db.createQuery(PlayaDatabase.ALL_TABLES, interceptQuery(sql.toString(), PlayaDatabase.ALL_TABLES)) .subscribeOn(Schedulers.computation()) .skipWhile(queryResp -> upgradeLock.get()); } public void updateFavorite(@NonNull String table, int id, boolean isFavorite) { ContentValues values = new ContentValues(1); values.put(PlayaItemTable.favorite, isFavorite ? 1 : 0); db.update(table, values, PlayaItemTable.id + "=?", String.valueOf(id)); } public void toggleFavorite(@NonNull final String table, int id) { db.createQuery(table, "SELECT " + PlayaItemTable.favorite + " FROM " + table + " WHERE " + PlayaItemTable.id + " =?", String.valueOf(id)) .first() .map(SqlBrite.Query::run) .map(cursor -> { if (cursor != null && cursor.moveToFirst()) { boolean isFavorite = cursor.getInt(cursor.getColumnIndex(PlayaItemTable.favorite)) == 1; cursor.close(); return isFavorite; } throw new IllegalStateException(String.format("No row in %s with id %d exists", table, id)); }) .subscribe(isFavorite -> updateFavorite(table, id, !isFavorite), throwable -> Timber.e(throwable, throwable.getMessage())); } /** * @return the int value used in virtual columns to represent a {@link com.gaiagps.iburn.Constants.PlayaItemType} */ public static int getTypeValue(Constants.PlayaItemType type) { switch (type) { case CAMP: return 1; case ART: return 2; case EVENT: return 3; case POI: return 4; } Timber.w("Unknown PlayaItemType"); return -1; } public static Constants.PlayaItemType getTypeValue(int type) { switch (type) { case 1: return Constants.PlayaItemType.CAMP; case 2: return Constants.PlayaItemType.ART; case 3: return Constants.PlayaItemType.EVENT; case 4: return Constants.PlayaItemType.POI; } throw new IllegalArgumentException("Invalid type value"); } private String interceptQuery(String query, String table) { return interceptQuery(query, Collections.singleton(table)); } private String interceptQuery(String query, Iterable<String> tables) { if (interceptor == null) return query; return interceptor.onQueryIntercepted(query, tables); } /** * Add Event-specific columns to a multi-table query, which are required whenever * we display an Event. */ private void addEventColsToMultitableQuery(String table, StringBuilder sql) { if (!table.equals(PlayaDatabase.EVENTS)) { sql.append(", ") .append("'0' as ") .append(EventTable.startTime) .append(", '0' as ") .append(EventTable.startTimePrint) .append(", '0' as ") .append(EventTable.endTime) .append(", '0' as ") .append(EventTable.endTimePrint) .append(", 0 as ") .append(EventTable.allDay) .append(", 'none' as ") .append(EventTable.eventType); } else { sql.append(", ") .append(EventTable.startTime) .append(", ") .append(EventTable.startTimePrint) .append(", ") .append(EventTable.endTime) .append(", ") .append(EventTable.endTimePrint) .append(", ") .append(EventTable.allDay) .append(", ") .append(EventTable.eventType); } } }