I'm following a tutorial on how to create a note app using Jetpack Compose. This is the link for the tutorial. There is a point in the tutorial that he creates this entity:
@Entity
data class Note(
val title: String,
val content: String,
val timestamp: Long,
val color: Int,
@PrimaryKey val id: Int? = null
)
Please take note that he is using a nullable value for the primary key and that he is not using the (autoGenerate = true)
.
I would have thought that wouldn't work. First because the primary key should never be null and second because without the autoGenerate how will the id behave? Won't they all have the same id?
The code I have seen more often when creating room entities is this:
@PrimaryKey(autoGenerate = true) val id: Int = 0
How does the @PrimaryKey
annotation works exactly? Why both ways work? Which one is more recommended?
Edit: I want to clarify that the code used in the tutorial compiles and it saves to the database properly.
I would have thought that wouldn't work. First because the primary key should never be null and second because without the autoGenerate how will the id behave?
Room actually uses null
, converting 0 to null
if autogenerate is true as SQLite, in the special case of an INTEGER PRIMARY KEY, generates a value when null
is specified.
The PRIMARY KEY constraint for a rowid table (as long as it is not the true primary key or INTEGER PRIMARY KEY) is really the same thing as a UNIQUE constraint. Because it is not a true primary key, columns of the PRIMARY KEY are allowed to be NULL, in violation of all SQL standards.
- bolded the exception that I believe infers that INTEGER PRIMARY KEY cannot be null.
- from https://www.sqlite.org/rowidtable.html
If autogenerate is true then the generated code (as per/from the demo below) includes:-
"INSERT OR IGNORE INTO `AutoGenTrueTable` (`id`,`name`) VALUES (nullif(?, 0),?)"
Whilst in the case of autogenerate being false the generated code instead uses:-
"INSERT OR IGNORE INTO `AutoGenFalseTable` (`id`,`name`) VALUES (?,?)"
note that the OR IGNORE
is because of the onConflictStrategy of IGNORE
.
the latter example, i.e. autogenerate being false, will thus use the value 0 if the the id field is 0
The generated java can be found easily via the Android View of Android Studio. The DAOs being in the class that is the same name as the interface/abstract class but suffixed with _Impl
.
the binding via the Android SQLite API used by Room converts the null to the null
keyword (token).
@Database
annotated class but with the _Impl
suffix, will have other useful code, such as in the createAllTables
method where the SQL for creating the tables can be found.Won't they all have the same id?
NO as a primary key is implicitly unique and therefore NEVER can an id be the same as another id in the same table if it is the primary key and irrespective of autogenerated being true or false.
If autogenerate
is true then Room also converts 0 to be no value provided and thus 0 results in a generated value.
However if you specify a value of 0 and if autogenerate is false (explicitly or implicitly by default) then 0 will be used for the id which will not be allowed more than once, but can be handled by the onConflictStrategy
of the insert.
The DEMO below illustrates the above (noting that the IGNORE onConflictStrategy is used and hence the errant duplicated 0 id's are just ignored).
A little bit about INTEGER PRIMARY KEY (e.g. @PrimaryKey val whatever:Int
or more correctly Long
) aka an alias of the rowid column.
Int
isn't large enough, Long
is, for many situations this isn't an issue).If a column is specifically INTEGER (which Room determines at compile time) and is the PRIMARY KEY (either at the column or table level) then the column is an alias of a special normally hidden column, the rowid, that all tables have (except WITHOUT ROWID tables, which Room does not support via annotations).
note that the rowid column ALWAYS exists (unless the table is a WITHOUT ROWID table, again which Room does not support) and is always assigned an integer value (irrespective of whether or not INTEGER PRIMARY KEY is specified or implied).
although rowid is used SQLite accepts other aliases see https://www.sqlite.org/rowidtable.html for more on rowids
Such a column must be an INTEGER type value (with the exception of the rowid column or an alias thereof any column type can actually store any type of value, although Room does not support this). Furthermore if no value is given for such a column when inserting then the value will be generated by SQLite. This will typically be 1 greater than the highest rowid for that table.
Hence as long as the situation is that no value is provided for the column then the value will be generated (and will very likely be 1 greater than the highest value).
If Room's autogenerate=true
is used then that adds the SQLite AUTOINCREMNET
keyword to the table definition/schema. This changes the way that the value is generated in that it is the greater of two values, one being the highest rowid in the table, the other being the highest recorded/ever used rowid value, which may be higher than the highest rowid, if the the row with the highest rowid has been deleted.
In short AUTOINCREMENT
adds a constraint/rule that says that the generated value MUST be greater than any used. However, this requires that the highest assigned value must be stored elsewhere. SQLite stores this additional value in a table named sqlite_sequence which will have 1 row per table. There is an overhead of obtaining and maintaining such a value and hence why the SQLite document states:-
Using null or 0 is the same with autogenerate=true
, Room does not provide a value and thus the value gets generated. If any other value is provided then that value is used (which will result in a unique conflict if a row already exists, the unique conflict handling with the onConFlictStrategy
parameter of the respective annotation ('@Insert' or @Update
) when inserting or updating)
As stated previously without autogenerate=true, 0 will be used as the value (see demo below), thus to not have the overheads/waste/inefficiency of AUTOINCREMENT then the field should be nullable and null used to have the value generated.
DEMO
Perhaps consider the following demo where 3 tables (entities) are used one where autogenerate=true is used the other 2 where it is not specified and thus autogenerate=false is implied. The difference between the other two is that the first does not allow nulls for the id and the default id is 0, the second does and the default value for the id is null.
The 3 @Entity
annotated classes are:-
@Entity
data class AutoGenTrueTable(
@PrimaryKey(autoGenerate = true)
val id: Long=0,
val name: String
)
@Entity
data class AutoGenFalseTable(
@PrimaryKey
val id: Long=0,
val name: String
)
@Entity
data class AutoGenFalseNullableTable(
@PrimaryKey
val id: Long?=null,
val name: String
)
To demonstrate sqlite_sequence (extract all data from it) then a POJO:-
data class SQLiteSequence(
val name: String,
val seq: Long
)
an @Dao
annotated interface (catering for inserting, specialised deletions and extracting data):-
@Dao
interface AllDAOs {
@Insert(onConflict = OnConflictStrategy.IGNORE)
fun insert(autoGenTrueTable: AutoGenTrueTable): Long
@Insert(onConflict = OnConflictStrategy.IGNORE)
fun insert(autoGenFalseTable: AutoGenFalseTable): Long
@Insert(onConflict = OnConflictStrategy.IGNORE)
fun insert(autoGenFalseNullableTable: AutoGenFalseNullableTable): Long
@Query("DELETE FROM autogentruetable WHERE id >=:highAsOrHigherThanId")
fun deleteFromAGTTByHighIds(highAsOrHigherThanId: Long)
@Query("DELETE FROM autogenfalsetable WHERE id >=:highAsOrHigherThanId")
fun deleteFromAGFTByHighIds(highAsOrHigherThanId: Long)
@Query("DELETE FROM autogenfalsenullabletable WHERE id >=:highAsOrHigherThanId")
fun deleteFromFalseNullableByHighIds(highAsOrHigherThanId: Long)
@Query("SELECT * FROM autogentruetable")
fun getAllFromAutoGenTrue(): List<AutoGenTrueTable>
@Query("SELECT * FROM autogenfalsetable")
fun getAllFromAutoGenFalse(): List<AutoGenFalseTable>
@Query("SELECT * FROM autogenfalsenullabletable")
fun getAllFromAutoGenFalseNullable(): List<AutoGenFalseNullableTable>
@Query("SELECT * FROM sqlite_sequence")
fun getAllFromSQLiteSequence(): List<SQLiteSequence>
}
A pretty straight forward @Database
annotated abstract class, allowing the main thread to be used for the brevity of the demo:-
@Database(entities = [AutoGenTrueTable::class,AutoGenFalseTable::class,AutoGenFalseNullableTable::class], exportSchema = false, version = 1)
abstract class TheDatabase: RoomDatabase() {
abstract fun getAllDAOs(): AllDAOs
companion object {
private var instance: TheDatabase? = null
fun getInstance(context: Context): TheDatabase {
if (instance == null) {
instance = Room.databaseBuilder(context, TheDatabase::class.java, "the_database.db")
.allowMainThreadQueries()
.build()
}
return instance as TheDatabase
}
}
}
Finally some activity code that inserts/extracts and deletes data, for all 3 demo tables, writing the extracted data, including the contents of sqlite_master to the log at various stages:-
class MainActivity : AppCompatActivity() {
lateinit var db: TheDatabase
lateinit var dao: AllDAOs
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
db = TheDatabase.getInstance(this)
dao = db.getAllDAOs()
var stage = 0
logEverything(stage++)
for (i: Int in 1..3) {
dao.insert(AutoGenTrueTable(0,"AG_TT_ZERO_${i}"))
//dao.insert(AutoGenTrueTable(null,"AG_TT_NULL_${i}")) not nullable cannot be used
dao.insert(AutoGenTrueTable(name = "AG_TT_DEFAULT_${i}"))
dao.insert(AutoGenTrueTable(id = 100,"AG_TT_100_${i}"))
dao.insert(AutoGenFalseTable(0,"AG_FT_ZERO_${i}"))
//dao.insert(AutoGenFalseTable(id = null,name = "AG_FT_NULL_${i}")) not nullable cannot be used
dao.insert(AutoGenFalseTable(name = "AG_FT_DEFAULT_${i}"))
dao.insert(AutoGenFalseTable(id = 100, "AG_FT_100_${i}"))
dao.insert((AutoGenFalseNullableTable(0, "AG_FTNULL_ZERO_${i}") ))
dao.insert((AutoGenFalseNullableTable(null, "AG_FTNULL_NULL_${i}") ))
dao.insert((AutoGenFalseNullableTable( name = "AG_FTNULL_DEFAULT_${i}") ))
dao.insert(AutoGenFalseNullableTable(id = 100, name = "AG_FTNULL_100_${i}"))
}
logEverything(stage++)
dao.deleteFromAGTTByHighIds(100)
dao.deleteFromAGFTByHighIds(100)
dao.deleteFromFalseNullableByHighIds(100)
logEverything(stage++)
for (i: Int in 1..3) {
dao.insert(AutoGenTrueTable(0,"AG_TT_ZERO_${i}"))
//dao.insert(AutoGenTrueTable(null,"AG_TT_NULL_${i}")) not nullable cannot be used
dao.insert(AutoGenTrueTable(name = "AG_TT_DEFAULT_${i}"))
dao.insert(AutoGenTrueTable(id = 100,"AG_TT_100_${i}"))
dao.insert(AutoGenFalseTable(0,"AG_FT_ZERO_${i}"))
//dao.insert(AutoGenFalseTable(id = null,name = "AG_FT_NULL_${i}")) not nullable cannot be used
dao.insert(AutoGenFalseTable(name = "AG_FT_DEFAULT_${i}"))
dao.insert(AutoGenFalseTable(id = 100, "AG_FT_100_${i}"))
dao.insert((AutoGenFalseNullableTable(0, "AG_FTNULL_ZERO_${i}") ))
dao.insert((AutoGenFalseNullableTable(null, "AG_FTNULL_NULL_${i}") ))
dao.insert((AutoGenFalseNullableTable( name = "AG_FTNULL_DEFAULT_${i}") ))
dao.insert(AutoGenFalseNullableTable(id = 100, name = "AG_FTNULL_100_${i}"))
}
logEverything(stage++)
}
fun logEverything(stage: Int) {
Log.d("DBINFO_STARTSTAGE_${stage}","Starting logging of stage ${stage}")
logAllFromAGTT(stage)
logAllFromAGFT(stage)
logAllFromAGFTN(stage)
logAllFromSQLite_Sequence(stage)
}
fun logAllFromAGTT(stage: Int) {
for(a in dao.getAllFromAutoGenTrue()) {
Log.d("DBINFO_AGTT_STG${stage}","ID is ${a.id} NAME is ${a.name}")
}
}
fun logAllFromAGFT(stage: Int) {
for(a in dao.getAllFromAutoGenFalse()) {
Log.d("DBINFO_AGFT_STG${stage}","ID is ${a.id} NAME is ${a.name}")
}
}
fun logAllFromAGFTN(stage: Int) {
for(a in dao.getAllFromAutoGenFalseNullable()) {
Log.d("DBINFO_AGFTN_STG${stage}","ID is ${a.id} NAME is ${a.name}")
}
}
fun logAllFromSQLite_Sequence(stage: Int) {
for(ss in dao.getAllFromSQLiteSequence()) {
Log.d("DBINFO_SSEQ_STG${stage}","TABLE IS ${ss.name} HIGHEST ID STORED FOR THE TABLE IS ${ss.seq}")
}
}
}
When run for the first time the App is installed then the output is (2 blanks lines between stages and a blank line between the 3 tables):-
2023-04-14 12:01:26.073 D/DBINFO_STARTSTAGE_0: Starting logging of stage 0
2023-04-14 12:01:26.244 D/DBINFO_STARTSTAGE_1: Starting logging of stage 1
2023-04-14 12:01:26.246 D/DBINFO_AGTT_STG1: ID is 1 NAME is AG_TT_ZERO_1
2023-04-14 12:01:26.246 D/DBINFO_AGTT_STG1: ID is 2 NAME is AG_TT_DEFAULT_1
2023-04-14 12:01:26.246 D/DBINFO_AGTT_STG1: ID is 100 NAME is AG_TT_100_1
2023-04-14 12:01:26.246 D/DBINFO_AGTT_STG1: ID is 101 NAME is AG_TT_ZERO_2
2023-04-14 12:01:26.246 D/DBINFO_AGTT_STG1: ID is 102 NAME is AG_TT_DEFAULT_2
2023-04-14 12:01:26.247 D/DBINFO_AGTT_STG1: ID is 103 NAME is AG_TT_ZERO_3
2023-04-14 12:01:26.247 D/DBINFO_AGTT_STG1: ID is 104 NAME is AG_TT_DEFAULT_3
2023-04-14 12:01:26.249 D/DBINFO_AGFT_STG1: ID is 0 NAME is AG_FT_ZERO_1
2023-04-14 12:01:26.249 D/DBINFO_AGFT_STG1: ID is 100 NAME is AG_FT_100_1
2023-04-14 12:01:26.250 D/DBINFO_AGFTN_STG1: ID is 0 NAME is AG_FTNULL_ZERO_1
2023-04-14 12:01:26.250 D/DBINFO_AGFTN_STG1: ID is 1 NAME is AG_FTNULL_NULL_1
2023-04-14 12:01:26.250 D/DBINFO_AGFTN_STG1: ID is 2 NAME is AG_FTNULL_DEFAULT_1
2023-04-14 12:01:26.250 D/DBINFO_AGFTN_STG1: ID is 100 NAME is AG_FTNULL_100_1
2023-04-14 12:01:26.251 D/DBINFO_AGFTN_STG1: ID is 101 NAME is AG_FTNULL_NULL_2
2023-04-14 12:01:26.251 D/DBINFO_AGFTN_STG1: ID is 102 NAME is AG_FTNULL_DEFAULT_2
2023-04-14 12:01:26.251 D/DBINFO_AGFTN_STG1: ID is 103 NAME is AG_FTNULL_NULL_3
2023-04-14 12:01:26.251 D/DBINFO_AGFTN_STG1: ID is 104 NAME is AG_FTNULL_DEFAULT_3
2023-04-14 12:01:26.253 D/DBINFO_SSEQ_STG1: TABLE IS AutoGenTrueTable HIGHEST ID STORED FOR THE TABLE IS 104
2023-04-14 12:01:26.258 D/DBINFO_STARTSTAGE_2: Starting logging of stage 2
2023-04-14 12:01:26.261 D/DBINFO_AGTT_STG2: ID is 1 NAME is AG_TT_ZERO_1
2023-04-14 12:01:26.261 D/DBINFO_AGTT_STG2: ID is 2 NAME is AG_TT_DEFAULT_1
2023-04-14 12:01:26.262 D/DBINFO_AGFT_STG2: ID is 0 NAME is AG_FT_ZERO_1
2023-04-14 12:01:26.263 D/DBINFO_AGFTN_STG2: ID is 0 NAME is AG_FTNULL_ZERO_1
2023-04-14 12:01:26.263 D/DBINFO_AGFTN_STG2: ID is 1 NAME is AG_FTNULL_NULL_1
2023-04-14 12:01:26.263 D/DBINFO_AGFTN_STG2: ID is 2 NAME is AG_FTNULL_DEFAULT_1
2023-04-14 12:01:26.264 D/DBINFO_SSEQ_STG2: TABLE IS AutoGenTrueTable HIGHEST ID STORED FOR THE TABLE IS 104
2023-04-14 12:01:26.333 D/DBINFO_STARTSTAGE_3: Starting logging of stage 3
2023-04-14 12:01:26.336 D/DBINFO_AGTT_STG3: ID is 1 NAME is AG_TT_ZERO_1
2023-04-14 12:01:26.336 D/DBINFO_AGTT_STG3: ID is 2 NAME is AG_TT_DEFAULT_1
2023-04-14 12:01:26.337 D/DBINFO_AGTT_STG3: ID is 100 NAME is AG_TT_100_1
2023-04-14 12:01:26.337 D/DBINFO_AGTT_STG3: ID is 105 NAME is AG_TT_ZERO_1
2023-04-14 12:01:26.337 D/DBINFO_AGTT_STG3: ID is 106 NAME is AG_TT_DEFAULT_1
2023-04-14 12:01:26.337 D/DBINFO_AGTT_STG3: ID is 107 NAME is AG_TT_ZERO_2
2023-04-14 12:01:26.337 D/DBINFO_AGTT_STG3: ID is 108 NAME is AG_TT_DEFAULT_2
2023-04-14 12:01:26.337 D/DBINFO_AGTT_STG3: ID is 109 NAME is AG_TT_ZERO_3
2023-04-14 12:01:26.337 D/DBINFO_AGTT_STG3: ID is 110 NAME is AG_TT_DEFAULT_3
2023-04-14 12:01:26.340 D/DBINFO_AGFT_STG3: ID is 0 NAME is AG_FT_ZERO_1
2023-04-14 12:01:26.340 D/DBINFO_AGFT_STG3: ID is 100 NAME is AG_FT_100_1
2023-04-14 12:01:26.342 D/DBINFO_AGFTN_STG3: ID is 0 NAME is AG_FTNULL_ZERO_1
2023-04-14 12:01:26.342 D/DBINFO_AGFTN_STG3: ID is 1 NAME is AG_FTNULL_NULL_1
2023-04-14 12:01:26.342 D/DBINFO_AGFTN_STG3: ID is 2 NAME is AG_FTNULL_DEFAULT_1
2023-04-14 12:01:26.342 D/DBINFO_AGFTN_STG3: ID is 3 NAME is AG_FTNULL_NULL_1
2023-04-14 12:01:26.343 D/DBINFO_AGFTN_STG3: ID is 4 NAME is AG_FTNULL_DEFAULT_1
2023-04-14 12:01:26.343 D/DBINFO_AGFTN_STG3: ID is 100 NAME is AG_FTNULL_100_1
2023-04-14 12:01:26.343 D/DBINFO_AGFTN_STG3: ID is 101 NAME is AG_FTNULL_NULL_2
2023-04-14 12:01:26.343 D/DBINFO_AGFTN_STG3: ID is 102 NAME is AG_FTNULL_DEFAULT_2
2023-04-14 12:01:26.343 D/DBINFO_AGFTN_STG3: ID is 103 NAME is AG_FTNULL_NULL_3
2023-04-14 12:01:26.343 D/DBINFO_AGFTN_STG3: ID is 104 NAME is AG_FTNULL_DEFAULT_3
2023-04-14 12:01:26.346 D/DBINFO_SSEQ_STG3: TABLE IS AutoGenTrueTable HIGHEST ID STORED FOR THE TABLE IS 110
The Results explained (a little)
It can clearly be seen that id's aren't duplicated (they cannot be as the primary key in all cases is implicitly unique) and also that sqlite_sequence only records the highest ever used id for the TT table (the one with autogenerate=true).
Not so easy to see is that where autogenerate is false, at least be implicata ion/default, that using any value more than once, 0 included, will not generate the id, that is Room passes that value to the insert. The demo has the IGNORE onConflictStrategy
so these attempted duplicates are ignored an no failure occurs.
Hence the ???_ZERO_nn, for the FT and FTNULL have an id of 0 and only the single row where nn is 1. Unlike the TT_ZERO where all 3 rows have been inserted with a generated ID that is not 0.