Android/SQLite: Insert-Update table columns to keep the identifier
Asked Answered
G

6

25

Currently, I am using the following statement to create a table in an SQLite database on an Android device.

CREATE TABLE IF NOT EXISTS 'locations' (
  '_id' INTEGER PRIMARY KEY AUTOINCREMENT, 'name' TEXT, 
  'latitude' REAL, 'longitude' REAL, 
  UNIQUE ( 'latitude',  'longitude' ) 
ON CONFLICT REPLACE );

The conflict-clause at the end causes that rows are dropped when new inserts are done that come with the same coordinates. The SQLite documentation contains further information about the conflict-clause.

Instead, I would like to keep the former rows and just update their columns. What is the most efficient way to do this in a Android/SQLite environment?

  • As a conflict-clause in the CREATE TABLE statement.
  • As an INSERT trigger.
  • As a conditional clause in the ContentProvider#insert method.
  • ... any better you can think off

I would think it is more performant to handle such conflicts within the database. Also, I find it hard to rewrite the ContentProvider#insert method to consider the insert-update scenario. Here is code of the insert method:

public Uri insert(Uri uri, ContentValues values) {
    final SQLiteDatabase db = mOpenHelper.getWritableDatabase();
    long id = db.insert(DatabaseProperties.TABLE_NAME, null, values);
    return ContentUris.withAppendedId(uri, id);
}

When data arrives from the backend all I do is inserting the data as follows.

getContentResolver.insert(CustomContract.Locations.CONTENT_URI, contentValues);

I have problems figuring out how to apply an alternative call to ContentProvider#update here. Additionally, this is not my favored solution anyways.


Edit:

@CommonsWare: I tried to implement your suggestion to use INSERT OR REPLACE. I came up with this ugly piece of code.

private static long insertOrReplace(SQLiteDatabase db, ContentValues values, String tableName) {
    final String COMMA_SPACE = ", ";
    StringBuilder columnsBuilder = new StringBuilder();
    StringBuilder placeholdersBuilder = new StringBuilder();
    List<Object> pureValues = new ArrayList<Object>(values.size());
    Iterator<Entry<String, Object>> iterator = values.valueSet().iterator();
    while (iterator.hasNext()) {
        Entry<String, Object> pair = iterator.next();
        String column = pair.getKey();
        columnsBuilder.append(column).append(COMMA_SPACE);
        placeholdersBuilder.append("?").append(COMMA_SPACE);
        Object value = pair.getValue();
        pureValues.add(value);
    }
    final String columns = columnsBuilder.substring(0, columnsBuilder.length() - COMMA_SPACE.length());
    final String placeholders = placeholderBuilder.substring(0, placeholdersBuilder.length() - COMMA_SPACE.length());
    db.execSQL("INSERT OR REPLACE INTO " + tableName + "(" + columns + ") VALUES (" + placeholders + ")", pureValues.toArray());

    // The last insert id retrieved here is not safe. Some other inserts can happen inbetween.
    Cursor cursor = db.rawQuery("SELECT * from SQLITE_SEQUENCE;", null);
    long lastId = INVALID_LAST_ID;
    if (cursor != null && cursor.getCount() > 0 && cursor.moveToFirst()) {
        lastId = cursor.getLong(cursor.getColumnIndex("seq"));
    }
    cursor.close();
    return lastId;
}

When I check the SQLite database, however, equal columns are still removed and inserted with new ids. I do not understand why this happens and thought the reason is my conflict-clause. But the documentation states the opposite.

The algorithm specified in the OR clause of an INSERT or UPDATE overrides any algorithm specified in a CREATE TABLE. If no algorithm is specified anywhere, the ABORT algorithm is used.

Another disadvantage of this attempt is that you loose the value of the id which is return by an insert statement. To compensate this, I finally found an option to ask for the last_insert_rowid. It is as explained in the posts of dtmilano and swiz. I am, however, not sure if this is safe since another insert can happen inbetween.

Gastroenterology answered 27/7, 2012 at 11:13 Comment(8)
Have you tried INSERT OR REPLACE?Alternate
I guess you mean adding INSERT OR REPLACE on the CREATE statement. No, I have not since it is not written in the documentation. Could you please clarify what exactly do you recommend.Gastroenterology
No, I mean INSERT OR REPLACE. sqlite.org/lang_insert.htmlAlternate
I know how to use it in general but do not understand how to use it in this Android environment. Sorry.Gastroenterology
Use execSQL("INSERT OR REPLACE...") instead of insert() inside your ContentProvider implementation, with ... replaced by the rest of the SQL statement. For example: getWritableDatabase().execSQL("INSERT OR REPLACE INTO notes (position, prose) VALUES (?, ?)", args);Alternate
Ah! I did not see this. The disadvantage is that I need to "unpack" the ContentValues object to get the column names and their values. Do you think, there is any way around this?Gastroenterology
Not for INSERT AND DELETE. There may be other ways to skin the proverbial cat to address your problem, though I cannot think of any off the top of my head.Alternate
I had a similar situation and handled it with a condition/update clause if(db.update(TABLE, values, CONDITION, null) == 0){db.insert(TABLE, null, values)} this way the db tries to update first and if no rows are affected, i.e. no conflicts, it will add a new row.Olson
P
20

I can understand the perceived notion that it is best for performance to do all this logic in SQL, but perhaps the simplest (least code) solution is the best one in this case? Why not attempt the update first, and then use insertWithOnConflict() with CONFLICT_IGNORE to do the insert (if necessary) and get the row id you need:

public Uri insert(Uri uri, ContentValues values) {
    final SQLiteDatabase db = mOpenHelper.getWritableDatabase();
    String selection = "latitude=? AND longitude=?"; 
    String[] selectionArgs = new String[] {values.getAsString("latitude"),
                values.getAsString("longitude")};

    //Do an update if the constraints match
    db.update(DatabaseProperties.TABLE_NAME, values, selection, null);

    //This will return the id of the newly inserted row if no conflict
    //It will also return the offending row without modifying it if in conflict
    long id = db.insertWithOnConflict(DatabaseProperties.TABLE_NAME, null, values, CONFLICT_IGNORE);        

    return ContentUris.withAppendedId(uri, id);
}

A simpler solution would be to check the return value of update() and only do the insert if the affected count was zero, but then there would be a case where you could not obtain the id of the existing row without an additional select. This form of insert will always return to you the correct id to pass back in the Uri, and won't modify the database more than necessary.

If you want to do a large number of these at once, you might look at the bulkInsert() method on your provider, where you can run multiple inserts inside a single transaction. In this case, since you don't need to return the id of the updated record, the "simpler" solution should work just fine:

public int bulkInsert(Uri uri, ContentValues[] values) {
    final SQLiteDatabase db = mOpenHelper.getWritableDatabase();
    String selection = "latitude=? AND longitude=?";
    String[] selectionArgs = null;

    int rowsAdded = 0;
    long rowId;
    db.beginTransaction();
    try {
        for (ContentValues cv : values) {
            selectionArgs = new String[] {cv.getAsString("latitude"),
                cv.getAsString("longitude")};

            int affected = db.update(DatabaseProperties.TABLE_NAME, 
                cv, selection, selectionArgs);
            if (affected == 0) {
                rowId = db.insert(DatabaseProperties.TABLE_NAME, null, cv);
                if (rowId > 0) rowsAdded++;
            }
        }
        db.setTransactionSuccessful();
    } catch (SQLException ex) {
        Log.w(TAG, ex);
    } finally {
        db.endTransaction();
    }

    return rowsAdded;
}

In truth, the transaction code is what makes things faster by minimizing the number of times the database memory is written to the file, bulkInsert() just allows multiple ContentValues to be passed in with a single call to the provider.

Pilewort answered 2/8, 2012 at 15:54 Comment(6)
I appreciate your idea but I am concerned that a frontend operation will take to long on a large number of items. Maybe, my fear is without reason...Gastroenterology
If you are doing this over a large collection, you should implement the same logic inside of the ContentProvider.bulkInsert() method and use transactions to minimize the load on the actual database file I/OPilewort
Meanwhile, I realized that the sequential INSERT yields a large performance problem (read here). How would you design the bulk INSERT?Gastroenterology
Added. Basically do the insert/update pair over all the values passed in, all wrapped in a transaction.Pilewort
I switched to your solution. Initially, I did not recognized that bulkInsert() is a method of the content provider. It works quite good as fas as I can tell. Thank you, again. Now, I still need to sort out a minor update problem.Gastroenterology
db.insertWithOnConflict with CONFLICT_IGNORE will return -1 if there occurs a conflict. So you may not always get the correct row number effected by update.Welch
G
5

One solution is to create a view for the locations table with a INSTEAD OF trigger on the view, then insert into the view. Here's what that would look like:

View:

CREATE VIEW locations_view AS SELECT * FROM locations;

Trigger:

CREATE TRIGGER update_location INSTEAD OF INSERT ON locations_view FOR EACH ROW 
  BEGIN 
    INSERT OR REPLACE INTO locations (_id, name, latitude, longitude) VALUES ( 
       COALESCE(NEW._id, 
         (SELECT _id FROM locations WHERE latitude = NEW.latitude AND longitude = NEW.longitude)),
       NEW.name, 
       NEW.latitude, 
       NEW.longitude
    );
  END;

Instead of inserting into the locations table, you insert into the locations_view view. The trigger will take care of providing the correct _id value by using the sub-select. If, for some reason, the insert already contains an _id the COALESCE will keep it and override an existing one in the table.

You'll probably want to check how much the sub-select affects performance and compare that to other possible changes you could make, but it does allow you keep this logic out of your code.

I tried some other solutions involving triggers on the table itself based on INSERT OR IGNORE, but it seems that BEFORE and AFTER triggers only trigger if it will actually insert into the table.

You might find this answer helpful, which is the basis for the trigger.

Edit: Due to BEFORE and AFTER triggers not firing when an insert is ignored (which could then have been updated instead), we need to rewrite the insert with an INSTEAD OF trigger. Unfortunately, those don't work with tables - we have to create a view to use it.

Gomes answered 2/8, 2012 at 0:38 Comment(4)
I tested it and it works as far as I can see. Though, I do not understand what the value of NEW.id would be at an INSERT. I wonder, if it could be NULL instead. But this would contradict with that _id cannot be NULL?! And, why did you include OR REPLACE - is it just necessary in order to use COALESCE?Gastroenterology
Could you please add to your answer why is it necessary to create a view for the table to work on?Gastroenterology
Due to the change that each INSERT now runs on the VIEW I can no longer use another trigger that limits the number of rows. Here is the problem.Gastroenterology
Generally, NEW._id will be NULL - because the table uses AUTOINCREMENT it will create an _id for you. However, since it's valid to include an _id the COALESCE will ensure that's handled appropriately. OR REPLACE is included in order to maintain the existing _id since if the latitude and longitude already exist in the table it needs to replace that row.Gomes
T
3

INSERT OR REPLACE works just like ON CONFLICT REPLACE. It will delete the row if the row with the unique column already exists and than it does an insert. It never does update.

I would recommend you stick with your current solution, you create table with ON CONFLICT clausule, but every time you insert a row and the constraint violation occurs, your new row will have new _id as origin row will be deleted.

Or you can create table without ON CONFLICT clausule and use INSERT OR REPLACE, you can use insertWithOnConflict() method for that, but it is available since API level 8, requires more coding and leads to the same solution as table with ON CONFLICT clausule.

If you still want to keep your origin row, it means you want to keep the same _id you will have to make two queries, first one for inserting a row, second to update a row if insertion failed (or vice versa). To preserve consistency, you have to execute queries in a transaction.

    db.beginTransaction();
    try {
        long rowId = db.insert(table, null, values);
        if (rowId == -1) {
            // insertion failed
            String whereClause = "latitude=? AND longitude=?"; 
            String[] whereArgs = new String[] {values.getAsString("latitude"),
                    values.getAsString("longitude")};
            db.update(table, values, whereClause, whereArgs);
            // now you have to get rowId so you can return correct Uri from insert()
            // method of your content provider, so another db.query() is required
        }
        db.setTransactionSuccessful();
    } finally {
        db.endTransaction();
    }
Trimetallic answered 31/7, 2012 at 0:37 Comment(0)
M
0

Use insertWithOnConflict and set the last parameter (conflictAlgorithm) to CONFLICT_REPLACE.

Read more at the following links:

insertWithOnConflict documentation

CONFLICT_REPLACE flag

Mccurry answered 18/6, 2013 at 12:48 Comment(0)
F
0

for me, none of the approaches are work if I don't have "_id"

you should first call update, if the affected rows are zero, then insert it with ignore:

 String selection = MessageDetailTable.SMS_ID+" =?";
 String[] selectionArgs =  new String[] { String.valueOf(md.getSmsId())};

 int affectedRows = db.update(MessageDetailTable.TABLE_NAME, values, selection,selectionArgs);

 if(affectedRows<=0) {
     long id = db.insertWithOnConflict(MessageDetailTable.TABLE_NAME, null, values, SQLiteDatabase.CONFLICT_IGNORE);
 }
Fret answered 25/5, 2020 at 22:47 Comment(0)
N
-2

Use INSERT OR REPLACE.

This is the correct way to do it.

Notarize answered 3/8, 2012 at 18:29 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.