/** * Copyright (c) 2012 Todoroo Inc * * See the file "LICENSE" for the full license governing this code. */ package com.todoroo.andlib.data; import java.lang.reflect.Constructor; import java.lang.reflect.InvocationTargetException; import java.util.ArrayList; import java.util.Map.Entry; import java.util.Set; import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicInteger; import android.content.ContentValues; import android.database.Cursor; import android.database.sqlite.SQLiteTransactionListener; import android.util.Log; import com.todoroo.andlib.service.Autowired; import com.todoroo.andlib.service.DependencyInjectionService; import com.todoroo.andlib.sql.Criterion; import com.todoroo.andlib.sql.Query; import com.todoroo.andlib.utility.DateUtilities; import com.todoroo.astrid.data.OutstandingEntry; import com.todoroo.astrid.data.SyncFlags; /** * DAO for reading data from an instance of {@link AbstractDatabase}. If you * are writing an add-on for Astrid, you probably want to be using a subclass * of {@link ContentResolverDao} instead. * * @author Tim Su <tim@todoroo.com> * */ public class DatabaseDao<TYPE extends AbstractModel> { private static final String ERROR_TAG = "database-dao"; //$NON-NLS-1$ private final Class<TYPE> modelClass; private Table table; protected Table outstandingTable; private AbstractDatabase database; @Autowired protected Boolean debug; public DatabaseDao(Class<TYPE> modelClass) { DependencyInjectionService.getInstance().inject(this); this.modelClass = modelClass; if(debug == null) debug = false; } public DatabaseDao(Class<TYPE> modelClass, AbstractDatabase database) { this(modelClass); setDatabase(database); } /** Gets table associated with this DAO */ public Table getTable() { return table; } public Class<TYPE> getModelClass() { return modelClass; } /** * Sets database accessed by this DAO. Used for dependency-injected * initialization by child classes and unit tests * * @param database */ public void setDatabase(AbstractDatabase database) { if(database == this.database) return; this.database = database; table = database.getTable(modelClass); outstandingTable = database.getOutstandingTable(modelClass); } // --- listeners public interface ModelUpdateListener<MTYPE> { public void onModelUpdated(MTYPE model, boolean outstandingEntries); } private final ArrayList<ModelUpdateListener<TYPE>> listeners = new ArrayList<ModelUpdateListener<TYPE>>(); public void addListener(ModelUpdateListener<TYPE> listener) { listeners.add(listener); } protected void onModelUpdated(TYPE model, boolean outstandingEntries) { TYPE modelCopy = (TYPE) model.clone(); for(ModelUpdateListener<TYPE> listener : listeners) { listener.onModelUpdated(modelCopy, outstandingEntries); } } // --- dao methods /** * Construct a query with SQL DSL objects * * @param query * @return */ public TodorooCursor<TYPE> query(Query query) { query.from(table); if(debug) Log.i("SQL-" + modelClass.getSimpleName(), query.toString()); //$NON-NLS-1$ Cursor cursor = database.rawQuery(query.toString(), null); return new TodorooCursor<TYPE>(cursor, query.getFields()); } /** * Construct a query with raw SQL * * @param properties * @param selection * @param selectionArgs * @return */ public TodorooCursor<TYPE> rawQuery(String selection, String[] selectionArgs, Property<?>... properties) { String[] fields = new String[properties.length]; for(int i = 0; i < properties.length; i++) fields[i] = properties[i].name; return new TodorooCursor<TYPE>(database.getDatabase().query(table.name, fields, selection, selectionArgs, null, null, null), properties); } /** * Returns object corresponding to the given identifier * * @param database * @param table * name of table * @param properties * properties to read * @param id * id of item * @return null if no item found */ public TYPE fetch(long id, Property<?>... properties) { TodorooCursor<TYPE> cursor = fetchItem(id, properties); return returnFetchResult(cursor); } protected TYPE returnFetchResult(TodorooCursor<TYPE> cursor) { try { if (cursor.getCount() == 0) return null; Constructor<TYPE> constructor = modelClass.getConstructor(TodorooCursor.class); return constructor.newInstance(cursor); } catch (SecurityException e) { throw new RuntimeException(e); } catch (NoSuchMethodException e) { throw new RuntimeException(e); } catch (IllegalArgumentException e) { throw new RuntimeException(e); } catch (InstantiationException e) { throw new RuntimeException(e); } catch (IllegalAccessException e) { throw new RuntimeException(e); } catch (InvocationTargetException e) { throw new RuntimeException(e); } finally { cursor.close(); } } /** * Delete the given id * * @param database * @param id * @return true if delete was successful */ public boolean delete(long id) { return database.delete(table.name, AbstractModel.ID_PROPERTY.eq(id).toString(), null) > 0; } /** * Delete all matching a clause * @param where predicate for deletion * @return # of deleted items */ public int deleteWhere(Criterion where) { return database.delete(table.name, where.toString(), null); } /** * Update all matching a clause to have the values set on template object. * <p> * Example (updates "joe" => "bob" in metadata value1): * {code} * Metadata item = new Metadata(); * item.setValue(Metadata.VALUE1, "bob"); * update(item, Metadata.VALUE1.eq("joe")); * {code} * @param where sql criteria * @param template set fields on this object in order to set them in the db. * @return # of updated items */ public int update(Criterion where, TYPE template) { boolean recordOutstanding = shouldRecordOutstanding(template); final AtomicInteger result = new AtomicInteger(0); if (recordOutstanding) { TodorooCursor<TYPE> toUpdate = query(Query.select(AbstractModel.ID_PROPERTY).where(where)); Long[] ids = null; try { ids = new Long[toUpdate.getCount()]; for (int i = 0; i < toUpdate.getCount(); i++) { toUpdate.moveToNext(); ids[i] = toUpdate.get(AbstractModel.ID_PROPERTY); } } finally { toUpdate.close(); } if (toUpdate.getCount() == 0) return 0; synchronized (database) { database.getDatabase().beginTransactionWithListener(new SQLiteTransactionListener() { @Override public void onRollback() { Log.e(ERROR_TAG, "Error updating rows", new Throwable()); //$NON-NLS-1$ result.set(0); } @Override public void onCommit() {/**/} @Override public void onBegin() {/**/} }); try { result.set(database.update(table.name, template.getSetValues(), where.toString(), null)); if (result.get() > 0) { for (Long id : ids) { createOutstandingEntries(id, template.getSetValues()); } } database.getDatabase().setTransactionSuccessful(); } finally { database.getDatabase().endTransaction(); } } return result.get(); } else { return database.update(table.name, template.getSetValues(), where.toString(), null); } } /** * Save the given object to the database. Creates a new object if * model id property has not been set * * @return true on success. */ public boolean persist(TYPE item) { if (item.getId() == AbstractModel.NO_ID) { return createNew(item); } else { ContentValues values = item.getSetValues(); if (values.size() == 0) // nothing changed return true; return saveExisting(item); } } private interface DatabaseChangeOp { public boolean makeChange(); } protected boolean shouldRecordOutstanding(TYPE item) { return (outstandingTable != null) && !item.checkAndClearTransitory(SyncFlags.ACTFM_SUPPRESS_OUTSTANDING_ENTRIES); } private boolean insertOrUpdateAndRecordChanges(TYPE item, ContentValues values, DatabaseChangeOp op) { boolean recordOutstanding = shouldRecordOutstanding(item); final AtomicBoolean result = new AtomicBoolean(false); synchronized(database) { if (recordOutstanding) { // begin transaction database.getDatabase().beginTransactionWithListener(new SQLiteTransactionListener() { @Override public void onRollback() { Log.e(ERROR_TAG, "Error inserting or updating rows", new Throwable()); //$NON-NLS-1$ result.set(false); } @Override public void onCommit() {/**/} @Override public void onBegin() {/**/} }); } int numOutstanding = 0; try { result.set(op.makeChange()); if(result.get()) { if (recordOutstanding && ((numOutstanding = createOutstandingEntries(item.getId(), values)) != -1)) // Create entries for setValues in outstanding table database.getDatabase().setTransactionSuccessful(); } } finally { if (recordOutstanding) // commit transaction database.getDatabase().endTransaction(); } if (result.get()) { onModelUpdated(item, recordOutstanding && numOutstanding > 0); item.markSaved(); } } return result.get(); } /** * Creates the given item. * * @param database * @param table * table name * @param item * item model * @return returns true on success. */ public boolean createNew(final TYPE item) { item.clearValue(AbstractModel.ID_PROPERTY); DatabaseChangeOp insert = new DatabaseChangeOp() { @Override public boolean makeChange() { long newRow = database.insert(table.name, AbstractModel.ID_PROPERTY.name, item.getMergedValues()); boolean result = newRow >= 0; if (result) item.setId(newRow); return result; } }; return insertOrUpdateAndRecordChanges(item, item.getMergedValues(), insert); } /** * Saves the given item. Will not create a new item! * * @param database * @param table * table name * @param item * item model * @return returns true on success. */ public boolean saveExisting(final TYPE item) { final ContentValues values = item.getSetValues(); if(values == null || values.size() == 0) // nothing changed return true; DatabaseChangeOp update = new DatabaseChangeOp() { @Override public boolean makeChange() { return database.update(table.name, values, AbstractModel.ID_PROPERTY.eq(item.getId()).toString(), null) > 0; } }; return insertOrUpdateAndRecordChanges(item, values, update); } protected int createOutstandingEntries(long modelId, ContentValues modelSetValues) { Set<Entry<String, Object>> entries = modelSetValues.valueSet(); long now = DateUtilities.now(); int count = 0; for (Entry<String, Object> entry : entries) { if (entry.getValue() != null && shouldRecordOutstandingEntry(entry.getKey(), entry.getValue())) { AbstractModel m; try { m = outstandingTable.modelClass.newInstance(); } catch (IllegalAccessException e) { return -1; } catch (InstantiationException e2) { return -1; } m.setValue(OutstandingEntry.ENTITY_ID_PROPERTY, modelId); m.setValue(OutstandingEntry.COLUMN_STRING_PROPERTY, entry.getKey()); m.setValue(OutstandingEntry.VALUE_STRING_PROPERTY, entry.getValue().toString()); m.setValue(OutstandingEntry.CREATED_AT_PROPERTY, now); database.insert(outstandingTable.name, null, m.getSetValues()); count++; } } return count; } /** * Returns true if an entry in the outstanding table should be recorded for this * column. Subclasses can override to return false for insignificant columns * (e.g. Task.DETAILS, last modified, etc.) * @param columnName * @return */ protected boolean shouldRecordOutstandingEntry(String columnName, Object value) { return true; } // --- helper methods /** * Returns cursor to object corresponding to the given identifier * * @param database * @param table * name of table * @param properties * properties to read * @param id * id of item * @return */ protected TodorooCursor<TYPE> fetchItem(long id, Property<?>... properties) { TodorooCursor<TYPE> cursor = query( Query.select(properties).where(AbstractModel.ID_PROPERTY.eq(id))); cursor.moveToFirst(); return new TodorooCursor<TYPE>(cursor, properties); } public int count(Query query) { TodorooCursor<TYPE> cursor = query(query); try { return cursor.getCount(); } finally { cursor.close(); } } }