diff --git a/simpleprovider/src/main/java/de/triplet/simpleprovider/AbstractProvider.java b/simpleprovider/src/main/java/de/triplet/simpleprovider/AbstractProvider.java index ec4e59b..40d5713 100644 --- a/simpleprovider/src/main/java/de/triplet/simpleprovider/AbstractProvider.java +++ b/simpleprovider/src/main/java/de/triplet/simpleprovider/AbstractProvider.java @@ -8,6 +8,7 @@ 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; @@ -15,6 +16,7 @@ import android.provider.BaseColumns; import android.util.Log; +import java.lang.reflect.Field; import java.util.ArrayList; import java.util.List; @@ -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 @@ -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 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 AbstractProvider has * done its own work. That is, after creating columns that have been added using the @@ -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 @@ -118,15 +231,21 @@ private ContentResolver getContentResolver() { } private SelectionBuilder buildBaseQuery(Uri uri) { - List 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 segments = uri.getPathSegments(); + if(detail.forcedColumn != null) { + builder.whereEquals(detail.forcedColumn.columnName, segments.get(detail.forcedColumn.pathSegment)); } return builder; @@ -134,17 +253,34 @@ private SelectionBuilder buildBaseQuery(Uri uri) { @Override public Uri insert(Uri uri, ContentValues values) { - List 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; diff --git a/simpleprovider/src/main/java/de/triplet/simpleprovider/ForeignKey.java b/simpleprovider/src/main/java/de/triplet/simpleprovider/ForeignKey.java new file mode 100644 index 0000000..41f3bcb --- /dev/null +++ b/simpleprovider/src/main/java/de/triplet/simpleprovider/ForeignKey.java @@ -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(); +} diff --git a/simpleprovider/src/main/java/de/triplet/simpleprovider/SimpleSQLHelper.java b/simpleprovider/src/main/java/de/triplet/simpleprovider/SimpleSQLHelper.java index e428719..6dd5b9b 100644 --- a/simpleprovider/src/main/java/de/triplet/simpleprovider/SimpleSQLHelper.java +++ b/simpleprovider/src/main/java/de/triplet/simpleprovider/SimpleSQLHelper.java @@ -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; @@ -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); } @@ -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); @@ -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); } @@ -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); } diff --git a/simpleprovider/src/main/java/de/triplet/simpleprovider/Table.java b/simpleprovider/src/main/java/de/triplet/simpleprovider/Table.java index 34fb284..1943a4d 100644 --- a/simpleprovider/src/main/java/de/triplet/simpleprovider/Table.java +++ b/simpleprovider/src/main/java/de/triplet/simpleprovider/Table.java @@ -13,4 +13,5 @@ int since() default 1; + String mimeSuffix() default ""; } diff --git a/simpleprovider/src/main/java/de/triplet/simpleprovider/Utils.java b/simpleprovider/src/main/java/de/triplet/simpleprovider/Utils.java index 5f5dc71..0939b0f 100644 --- a/simpleprovider/src/main/java/de/triplet/simpleprovider/Utils.java +++ b/simpleprovider/src/main/java/de/triplet/simpleprovider/Utils.java @@ -1,5 +1,6 @@ package de.triplet.simpleprovider; +import android.provider.BaseColumns; import android.text.TextUtils; import java.lang.reflect.Field; @@ -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(); @@ -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(); } } diff --git a/simpleprovider/src/test/java/de/triplet/simpleprovider/SimpleProviderTest.java b/simpleprovider/src/test/java/de/triplet/simpleprovider/SimpleProviderTest.java index c0efe98..bce450c 100644 --- a/simpleprovider/src/test/java/de/triplet/simpleprovider/SimpleProviderTest.java +++ b/simpleprovider/src/test/java/de/triplet/simpleprovider/SimpleProviderTest.java @@ -1,6 +1,7 @@ package de.triplet.simpleprovider; import android.content.ContentResolver; +import android.content.ContentUris; import android.content.ContentValues; import android.database.Cursor; import android.net.Uri; @@ -24,6 +25,8 @@ public class SimpleProviderTest { private static final String CONTENT_1 = "This is some content we want to store in a post!"; private static final String CONTENT_2 = "Another post"; + private static final String COMMENT_RESPONSE_1 = "this is a comment!"; + private static final String COMMENT_RESPONSE_2 = "this is a second comment!"; TestProvider mProvider; ContentResolver mContentResolver; Uri mPostsUri; @@ -54,8 +57,7 @@ public void testInsert() { assertNotNull("Resulting cursor must not be null", c); assertEquals("Query should return one post", c.getCount(), 1); assertTrue(c.moveToFirst()); - assertEquals("Entry should have the correct content", - CONTENT_1, c.getString(c.getColumnIndex(TestProvider.Post.CONTENT))); + assertEquals("Entry should have the correct content", CONTENT_1, c.getString(c.getColumnIndex(TestProvider.Post.CONTENT))); } @Test @@ -80,8 +82,80 @@ public void testQueryById() { // Make sure the query has returned the correct entity assertNotNull("Resulting cursor must not be null", c); assertTrue("We should be able to get the first entry", c.moveToFirst()); - assertEquals("Entry should have the correct content", - CONTENT_2, c.getString(c.getColumnIndex(TestProvider.Post.CONTENT))); + assertEquals("Entry should have the correct content", CONTENT_2, c.getString(c.getColumnIndex(TestProvider.Post.CONTENT))); assertFalse("There shouldn't be any more entries", c.moveToNext()); } + + @Test + public void testInsertByParentId() { + ContentValues values = new ContentValues(); + values.put(TestProvider.Post.CONTENT, "first post"); + + Uri firstPost = mContentResolver.insert(mPostsUri, values); + + ContentValues comment = new ContentValues(); + + comment.put(TestProvider.Comment.RESPONSE, COMMENT_RESPONSE_1); + + Uri commentUri = mContentResolver.insert(Uri.parse("content://" + TestProvider.AUTHORITY + "/posts/" + Long.toString(ContentUris.parseId(firstPost)) + "/comments"), comment); + + // Perform a "SELECT * FROM posts" query + Cursor c = mContentResolver.query(commentUri, new String[] { TestProvider.Comment.POST, TestProvider.Comment.RESPONSE}, null, null, null); + + // Make sure the query has returned (the) one element + assertNotNull("Resulting cursor must not be null", c); + assertEquals("Query should return one comment", c.getCount(), 1); + assertTrue(c.moveToFirst()); + assertEquals("Entry should have the correct response", COMMENT_RESPONSE_1, c.getString(c.getColumnIndex(TestProvider.Comment.RESPONSE))); + assertEquals("Entry should have the correct post_id", ContentUris.parseId(firstPost), c.getLong(c.getColumnIndex(TestProvider.Comment.POST))); + + } + + @Test + public void testQueryByParentId() { + ContentValues values = new ContentValues(); + values.put(TestProvider.Post.CONTENT, "first post"); + + Uri firstPost = mContentResolver.insert(mPostsUri, values); + + values.put(TestProvider.Post.CONTENT, "second"); + + Uri secondPost = mContentResolver.insert(mPostsUri, values); + + ContentValues firstComment = new ContentValues(); + firstComment.put(TestProvider.Comment.RESPONSE, COMMENT_RESPONSE_1); + firstComment.put(TestProvider.Comment.POST, ContentUris.parseId(firstPost)); + + Uri commentOne = mContentResolver.insert(Uri.parse("content://" + TestProvider.AUTHORITY + "/comments"), firstComment); + + ContentValues secondComment = new ContentValues(); + secondComment.put(TestProvider.Comment.RESPONSE, COMMENT_RESPONSE_2); + secondComment.put(TestProvider.Comment.POST, ContentUris.parseId(secondPost)); + + mContentResolver.insert(Uri.parse("content://" + TestProvider.AUTHORITY + "/comments"), secondComment); + + Uri firstPostComments = Uri.parse("content://" + TestProvider.AUTHORITY + "/posts/" + ContentUris.parseId(firstPost) + "/comments"); + + // Now get all the comments for the first post. + Cursor c = mContentResolver.query(firstPostComments, new String[] { TestProvider.Comment.ID, TestProvider.Comment.POST, TestProvider.Comment.RESPONSE}, null, null, null); + + // Make sure the query has returned (the) one element + assertNotNull("Resulting cursor must not be null", c); + assertEquals("Query should return one comment", c.getCount(), 1); + assertTrue(c.moveToFirst()); + assertEquals("Entry should have the correct id", ContentUris.parseId(commentOne), c.getLong(c.getColumnIndex(TestProvider.Comment.ID))); + assertEquals("Entry should have the correct response", COMMENT_RESPONSE_1, c.getString(c.getColumnIndex(TestProvider.Comment.RESPONSE))); + assertEquals("Entry should have the correct post_id", ContentUris.parseId(firstPost), c.getLong(c.getColumnIndex(TestProvider.Comment.POST))); + } + + @Test + public void testGetType() { + String actual = mContentResolver.getType(mPostsUri); + + assertEquals("vnd.android.cursor.dir/vnd." + TestProvider.AUTHORITY + ".post", actual); + + actual = mContentResolver.getType(ContentUris.withAppendedId(mPostsUri , 1)); + + assertEquals("vnd.android.cursor.item/vnd." + TestProvider.AUTHORITY + ".post", actual); + } } diff --git a/simpleprovider/src/test/java/de/triplet/simpleprovider/TestProvider.java b/simpleprovider/src/test/java/de/triplet/simpleprovider/TestProvider.java index 780f05e..099b5a7 100644 --- a/simpleprovider/src/test/java/de/triplet/simpleprovider/TestProvider.java +++ b/simpleprovider/src/test/java/de/triplet/simpleprovider/TestProvider.java @@ -12,10 +12,23 @@ protected String getAuthority() { @Table public class Post { - @Column(Column.FieldType.INTEGER) + @Column(value = Column.FieldType.INTEGER, primaryKey = true) public static final String ID = "_id"; @Column(Column.FieldType.TEXT) public static final String CONTENT = "content"; } + + @Table + public class Comment { + @Column(value = Column.FieldType.INTEGER, primaryKey = true) + public static final String ID = "_id"; + + @Column(Column.FieldType.TEXT) + public static final String RESPONSE = "response"; + + @Column(value = Column.FieldType.INTEGER, notNull = true) + @ForeignKey(references = Post.class) + public static final String POST = "post_id"; + } }