Search code examples
javaandroidsqlitekotlinfts4

Using SQLite Triggers on Android Returns Error


I'm new to SQLite, I'm trying to create an application where a user can create tasks and attach reminders to them. I'm using SQLite database to save these items. Everything works perfectly, now i want to implement a full text search feature, i have read on SQLite documentations that using FTS4 VIRTUAL TABLE is a way better than the ordinary one.

  • so in order to keep the virtual table synced i had to use triggers. but an error occurs after calling execSQL("//*Trigger code*//")

here are my triggers (using them in the same order as mentioned in the documentations) :

object SQLiteTriggerUtils {

    fun getBeforeDeleteTrigger(mainTable : String,
                               ftsTable : String,
                               rowId : Int?) : String {

        return "CREATE TRIGGER table_bd" +
                " BEFORE DELETE ON $mainTable" +
                " BEGIN DELETE FROM $ftsTable" +
                " WHERE docid=$rowId END;"
    }

    fun getBeforeUpdateTrigger(mainTable: String,
                               ftsTable: String,
                               rowId: Int?) : String {

        return "CREATE TRIGGER table_bu" +
                " BEFORE UPDATE ON $mainTable" +
                " BEGIN DELETE FROM $ftsTable" +
                " WHERE docid=$rowId END;"
    }

    fun getAfterUpdateTrigger(
        mainTable: String,
        ftsTable: String,
        rowId: Int?,
        updatedField: String,
        updatedValue: String?
    ) : String {

        return "CREATE TRIGGER table_au" +
                " AFTER UPDATE ON $mainTable" +
                " BEGIN INSERT INTO $ftsTable(docid, $updatedField)" +
                " VALUES($rowId, $updatedValue) END;"
    }

    fun getAfterInsertTrigger(
        mainTable: String,
        ftsTable: String,
        rowId: Int?,
        updatedField: String,
        updatedValue: String?
    ) : String {

        return "CREATE TRIGGER table_ai" +
                " AFTER INSERT ON $mainTable" +
                " BEGIN INSERT INTO $ftsTable(docid, $updatedField)" +
                " VALUES($rowId, $updatedValue) END;"
    }
}

here's my database onCreate() method :

override fun onCreate(db: SQLiteDatabase) {
        val CREATION_TABLE = ("CREATE TABLE $TABLE_NAME ( "
                + "$KEY_ID INTEGER PRIMARY KEY AUTOINCREMENT, "
                + "$KEY_LABEL TEXT, "
                + "$KEY_DESCRIPTION TEXT, "
                + "$KEY_IMPORTANCE INTEGER,"
                + "$KEY_LOGO INTEGER,"
                + "$KEY_TO_DO_DATE TEXT,"
                + "$KEY_CREATION_DATE TEXT)")
        val FTS_CREATION_TABLE = ("CREATE VIRTUAL TABLE $FTS_TABLE_NAME USING fts4 (content='$TABLE_NAME', $KEY_LABEL)")
        db.execSQL(CREATION_TABLE)
        db.execSQL(FTS_CREATION_TABLE)
    } 

override fun onUpgrade(db: SQLiteDatabase, oldVersion: Int, newVersion: Int) {
        db.execSQL("DROP TABLE IF EXISTS $TABLE_NAME")
        db.execSQL("DROP TABLE IF EXISTS $FTS_TABLE_NAME")
        onCreate(db)
    }

example excuting triggers onDeleteItem() method :

override fun deleteItem(itemId: Int): Boolean {
        var success : Boolean
        writableDatabase.apply {
            execSQL(SQLiteTriggerUtils.getBeforeDeleteTrigger(TABLE_NAME, FTS_TABLE_NAME, itemId))
            success = delete(TABLE_NAME, "id = ?", arrayOf(itemId.toString())) > 0
            close()
        }
        return success
    }

returned error :

android.database.sqlite.SQLiteException: near "END": syntax error (Sqlite code 1): , while compiling: CREATE TRIGGER table_bd BEFORE DELETE ON todo_tasks BEGIN DELETE FROM fts_todo_tasks WHERE docid=1 END;, (OS error - 2:No such file or directory)

Solution

  • I think your issues after correcting the syntax error of following the triggered action(s) that is coded between BEGIN and END with a semi-colon as per :-

    enter image description here

    May have been that you tried adding the triggers before you knew the value (might not be so would depend upon the code).

    Is that a trigger is an entity, like a view,table,index etc that forms part of the schema and therefore requires a unique name. So CREATE TRIGGER the_trigger_name ...... requires that the_trigger_name is unique. It appears that you may be trying to create the same trigger every time the action that would invoke the trigger is about to be actioned and would then fail as the trigger already exists.

    You could use CREATE TRIGGER IF NOT EXISTS the_trigger_name ......, however that existing trigger would then be used.

    Therefore you would probably want to DROP TRIGGER the_trigger_name before creating the trigger.

    However!

    Saying that the expected use of triggers is to perform a similar action automatically when the event (UPDATE, DELETE or INSERT) happens (actually immediately before or after). As such the trigger has access to the columns of the row which causes the trigger to be actioned (triggering action).

    If the triggering action is INSERT then the columns of the row being inserted can be referenced using new.column and used in the triggered action (the action(s) specified between the BEGIN and END).

    If the triggering action is DELETE then old.column can be used to referenced the columns of the row being deleted.

    If the triggering action is UPDATE then both new.column and old.column can be referenced.

    • where .column is replaced by the respective column.

    As such when the documentation says :-

    Instead of writing separately to the full-text index and the content table, some users may wish to use database triggers to keep the full-text index up to date with respect to the set of documents stored in the content table. For example, using the tables from earlier examples:

    CREATE TRIGGER t2_bu BEFORE UPDATE ON t2 BEGIN
      DELETE FROM t3 WHERE docid=old.rowid;
    END;
    CREATE TRIGGER t2_bd BEFORE DELETE ON t2 BEGIN
      DELETE FROM t3 WHERE docid=old.rowid;
    END;
    
    CREATE TRIGGER t2_au AFTER UPDATE ON t2 BEGIN
      INSERT INTO t3(docid, b, c) VALUES(new.rowid, new.b, new.c);
    END;
    CREATE TRIGGER t2_ai AFTER INSERT ON t2 BEGIN
      INSERT INTO t3(docid, b, c) VALUES(new.rowid, new.b, new.c);
    END;
    

    You will see that it uses old.docid and new.rowid

    Thus old.docid will be the docid of the row being deleted updated and new.rowid will be the rowid of the row being inserted into the $TABLE_NAME. As such each trigger only needs to be defined once as it is generic.

    As such I believe that you could use :-

    val TRG_BD = "trigger_bd" //<<<<<<<<<< ADDED >>>>>>>>>>
    val TRG_BU = "trigger_bu" //<<<<<<<<<< ADDED >>>>>>>>>>
    val TRG_AU = "trigger_au" //<<<<<<<<<< ADDED >>>>>>>>>>
    val TRG_AI = "trigger_ai" //<<<<<<<<<< ADDED >>>>>>>>>>
    
    override fun onCreate(db: SQLiteDatabase) {
        val CREATION_TABLE = ("CREATE TABLE $TABLE_NAME ( "
                + "$KEY_ID INTEGER PRIMARY KEY AUTOINCREMENT, "
                + "$KEY_LABEL TEXT, "
                + "$KEY_DESCRIPTION TEXT, "
                + "$KEY_IMPORTANCE INTEGER,"
                + "$KEY_LOGO INTEGER,"
                + "$KEY_TO_DO_DATE TEXT,"
                + "$KEY_CREATION_DATE TEXT)")
        val FTS_CREATION_TABLE = ("CREATE VIRTUAL TABLE $FTS_TABLE_NAME USING fts4 (content='$TABLE_NAME', $KEY_LABEL)")
    
        //<<<<<<<<<< ADDED FOLLOWING LINES  >>>>>>>>
        val BD_CREATION_TRIGGER = ("CREATE TRIGGER IF NOT EXISTS $TRG_BD " +
                "BEFORE DELETE ON $TABLE_NAME " +
                "BEGIN " +
                "DELETE FROM $FTS_TABLE_NAME " +
                "WHERE docid=old.rowid; " +
                "END;")
        val BU_CREATION_TRIGGER = ("CREATE TRIGGER IF NOT EXISTS $TRG_BU " +
                "BEFORE UPDATE ON $TABLE_NAME " +
                "BEGIN " +
                "DELETE FROM $FTS_TABLE_NAME " +
                "WHERE docid=old.rowid; " +
                "END;")
        val AU_CREATION_TRIGGER = ("CREATE TRIGGER IF NOT EXISTS $TRG_AU " +
                "AFTER UPDATE ON $TABLE_NAME " +
                "BEGIN " +
                "INSERT INTO $FTS_TABLE_NAME (docid, $KEY_LABEL) VALUES(new.$KEY_ID,new.$KEY_LABEL); " + //<<<<<<<< not sure $KEY_LABEL is correct column
                "END;")
        val AI_CREATION_TRIGGER = ("CREATE TRIGGER IF NOT EXISTS $TRG_AI " +
                "AFTER INSERT ON $TABLE_NAME " +
                "BEGIN " +
                "INSERT INTO $FTS_TABLE_NAME (docid, $KEY_LABEL) VALUES(new.$KEY_ID,new.$KEY_LABEL); " +
                "END;")
        //<<<<<<<<<< END OF ADDED LINES >>>>>>>>
    
    
        db.execSQL(CREATION_TABLE)
        db.execSQL(FTS_CREATION_TABLE)
        db.execSQL(BD_CREATION_TRIGGER) //<<<<<<<<<< ADDED >>>>>>>>>>
        db.execSQL(BU_CREATION_TRIGGER) //<<<<<<<<<< ADDED >>>>>>>>>>
        db.execSQL(AU_CREATION_TRIGGER) //<<<<<<<<<< ADDED >>>>>>>>>>
        db.execSQL(AI_CREATION_TRIGGER) //<<<<<<<<<< ADDED >>>>>>>>>>
    }
    
    override fun onUpgrade(db: SQLiteDatabase, oldVersion: Int, newVersion: Int) {
        db.execSQL("DROP TABLE IF EXISTS $TABLE_NAME")
        db.execSQL("DROP TABLE IF EXISTS $FTS_TABLE_NAME")
        db.execSQL("DROP TRIGGER IF EXISTS $TRG_BD") //<<<<<<<<<< ADDDED >>>>>>>>
        db.execSQL("DROP TRIGGER IF EXISTS $TRG_BU") //<<<<<<<<<< ADDDED >>>>>>>>
        db.execSQL("DROP TRIGGER IF EXISTS $TRG_AU") //<<<<<<<<<< ADDDED >>>>>>>>
        db.execSQL("DROP TRIGGER If EXISTS $TRG_AI") //<<<<<<<<<< ADDDED >>>>>>>>
        onCreate(db)
    }
    

    The above would replace (i.e. the following would then not be needed) :-

    object SQLiteTriggerUtils {
    
        fun getBeforeDeleteTrigger(mainTable : String,
                                   ftsTable : String,
                                   rowId : Int?) : String {
    
            return "CREATE TRIGGER table_bd" +
                    " BEFORE DELETE ON $mainTable" +
                    " BEGIN DELETE FROM $ftsTable" +
                    " WHERE docid=$rowId END;"
        }
    
        fun getBeforeUpdateTrigger(mainTable: String,
                                   ftsTable: String,
                                   rowId: Int?) : String {
    
            return "CREATE TRIGGER table_bu" +
                    " BEFORE UPDATE ON $mainTable" +
                    " BEGIN DELETE FROM $ftsTable" +
                    " WHERE docid=$rowId END;"
        }
    
        fun getAfterUpdateTrigger(
            mainTable: String,
            ftsTable: String,
            rowId: Int?,
            updatedField: String,
            updatedValue: String?
        ) : String {
    
            return "CREATE TRIGGER table_au" +
                    " AFTER UPDATE ON $mainTable" +
                    " BEGIN INSERT INTO $ftsTable(docid, $updatedField)" +
                    " VALUES($rowId, $updatedValue) END;"
        }
    
        fun getAfterInsertTrigger(
            mainTable: String,
            ftsTable: String,
            rowId: Int?,
            updatedField: String,
            updatedValue: String?
        ) : String {
    
            return "CREATE TRIGGER table_ai" +
                    " AFTER INSERT ON $mainTable" +
                    " BEGIN INSERT INTO $ftsTable(docid, $updatedField)" +
                    " VALUES($rowId, $updatedValue) END;"
        }
    }
    

    and additionally, as the TRIGGERS run automatically

    override fun deleteItem(itemId: Int): Boolean {
            var success : Boolean
            writableDatabase.apply {
                execSQL(SQLiteTriggerUtils.getBeforeDeleteTrigger(TABLE_NAME, FTS_TABLE_NAME, itemId))
                success = delete(TABLE_NAME, "id = ?", arrayOf(itemId.toString())) > 0
                close()
            }
            return success
        }
    

    could be replaced with (and likewise for the others) :-

    override fun deleteItem(itemId: Int): Boolean {
            var success : Boolean
            writableDatabase.apply {
                success = delete(TABLE_NAME, "id = ?", arrayOf(itemId.toString())) > 0
                close()
            }
            return success
        }
    

    NOTE The above is in-principle code, it has not been tested or run. As such it may contain errors.

    • I would also suggest that the trigger names, rather than using au (after update) use more descriptive names and therefore that the naming convention is not as restrictive (e.g. say you wanted to use another after update trigger?).

    Working Example

    The following is a working example that demonstrates the triggers (note little Kotlin Experience, so the code might not be the best)

    DatabaseHelper.kt

    val DB_VERSION = 1;
    val DB_NAME = "mydb"
    
    public class DatabaseHelper(context: Context?) :
    
        SQLiteOpenHelper(context, DB_NAME, null, DB_VERSION) {
    
        val TABLE_NAME = "mytable"
        val FTS_TABLE_NAME = "myftstable"
        val TRG_BD = "trigger_bd"
        val TRG_BU = "trigger_bu"
        val TRG_AU = "trigger_au"
        val TRG_AI = "trigger_ai"
        val KEY_ID = "id";
        val KEY_LABEL = "label"
        val KEY_DESCRIPTION = "desctription"
        val KEY_IMPORTANCE = "importance";
        val KEY_LOGO = "logo";
        val KEY_TO_DO_DATE = "todo_date"
        val KEY_CREATION_DATE = "creation_date"
    
    
    
        override fun onCreate(db: SQLiteDatabase) {
            val CREATION_TABLE = ("CREATE TABLE $TABLE_NAME ( "
                    + "$KEY_ID INTEGER PRIMARY KEY AUTOINCREMENT, "
                    + "$KEY_LABEL TEXT, "
                    + "$KEY_DESCRIPTION TEXT, "
                    + "$KEY_IMPORTANCE INTEGER,"
                    + "$KEY_LOGO INTEGER,"
                    + "$KEY_TO_DO_DATE TEXT,"
                    + "$KEY_CREATION_DATE TEXT)")
            val FTS_CREATION_TABLE = ("CREATE VIRTUAL TABLE $FTS_TABLE_NAME USING fts4 (content='$TABLE_NAME', $KEY_LABEL)")
    
            //<<<<<<<<<< ADDED FOLLOWING LINES  >>>>>>>>
            val BD_CREATION_TRIGGER = ("CREATE TRIGGER IF NOT EXISTS $TRG_BD " +
                    "BEFORE DELETE ON $TABLE_NAME " +
                    "BEGIN " +
                    "DELETE FROM $FTS_TABLE_NAME " +
                    "WHERE docid=old.$KEY_ID; " +
                    "END;")
            val BU_CREATION_TRIGGER = ("CREATE TRIGGER IF NOT EXISTS $TRG_BU " +
                    "BEFORE UPDATE ON $TABLE_NAME " +
                    "BEGIN " +
                    "DELETE FROM $FTS_TABLE_NAME " +
                    "WHERE docid=old.$KEY_ID; " +
                    "END;")
            val AU_CREATION_TRIGGER = ("CREATE TRIGGER IF NOT EXISTS $TRG_AU " +
                    "AFTER UPDATE ON $TABLE_NAME " +
                    "BEGIN " +
                    "INSERT INTO $FTS_TABLE_NAME (docid, $KEY_LABEL) VALUES(new.$KEY_ID,new.$KEY_LABEL); " + //<<<<<<<< not sure $KEY_LABEL is correct column
                    "END;")
            val AI_CREATION_TRIGGER = ("CREATE TRIGGER IF NOT EXISTS $TRG_AI " +
                    "AFTER INSERT ON $TABLE_NAME " +
                    "BEGIN " +
                    "INSERT INTO $FTS_TABLE_NAME (docid, $KEY_LABEL) VALUES(new.$KEY_ID,new.$KEY_LABEL); " +
                    "END;")
            db.execSQL(CREATION_TABLE)
            db.execSQL(FTS_CREATION_TABLE)
            db.execSQL(BD_CREATION_TRIGGER) //<<<<<<<<<< ADDED >>>>>>>>>>
            db.execSQL(BU_CREATION_TRIGGER) //<<<<<<<<<< ADDED >>>>>>>>>>
            db.execSQL(AU_CREATION_TRIGGER) //<<<<<<<<<< ADDED >>>>>>>>>>
            db.execSQL(AI_CREATION_TRIGGER) //<<<<<<<<<< ADDED >>>>>>>>>>
        }
    
        override fun onUpgrade(db: SQLiteDatabase, oldVersion: Int, newVersion: Int) {
            db.execSQL("DROP TABLE IF EXISTS $TABLE_NAME")
            db.execSQL("DROP TABLE IF EXISTS $FTS_TABLE_NAME")
            db.execSQL("DROP TRIGGER IF EXISTS $TRG_BD") //<<<<<<<<<< ADDDED >>>>>>>>
            db.execSQL("DROP TRIGGER IF EXISTS $TRG_BU") //<<<<<<<<<< ADDDED >>>>>>>>
            db.execSQL("DROP TRIGGER IF EXISTS $TRG_AU") //<<<<<<<<<< ADDDED >>>>>>>>
            db.execSQL("DROP TRIGGER If EXISTS $TRG_AI") //<<<<<<<<<< ADDDED >>>>>>>>
            onCreate(db)
        }
    
        fun insert(label: String, description: String, importance: Int, tododate: String, creationdate: String ) {
    
            val cv = ContentValues()
            cv.put(KEY_LABEL,label)
            cv.put(KEY_DESCRIPTION,description)
            cv.put(KEY_IMPORTANCE,importance)
            cv.put(KEY_LOGO,0)
            cv.put(KEY_TO_DO_DATE,tododate)
            cv.put(KEY_CREATION_DATE,creationdate)
    
            val db = this.writableDatabase
            val inserted = db.insert(TABLE_NAME, null, cv )
            Log.d("INSERT","INSERT result in an id of " + inserted + ".")
        }
    
        fun update(id: Long, label: String) {
            val cv = ContentValues()
            cv.put(KEY_LABEL,label)
    
            val db = this.writableDatabase
            val updated = db.update(TABLE_NAME,cv,"$KEY_ID =" + id,null)
            Log.d("UPDATED","UPDATE resulted in " + updated + " rows being updated.")
        }
    
        fun delete(id: Long) {
            val whereclause = "$KEY_ID=" + id
    
            val db = this.writableDatabase
            val deleted = db.delete(TABLE_NAME,whereclause,null)
            Log.d("DELETED","DELETE resulted in " + deleted + " rows being deleted.")
        }
    
        fun logtables() {
            val db = this.writableDatabase
            val csr1 = db.query(TABLE_NAME, null, null, null, null, null, null)
            dumpCursor(csr1)
            val csr2 = db.query(FTS_TABLE_NAME,null,null,null,null,null,null)
            dumpCursor(csr2)
            csr1.close()
            csr2.close()
        }
    }
    

    MainActivity.kt

    class MainActivity : AppCompatActivity() {
    
        override fun onCreate(savedInstanceState: Bundle?) {
            super.onCreate(savedInstanceState)
            setContentView(R.layout.activity_main)
    
            val dbhelper = DatabaseHelper(this)
            dbhelper.insert("TEST001","Just Testing",10,"2019-01-01","2019-01-01")
            dbhelper.logtables()
            dbhelper.update(1,"001TEST")
            dbhelper.logtables()
            dbhelper.delete(1)
            dbhelper.logtables()
        }
    }
    

    Result (log)

    04-28 14:35:21.002 17810-17810/s.e.myapplication D/INSERT: INSERT result in an id of 1.
    04-28 14:35:21.002 17810-17810/s.e.myapplication I/System.out: >>>>> Dumping cursor android.database.sqlite.SQLiteCursor@11697ce
    04-28 14:35:21.002 17810-17810/s.e.myapplication I/System.out: 0 {
    04-28 14:35:21.002 17810-17810/s.e.myapplication I/System.out:    id=1
    04-28 14:35:21.002 17810-17810/s.e.myapplication I/System.out:    label=TEST001
    04-28 14:35:21.002 17810-17810/s.e.myapplication I/System.out:    desctription=Just Testing
    04-28 14:35:21.002 17810-17810/s.e.myapplication I/System.out:    importance=10
    04-28 14:35:21.002 17810-17810/s.e.myapplication I/System.out:    logo=0
    04-28 14:35:21.002 17810-17810/s.e.myapplication I/System.out:    todo_date=2019-01-01
    04-28 14:35:21.002 17810-17810/s.e.myapplication I/System.out:    creation_date=2019-01-01
    04-28 14:35:21.002 17810-17810/s.e.myapplication I/System.out: }
    04-28 14:35:21.002 17810-17810/s.e.myapplication I/System.out: <<<<<
    04-28 14:35:21.002 17810-17810/s.e.myapplication I/System.out: >>>>> Dumping cursor android.database.sqlite.SQLiteCursor@3396ef
    04-28 14:35:21.002 17810-17810/s.e.myapplication I/System.out: 0 {
    04-28 14:35:21.002 17810-17810/s.e.myapplication I/System.out:    label=TEST001
    04-28 14:35:21.003 17810-17810/s.e.myapplication I/System.out: }
    04-28 14:35:21.003 17810-17810/s.e.myapplication I/System.out: <<<<<
    

    i.e. the FTS has had a row inserted for TEST001

    04-28 14:35:21.007 17810-17810/s.e.myapplication D/UPDATED: UPDATE resulted in 1 rows being updated.
    04-28 14:35:21.007 17810-17810/s.e.myapplication I/System.out: >>>>> Dumping cursor android.database.sqlite.SQLiteCursor@9ce45fc
    04-28 14:35:21.007 17810-17810/s.e.myapplication I/System.out: 0 {
    04-28 14:35:21.007 17810-17810/s.e.myapplication I/System.out:    id=1
    04-28 14:35:21.007 17810-17810/s.e.myapplication I/System.out:    label=001TEST
    04-28 14:35:21.007 17810-17810/s.e.myapplication I/System.out:    desctription=Just Testing
    04-28 14:35:21.007 17810-17810/s.e.myapplication I/System.out:    importance=10
    04-28 14:35:21.007 17810-17810/s.e.myapplication I/System.out:    logo=0
    04-28 14:35:21.007 17810-17810/s.e.myapplication I/System.out:    todo_date=2019-01-01
    04-28 14:35:21.007 17810-17810/s.e.myapplication I/System.out:    creation_date=2019-01-01
    04-28 14:35:21.007 17810-17810/s.e.myapplication I/System.out: }
    04-28 14:35:21.007 17810-17810/s.e.myapplication I/System.out: <<<<<
    04-28 14:35:21.007 17810-17810/s.e.myapplication I/System.out: >>>>> Dumping cursor android.database.sqlite.SQLiteCursor@30ec485
    04-28 14:35:21.008 17810-17810/s.e.myapplication I/System.out: 0 {
    04-28 14:35:21.008 17810-17810/s.e.myapplication I/System.out:    label=001TEST
    04-28 14:35:21.008 17810-17810/s.e.myapplication I/System.out: }
    04-28 14:35:21.008 17810-17810/s.e.myapplication I/System.out: <<<<<
    

    i.e. the FTS table has been updated to reflect TEST001 being changed to 001TEST

    04-28 14:35:21.011 17810-17810/s.e.myapplication D/DELETED: DELETE resulted in 1 rows being deleted.
    04-28 14:35:21.011 17810-17810/s.e.myapplication I/System.out: >>>>> Dumping cursor android.database.sqlite.SQLiteCursor@10862da
    04-28 14:35:21.011 17810-17810/s.e.myapplication I/System.out: <<<<<
    04-28 14:35:21.011 17810-17810/s.e.myapplication I/System.out: >>>>> Dumping cursor android.database.sqlite.SQLiteCursor@d4cb30b
    04-28 14:35:21.011 17810-17810/s.e.myapplication I/System.out: <<<<<
    

    after the deletion from the non-fts table both are empty