package nl.elastique.poetry.json;
import android.annotation.TargetApi;
import android.content.ContentValues;
import android.database.Cursor;
import android.database.sqlite.SQLiteDatabase;
import android.database.sqlite.SQLiteException;
import android.os.Build;
import android.os.Looper;
import com.j256.ormlite.field.DatabaseField;
import com.j256.ormlite.field.ForeignCollectionField;
import com.j256.ormlite.table.DatabaseTable;
import org.json.JSONArray;
import org.json.JSONException;
import org.json.JSONObject;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.lang.reflect.Field;
import java.util.ArrayList;
import java.util.Iterator;
import java.util.List;
import nl.elastique.poetry.json.annotations.ForeignCollectionFieldSingleTarget;
import nl.elastique.poetry.json.annotations.ManyToManyField;
import nl.elastique.poetry.reflection.AnnotationRetriever;
import nl.elastique.poetry.reflection.FieldRetriever;
import nl.elastique.poetry.reflection.OrmliteReflection;
import nl.elastique.poetry.utils.QueryUtils;
/**
* Persist a JSONObject or JSONArray to an SQLite database by parsing annotations (both from OrmLite and custom ones).
*/
public class JsonPersister
{
/**
* Constructor options. You can combine them with the bitwise OR operator.
*/
public static class Option
{
/**
* When a foreign collection is imported (one-to-many relationship),
* the normal behavior is that the old children are deleted.
* This options allows you to disable that behavior.
*/
public static final int DISABLE_FOREIGN_COLLECTION_CLEANUP = 0x0001;
/**
* Don't display warnings when JSON attributes are not annotated as a field in an object.
*/
public static final int DISABLE_IGNORED_ATTRIBUTES_WARNING = 0x0002;
/**
* Check if an option is enabled
* @param optionsSet the compound of option values (combined with logical OR operator)
* @param optionCheck one or more options to check (combined with logical OR operator)
* @return true when all the options from optionCheck are contained in optionsSet
*/
public static boolean isEnabled(int optionsSet, int optionCheck)
{
return (optionsSet & optionCheck) == optionCheck;
}
}
private static final Logger sLogger = LoggerFactory.getLogger(JsonPersister.class);
private final SQLiteDatabase mDatabase;
private final int mOptions;
private final FieldRetriever mFieldRetriever = new FieldRetriever();
private final AnnotationRetriever mAnnotationRetriever = new AnnotationRetriever();
public JsonPersister(SQLiteDatabase writableDatabase)
{
this(writableDatabase, 0);
}
/**
* Constructor.
*
* @param writableDatabase the database used for persistence
* @param options 0 or a combination of 1 or more {@link nl.elastique.poetry.json.JsonPersister.Option} values
*/
public JsonPersister(SQLiteDatabase writableDatabase, int options)
{
mDatabase = writableDatabase;
mOptions = options;
}
/**
* All necessary data to map an array of objects onto the provided parent field.
*/
private static class ForeignCollectionMapping
{
private final Field mField;
private final JSONArray mJsonArray;
/**
* @param field
* @param jsonArray or null
*/
public ForeignCollectionMapping(Field field, JSONArray jsonArray)
{
mField = field;
mJsonArray = jsonArray;
}
public Field getField()
{
return mField;
}
public JSONArray getJsonArray()
{
return mJsonArray;
}
}
/**
* Recursively persist this object and all its children.
*
* @param modelClass the type to persist
* @param jsonObject the json to process
* @param <IdType> the ID type to return
* @return the ID of the persisted object
* @throws JSONException when something went wrong through parsing, this also fails the database transaction and results in no data changes
*/
public <IdType> IdType persistObject(Class<?> modelClass, JSONObject jsonObject) throws JSONException
{
if (Looper.myLooper() == Looper.getMainLooper())
{
sLogger.warn("please call persistObject() on a background thread");
}
if (Build.VERSION.SDK_INT >= 11)
{
return persistObjectApi11(modelClass, jsonObject);
}
else
{
return persistObjectApiDeprecate(modelClass, jsonObject);
}
}
/**
* Recursively persist the array and all its object's children.
*
* @param modelClass the type to persist
* @param jsonArray the json to process
* @param <IdType> the ID type to return
* @return the list of IDs of the persisted objects
* @throws JSONException when something went wrong through parsing, this also fails the database transaction and results in no data changes
*/
public <IdType> List<IdType> persistArray(Class<?> modelClass, JSONArray jsonArray) throws JSONException
{
if (Looper.myLooper() == Looper.getMainLooper())
{
sLogger.warn("please call persistArray() on a background thread");
}
// TODO: make Transaction object (that handles all API levels), so we don't need a separate code path
if (Build.VERSION.SDK_INT >= 11)
{
return persistArrayApi11(modelClass, jsonArray);
}
else
{
return persistArrayDeprecate(modelClass, jsonArray);
}
}
@TargetApi(Build.VERSION_CODES.HONEYCOMB)
private <IdType> IdType persistObjectApi11(Class<?> modelClass, JSONObject jsonObject) throws JSONException
{
try
{
enableWriteAheadLogging();
mDatabase.beginTransactionNonExclusive();
IdType id = persistObjectInternal(modelClass, jsonObject);
mDatabase.setTransactionSuccessful();
return id;
}
finally
{
endTransaction();
}
}
@TargetApi(Build.VERSION_CODES.HONEYCOMB)
private <IdType> List<IdType> persistArrayApi11(Class<?> modelClass, JSONArray jsonArray) throws JSONException
{
try
{
enableWriteAheadLogging();
mDatabase.beginTransactionNonExclusive();
List<IdType> id_list = persistArrayOfObjects(modelClass, jsonArray);
mDatabase.setTransactionSuccessful();
return id_list;
}
catch (JSONException e)
{
throw e;
}
finally
{
endTransaction();
}
}
private <IdType> IdType persistObjectApiDeprecate(Class<?> modelClass, JSONObject jsonObject) throws JSONException
{
try
{
mDatabase.beginTransaction();
IdType id = persistObjectInternal(modelClass, jsonObject);
mDatabase.setTransactionSuccessful();
return id;
}
finally
{
endTransaction();
}
}
private <IdType> List<IdType> persistArrayDeprecate(Class<?> modelClass, JSONArray jsonArray) throws JSONException
{
try
{
mDatabase.beginTransaction();
List<IdType> id_list = persistArrayOfObjects(modelClass, jsonArray);
mDatabase.setTransactionSuccessful();
return id_list;
}
catch (JSONException e)
{
throw e;
}
finally
{
endTransaction();
}
}
@TargetApi(Build.VERSION_CODES.HONEYCOMB)
private void enableWriteAheadLogging()
{
try
{
// Write Ahead Logging (WAL) mode cannot be enabled or disabled while there are transactions in progress.
if (!mDatabase.inTransaction())
{
mDatabase.enableWriteAheadLogging();
}
}
catch (IllegalStateException e)
{
/*
* To catch: java.lang.IllegalStateException: Write Ahead Logging (WAL) mode cannot be enabled or disabled while there are transactions in progress. Finish all transactions and release all active database connections first.
* This exception still gets triggered, possibly because the transaction is started right after inTransaction() is checked.
*/
if (Build.VERSION.SDK_INT >= 16 && !mDatabase.isWriteAheadLoggingEnabled())
{
sLogger.warn("Write Ahead Logging is not enabled because a transaction was active");
}
}
}
private void endTransaction()
{
if (mDatabase.inTransaction())
{
try
{
mDatabase.endTransaction();
}
catch (IllegalStateException e)
{
sLogger.warn("endTransaction() failed - this does not mean there was a rollback, it just means that the transaction was closed earlier than expeced.");
}
}
}
/**
* Main persistence method for persisting a single object
*
* @param modelClass the type to persist
* @param jsonObject the json data to persist
* @param <IdType> the ID type to return
* @return the object ID (never null)
* @throws JSONException when json processing fails
*/
private <IdType> IdType persistObjectInternal(Class<?> modelClass, JSONObject jsonObject) throws JSONException
{
DatabaseTable table_annotation = modelClass.getAnnotation(DatabaseTable.class);
if (table_annotation == null)
{
throw new RuntimeException("DatabaseTable annotation not found for " + modelClass.getName());
}
ContentValues values = new ContentValues();
Iterator<?> json_keys = jsonObject.keys();
List<ForeignCollectionMapping> foreign_collection_mappings = new ArrayList<>();
String table_name = OrmliteReflection.getTableName(modelClass, table_annotation);
// We want to know the object ID because we need it to resolve one-to-many relationships (foreign collection fields)
String id_field_name = null;
Object object_id = null;
// Process all JSON keys and map them to the database
while (json_keys.hasNext())
{
// Get the next key
String json_key = (String)json_keys.next();
// Find a Field with the same name as the key
// TODO: use JsonProperty annotation to get an optional name override
Field field = mFieldRetriever.getField(modelClass, json_key);
if (field == null)
{
if (!Option.isEnabled(mOptions, Option.DISABLE_IGNORED_ATTRIBUTES_WARNING))
{
sLogger.warn("ignored attribute {} because it wasn't found in {} as a DatabaseField", json_key, modelClass.getSimpleName());
}
continue;
}
DatabaseField database_field = mAnnotationRetriever.getAnnotation(field, DatabaseField.class);
// DatabaseField is used for: object IDs, simple key-values and one-to-one relationships
if (database_field != null)
{
// Object IDs are a special case because we need to insert a new object if the object doesn't exist yet
// and we also want to retrieve the value to return it in this method and to resolve one-to-many relationships for child objects
if (OrmliteReflection.isId(database_field))
{
object_id = processIdField(database_field, field, jsonObject, json_key, table_name);
id_field_name = OrmliteReflection.getFieldName(field, database_field);
}
else // object exists, so process its value or reference
{
processDatabaseField(database_field, field, jsonObject, json_key, modelClass, values);
}
}
else // check if we have a ForeignCollectionField (which is used for one-to-many relationships)
{
ForeignCollectionField foreign_collection_field = mAnnotationRetriever.getAnnotation(field, ForeignCollectionField.class);
if (foreign_collection_field != null)
{
JSONArray json_array = !jsonObject.isNull(json_key) ? jsonObject.getJSONArray(json_key) : null;
ForeignCollectionMapping foreign_collection_mapping = new ForeignCollectionMapping(field, json_array);
foreign_collection_mappings.add(foreign_collection_mapping);
}
}
}
// Determine the object ID
if (object_id == null || id_field_name == null)
{
Field id_field = OrmliteReflection.findIdField(mAnnotationRetriever, modelClass);
if (id_field == null)
{
throw new SQLiteException("class " + modelClass.getName() + " doesn't have a DatabaseField that is marked as being an ID");
}
// we don't have to check for id_database_field being null because OrmliteReflection.findIdField implied it is there
DatabaseField id_database_field = mAnnotationRetriever.getAnnotation(id_field, DatabaseField.class);
id_field_name = OrmliteReflection.getFieldName(id_field, id_database_field);
long inserted_id = mDatabase.insert("'" + table_name + "'", id_field_name, new ContentValues());
if (inserted_id == -1)
{
throw new SQLiteException("failed to insert " + modelClass.getName() + " with id field " + id_field_name);
}
object_id = inserted_id;
}
// Process regular fields
if (values.size() > 0)
{
mDatabase.update("'" + table_name + "'", values, id_field_name + " = ?", new String[]{object_id.toString()});
}
sLogger.info("imported {} ({}={})", modelClass.getSimpleName(), id_field_name, object_id);
// Process foreign collection fields for inserted object
for (ForeignCollectionMapping foreign_collection_mapping : foreign_collection_mappings)
{
ManyToManyField many_to_many_field = mAnnotationRetriever.getAnnotation(foreign_collection_mapping.getField(), ManyToManyField.class);
if (many_to_many_field != null)
{
processManyToMany(many_to_many_field, foreign_collection_mapping, object_id, modelClass);
}
else
{
processManyToOne(foreign_collection_mapping, object_id, modelClass);
}
}
return (IdType)object_id;
}
private <IdType> List<IdType> persistArrayOfObjects(Class<?> modelClass, JSONArray jsonArray) throws JSONException
{
List<IdType> results = new ArrayList<>(jsonArray.length());
for (int i = 0; i < jsonArray.length(); i++)
{
JSONObject json_object = jsonArray.getJSONObject(i);
IdType object_id = persistObjectInternal(modelClass, json_object);
results.add(object_id);
}
return results;
}
private List<Object> persistArrayOfBaseTypes(Class<?> modelClass, JSONArray jsonArray, ForeignCollectionFieldSingleTarget singleTargetField) throws JSONException
{
DatabaseTable table_annotation = modelClass.getAnnotation(DatabaseTable.class);
if (table_annotation == null)
{
throw new RuntimeException("DatabaseTable annotation not found for " + modelClass.getName());
}
String table_name = OrmliteReflection.getTableName(modelClass, table_annotation);
List<Object> results = new ArrayList<>(jsonArray.length());
for (int i = 0; i < jsonArray.length(); i++)
{
Object value_object = jsonArray.get(i);
ContentValues content_values = new ContentValues();
content_values.put(singleTargetField.targetField(), value_object.toString());
long inserted_id = mDatabase.insert("'" + table_name + "'", singleTargetField.targetField(), content_values);
if (inserted_id == -1)
{
throw new SQLiteException("failed to insert " + modelClass.getName());
}
results.add(inserted_id);
}
return results;
}
/**
* Process an ID field giving JSON input and serialization information.
* If no object is found in the database, a new one is inserted and its ID is returned.
*
* @param databaseField the Ormlite annotation
* @param field the field that is annotated by databaseField
* @param jsonObject the object that is being mapped
* @param jsonKey the key where the value of the id field can be found within the jsonObject
* @param tableName the table to insert a new row in case the ID is not found in the database
* @return the ID field value of this object (never null)
* @throws JSONException when the ID field value cannot be determined
*/
private Object processIdField(DatabaseField databaseField, Field field, JSONObject jsonObject, String jsonKey, String tableName) throws JSONException
{
String db_field_name = OrmliteReflection.getFieldName(field, databaseField);
Object object_id = JsonUtils.getValue(jsonObject, jsonKey, field.getType());
if (object_id == null)
{
throw new RuntimeException(String.format("failed to get a value from JSON with key %s and type %s", jsonKey, field.getType().getName()));
}
String sql = String.format("SELECT * FROM '%s' WHERE %s = ? LIMIT 1", tableName, db_field_name);
String[] selection_args = new String[] { object_id.toString() };
Cursor cursor = mDatabase.rawQuery(sql, selection_args);
boolean object_exists = (cursor.getCount() > 0);
cursor.close();
if (object_exists)
{
// return existing object id
return object_id;
}
else // create object
{
ContentValues values = new ContentValues(1);
if (!JsonUtils.copyValue(object_id, db_field_name, values))
{
throw new JSONException(String.format("failed to process id field %s for table %s and jsonKey %s", field.getName(), tableName, jsonKey));
}
long inserted_id = mDatabase.insert("'" + tableName + "'", null, values);
if (inserted_id == -1)
{
throw new SQLiteException(String.format("failed to insert %s with id %s=%s", field.getType().getName(), db_field_name, object_id.toString()));
}
sLogger.info("prepared {} row (id={}/{})", tableName, object_id, inserted_id);
return object_id; // don't return inserted_id, because it's always long (while the target type might be int or another type)
}
}
private void processDatabaseField(DatabaseField databaseField, Field field, JSONObject jsonParentObject, String jsonKey, Class<?> modelClass, ContentValues values) throws JSONException
{
String db_field_name = OrmliteReflection.getFieldName(field, databaseField);
if (jsonParentObject.isNull(jsonKey))
{
values.putNull(db_field_name);
}
else if (OrmliteReflection.isForeign(databaseField))
{
JSONObject foreign_object = jsonParentObject.optJSONObject(jsonKey);
if (foreign_object != null)
{
//If the JSON includes the forein object, try to persist it
Object foreign_object_id = persistObjectInternal(field.getType(), foreign_object);
if (!JsonUtils.copyValue(foreign_object_id, db_field_name, values))
{
throw new RuntimeException("failed to copy values for key " + jsonKey + " in " + modelClass.getName() + ": key type " + foreign_object_id.getClass() + " is not supported");
}
}
else
{
//The JSON does not include the foreign object, see if it is a valid key for the foreign object
Field foreign_object_id_field = OrmliteReflection.findIdField(mAnnotationRetriever, field.getType());
if (foreign_object_id_field == null)
{
throw new RuntimeException("failed to find id field for foreign object " + field.getType().getName() + " in " + modelClass.getName());
}
Object foreign_object_id = JsonUtils.getValue(jsonParentObject, jsonKey, foreign_object_id_field.getType());
if (foreign_object_id == null)
{
throw new RuntimeException("incompatible id type for foreign object " + field.getType().getName() + " in " + modelClass.getName() + " (expected " + foreign_object_id_field.getType().getName() + ")");
}
if (!JsonUtils.copyValue(foreign_object_id, db_field_name, values))
{
throw new RuntimeException("failed to copy values for key " + jsonKey + " in " + modelClass.getName() + ": key type " + foreign_object_id.getClass() + " is not supported");
}
}
}
else // non-foreign
{
if (!JsonUtils.copyContentValue(jsonParentObject, jsonKey, values, db_field_name))
{
sLogger.warn("attribute type {} has an unsupported type while parsing {}", jsonKey, modelClass.getSimpleName());
}
}
}
private void processManyToMany(ManyToManyField manyToManyField, ForeignCollectionMapping foreignCollectionMapping, Object parentId, Class<?> parentClass) throws JSONException
{
if (foreignCollectionMapping.getJsonArray() == null)
{
// TODO: Delete mapping
sLogger.warn("Mapping {} for type {} was null. Ignored it, but it should be deleted!", foreignCollectionMapping.getField().getName(), foreignCollectionMapping.getField().getType().getName());
return;
}
Field foreign_collection_field = foreignCollectionMapping.getField();
Class<?> target_class = OrmliteReflection.getForeignCollectionParameterType(foreign_collection_field);
Field target_id_field = OrmliteReflection.findIdField(mAnnotationRetriever, target_class);
if (target_id_field == null)
{
throw new RuntimeException("no id field found while processing foreign collection relation for " + target_class.getName());
}
Field target_foreign_field = OrmliteReflection.findForeignField(mAnnotationRetriever, target_class, parentClass);
if (target_foreign_field == null)
{
throw new RuntimeException("no foreign field found while processing foreign collection relation for " + target_class.getName());
}
Field target_target_field = mFieldRetriever.getFirstFieldOfType(target_class, manyToManyField.targetType());
if (target_target_field == null)
{
throw new RuntimeException("ManyToMany problem: no ID field found for type " + manyToManyField.targetType().getName());
}
List<Object> target_target_ids = persistArrayOfObjects(target_target_field.getType(), foreignCollectionMapping.getJsonArray());
// TODO: cache table name
String target_table_name = OrmliteReflection.getTableName(mAnnotationRetriever, target_class);
DatabaseField target_foreign_db_field = mAnnotationRetriever.getAnnotation(target_foreign_field, DatabaseField.class);
String target_foreign_field_name = OrmliteReflection.getFieldName(target_foreign_field, target_foreign_db_field);
String delete_select_clause = target_foreign_field_name + " = " + QueryUtils.parseAttribute(parentId);
mDatabase.delete("'" + target_table_name + "'", delete_select_clause, new String[]{});
DatabaseField target_target_database_field = mAnnotationRetriever.getAnnotation(target_target_field, DatabaseField.class);
String target_target_field_name = OrmliteReflection.getFieldName(target_target_field, target_target_database_field);
// Insert new references
for (int i = 0; i < target_target_ids.size(); ++i)
{
ContentValues values = new ContentValues(2);
if (!JsonUtils.copyValue(parentId, target_foreign_field_name, values))
{
throw new RuntimeException("parent id copy failed");
}
if (!JsonUtils.copyValue(target_target_ids.get(i), target_target_field_name, values))
{
throw new RuntimeException("target id copy failed");
}
if (mDatabase.insert("'" + target_table_name + "'", null, values) == -1)
{
throw new RuntimeException("failed to insert item in " + target_table_name);
}
}
}
private void processManyToOne(ForeignCollectionMapping foreignCollectionMapping, Object parentId, Class<?> parentClass) throws JSONException
{
if (foreignCollectionMapping.getJsonArray() == null)
{
// TODO: Delete mapping
sLogger.warn("Mapping {} for type {} was null. Ignored it, but it should be deleted!", foreignCollectionMapping.getField().getName(), foreignCollectionMapping.getField().getType().getName());
return;
}
Field foreign_collection_field = foreignCollectionMapping.getField();
Class<?> target_class = OrmliteReflection.getForeignCollectionParameterType(foreign_collection_field);
Field target_id_field = OrmliteReflection.findIdField(mAnnotationRetriever, target_class);
if (target_id_field == null)
{
throw new RuntimeException("no id field found while processing foreign collection relation for " + target_class.getName());
}
Field target_foreign_field = OrmliteReflection.findForeignField(mAnnotationRetriever, target_class, parentClass);
if (target_foreign_field == null)
{
throw new RuntimeException("no foreign field found while processing foreign collection relation for " + target_class.getName());
}
ForeignCollectionFieldSingleTarget single_target_field = mAnnotationRetriever.getAnnotation(foreignCollectionMapping.getField(), ForeignCollectionFieldSingleTarget.class);
List<Object> target_ids;
if (single_target_field == null)
{
target_ids = persistArrayOfObjects(target_class, foreignCollectionMapping.getJsonArray());
}
else
{
target_ids = persistArrayOfBaseTypes(target_class, foreignCollectionMapping.getJsonArray(), single_target_field);
}
DatabaseField target_foreign_field_db_annotation = mAnnotationRetriever.getAnnotation(target_foreign_field, DatabaseField.class);
String target_foreign_field_name = OrmliteReflection.getFieldName(target_foreign_field, target_foreign_field_db_annotation);
ContentValues values = new ContentValues(1);
if (!JsonUtils.copyValue(parentId, target_foreign_field_name, values))
{
throw new RuntimeException("failed to copy foreign key " + target_foreign_field_name + " in " + parentClass.getName() + ": key type " + parentId.getClass() + " is not supported");
}
String[] target_id_args = new String[target_ids.size()];
String in_clause = QueryUtils.createInClause(target_ids, target_id_args);
// update references to all target objects
String target_table_name = OrmliteReflection.getTableName(mAnnotationRetriever, target_class);
String target_id_field_name = OrmliteReflection.getFieldName(mAnnotationRetriever, target_id_field);
String update_select_clause = target_id_field_name + " " + in_clause;
mDatabase.update("'" + target_table_name + "'", values, update_select_clause, target_id_args);
if (!Option.isEnabled(mOptions, Option.DISABLE_FOREIGN_COLLECTION_CLEANUP))
{
// remove all objects that are not referenced to the parent anymore
String delete_select_clause = target_id_field_name + " NOT " + in_clause + " AND " + target_foreign_field_name + " = " + QueryUtils.parseAttribute(parentId);
mDatabase.delete("'" + target_table_name + "'", delete_select_clause, target_id_args);
}
}
}