Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add foreign key support #21

Open
wants to merge 3 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,15 @@
import android.content.ContentValues;
import android.content.Context;
import android.content.OperationApplicationException;
import android.content.UriMatcher;
import android.database.Cursor;
import android.database.sqlite.SQLiteDatabase;
import android.database.sqlite.SQLiteException;
import android.net.Uri;
import android.provider.BaseColumns;
import android.util.Log;

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

Expand All @@ -23,9 +25,13 @@ public abstract class AbstractProvider extends ContentProvider {

protected final String mLogTag;
protected SQLiteDatabase mDatabase;
protected final Object mMatcherSynchronizer;
protected UriMatcher mMatcher;
protected MatchDetail[] mMatchDetails;

protected AbstractProvider() {
mLogTag = getClass().getName();
mMatcherSynchronizer = new Object();
}

@Override
Expand Down Expand Up @@ -54,6 +60,105 @@ public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) {
return false;
}

/**
* Initializes the matcher, based on the tables contained within the subclass,
* also guarantees to have initialized the mTableNames[] array as well.
*/
protected void initializeMatcher() {
// initialize the UriMatcher once, use a synchronized block
// here, so that subclasses can implement this by static initialization if
// they want.
synchronized (mMatcherSynchronizer) {
if(mMatcher != null) {
return;
}

mMatcher = new UriMatcher(UriMatcher.NO_MATCH);
List<MatchDetail> details = new ArrayList<>();

String authority = getAuthority();

for (Class<?> clazz : getClass().getClasses()) {
Table table = clazz.getAnnotation(Table.class);
if (table != null) {
String tableName = Utils.getTableName(clazz, table);
String mimeName = Utils.getMimeName(clazz, table);

// Add the plural version
mMatcher.addURI(authority, tableName, details.size());
details.add(new MatchDetail(tableName, "vnd.android.cursor.dir/vnd." + getAuthority() + "." + mimeName, null));

// Add the singular version
mMatcher.addURI(authority, tableName + "/#", details.size());
details.add(new MatchDetail(tableName, "vnd.android.cursor.item/vnd." + getAuthority() + "." + mimeName, new ForcedColumn(BaseColumns._ID, 1)));

// Add the hierarchical versions
for (Field field : clazz.getFields()) {

ForeignKey foreignKey = field.getAnnotation(ForeignKey.class);

if(foreignKey == null) {
continue;
}

Column column = field.getAnnotation(Column.class);
if(column == null) {
continue;
}

Class<?> parent = foreignKey.references();

Table parentTable = parent.getAnnotation(Table.class);

if(parentTable == null) {
throw new IllegalArgumentException("Parent is not a table!");
}

String parentTableName = Utils.getTableName(parent, parentTable);

String foreignKeyColumn = null;

try {
foreignKeyColumn = field.get(null).toString();
} catch (IllegalAccessException e) {
throw new IllegalArgumentException("Can't read the field name, needs to be static");
}

String nestedUri = parentTableName + "/#/" + tableName;
mMatcher.addURI(authority, nestedUri, details.size());
details.add(new MatchDetail(tableName, "vnd.android.cursor.dir/vnd." + getAuthority() + "." + mimeName, new ForcedColumn(foreignKeyColumn, 1)));
}
}
}

// Populate the rest.
mMatchDetails = details.toArray(new MatchDetail[details.size()]);
}
}

private static class MatchDetail
{
public final String tableName;
public final String mimeType;
public final ForcedColumn forcedColumn;

public MatchDetail(String tableName, String mimeType, ForcedColumn forcedColumn) {
this.tableName = tableName;
this.mimeType = mimeType;
this.forcedColumn = forcedColumn;
}
}

private static class ForcedColumn {
public final String columnName;
public final int pathSegment;

private ForcedColumn(String columnName, int pathSegment) {
this.columnName = columnName;
this.pathSegment = pathSegment;
}
}

/**
* Called when the database needs to be updated and after <code>AbstractProvider</code> has
* done its own work. That is, after creating columns that have been added using the
Expand Down Expand Up @@ -93,7 +198,15 @@ protected int getSchemaVersion() {

@Override
public String getType(Uri uri) {
return null;
initializeMatcher();

int match = mMatcher.match(uri);

if(match == UriMatcher.NO_MATCH) {
return null;
}

return mMatchDetails[match].mimeType;
}

@Override
Expand All @@ -118,33 +231,56 @@ private ContentResolver getContentResolver() {
}

private SelectionBuilder buildBaseQuery(Uri uri) {
List<String> pathSegments = uri.getPathSegments();
if (pathSegments == null) {
return null;
initializeMatcher();

int match = mMatcher.match(uri);

if(match == UriMatcher.NO_MATCH) {
throw new IllegalArgumentException("Unsupported content uri");
}

SelectionBuilder builder = new SelectionBuilder(pathSegments.get(0));
MatchDetail detail = mMatchDetails[match];

SelectionBuilder builder = new SelectionBuilder(detail.tableName);

if (pathSegments.size() == 2) {
builder.whereEquals(BaseColumns._ID, uri.getLastPathSegment());
List<String> segments = uri.getPathSegments();
if(detail.forcedColumn != null) {
builder.whereEquals(detail.forcedColumn.columnName, segments.get(detail.forcedColumn.pathSegment));
}

return builder;
}

@Override
public Uri insert(Uri uri, ContentValues values) {
List<String> segments = uri.getPathSegments();
if (segments == null || segments.size() != 1) {
return null;
initializeMatcher();

int match = mMatcher.match(uri);

if(match == UriMatcher.NO_MATCH) {
throw new IllegalArgumentException("Unsupported content uri");
}

MatchDetail detail = mMatchDetails[match];

if(detail.forcedColumn != null) {
// most likely a foreign key, so let's add it to the values.
values.put(detail.forcedColumn.columnName, Long.parseLong(uri.getPathSegments().get(detail.forcedColumn.pathSegment)));
}

long rowId = mDatabase.insert(segments.get(0), null, values);
long rowId = mDatabase.insert(detail.tableName, null, values);

if (rowId > -1) {
Uri canonical = Uri.parse("content://" + getAuthority() + "/" + detail.tableName);

getContentResolver().notifyChange(uri, null);
// when these differ it means there are two logical collections that the
// content observers may be interested in.
if(!canonical.equals(uri)) {
getContentResolver().notifyChange(canonical, null);
}

return ContentUris.withAppendedId(uri, rowId);
return ContentUris.withAppendedId(canonical, rowId);
}

return null;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
package de.triplet.simpleprovider;

import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;

@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.FIELD)
public @interface ForeignKey {
Class<?> references();
}
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
package de.triplet.simpleprovider;

import android.annotation.TargetApi;
import android.content.Context;
import android.database.sqlite.SQLiteDatabase;
import android.database.sqlite.SQLiteException;
import android.database.sqlite.SQLiteOpenHelper;
import android.os.Build;
import android.text.TextUtils;
import android.util.Log;

Expand All @@ -14,6 +16,8 @@ public class SimpleSQLHelper extends SQLiteOpenHelper {

private Class<?> mTableClass;

protected final static boolean mForeignKeysEnabled = Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN;

public SimpleSQLHelper(Context context, String fileName, int schemaVersion) {
super(context, fileName, null, schemaVersion);
}
Expand All @@ -30,6 +34,15 @@ private Class<?> getTableClass() {
}
}

@TargetApi(Build.VERSION_CODES.JELLY_BEAN)
@Override
public void onConfigure(SQLiteDatabase db) {
super.onConfigure(db);
// N.B. this will only be called on JELLY_BEAN and above,
// so the field will match exactly.
db.setForeignKeyConstraintsEnabled(mForeignKeysEnabled);
}

@Override
public void onCreate(SQLiteDatabase db) {
createTables(db);
Expand Down Expand Up @@ -62,8 +75,10 @@ private void createTable(SQLiteDatabase db, String tableName, Class<?> tableClas
for (Field field : tableClass.getFields()) {
Column column = field.getAnnotation(Column.class);
if (column != null) {
ForeignKey foreignKey = field.getAnnotation(ForeignKey.class);

try {
columns.add(Utils.getColumnConstraint(field, column));
columns.add(Utils.getColumnConstraint(field, column, mForeignKeysEnabled ? foreignKey : null));
} catch (Exception e) {
Log.e("SimpleSQLHelper", "Error accessing " + field, e);
}
Expand Down Expand Up @@ -95,8 +110,9 @@ private void upgradeTable(SQLiteDatabase db, int oldVersion, int newVersion, Str
if (column != null) {
int since = column.since();
if (oldVersion < since && newVersion >= since) {
ForeignKey foreignKey = field.getAnnotation(ForeignKey.class);
try {
db.execSQL("ALTER TABLE " + tableName + " ADD COLUMN " + Utils.getColumnConstraint(field, column) + ";");
db.execSQL("ALTER TABLE " + tableName + " ADD COLUMN " + Utils.getColumnConstraint(field, column, mForeignKeysEnabled ? foreignKey : null) + ";");
} catch (Exception e) {
Log.e("SimpleSQLHelper", "Error accessing " + field, e);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,4 +13,5 @@

int since() default 1;

String mimeSuffix() default "";
}
46 changes: 41 additions & 5 deletions simpleprovider/src/main/java/de/triplet/simpleprovider/Utils.java
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package de.triplet.simpleprovider;

import android.provider.BaseColumns;
import android.text.TextUtils;

import java.lang.reflect.Field;
Expand All @@ -19,6 +20,14 @@ static String getTableName(Class<?> clazz, Table table) {
}
}

static String getMimeName(Class<?> clazz, Table table) {
String mimeName = table.mimeSuffix();
if(TextUtils.isEmpty(mimeName)) {
return clazz.getSimpleName().toLowerCase(java.util.Locale.US);
}
return mimeName;
}

static String pluralize(String string) {
string = string.toLowerCase();

Expand All @@ -39,11 +48,38 @@ static String pluralize(String string) {
}
}

static String getColumnConstraint(Field field, Column column) throws IllegalAccessException {
return field.get(null) + " " + column.value()
+ (column.primaryKey() ? " PRIMARY KEY" : "")
+ (column.notNull() ? " NOT NULL" : "")
+ (column.unique() ? " UNIQUE" : "");
static String getColumnConstraint(Field field, Column column, ForeignKey foreignKey) throws IllegalAccessException {
StringBuilder columnDefinition = new StringBuilder();
columnDefinition.append(field.get(null));
columnDefinition.append(" " + column.value());

if(column.primaryKey()) {
columnDefinition.append(" PRIMARY KEY");
}

if(column.notNull()) {
columnDefinition.append(" NOT NULL");
}

if(column.unique()) {
columnDefinition.append(" UNIQUE");
}

// According to SQLite documentation don't have to worry about the order of table creation
// when creating foreign key constraints, as they are only checked when DML statements
// are executed; see: https://www.sqlite.org/foreignkeys.html#fk_schemacommands
if(foreignKey != null) {
columnDefinition.append(" REFERENCES ");
Class<?> parentClazz = foreignKey.references();
Table parentTable = parentClazz.getAnnotation(Table.class);
columnDefinition.append(getTableName(parentClazz, parentTable));
columnDefinition.append("(" + BaseColumns._ID + ")"); // always use _id as the parent key.

// defer the constraints so that batch operations can be done unordered.
columnDefinition.append(" DEFERRABLE INITIALLY DEFERRED");
}

return columnDefinition.toString();
}

}
Loading