blob: a115147d4cc5e88c10b4658534c917f2c2be91f5 [file] [log] [blame]
/*
* Copyright (C) 2017 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package android.arch.persistence.room.util;
import android.arch.persistence.db.SupportSQLiteDatabase;
import android.database.Cursor;
import android.os.Build;
import android.support.annotation.NonNull;
import android.support.annotation.Nullable;
import android.support.annotation.RestrictTo;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.TreeMap;
/**
* A data class that holds the information about a table.
* <p>
* It directly maps to the result of {@code PRAGMA table_info(<table_name>)}. Check the
* <a href="http://www.sqlite.org/pragma.html#pragma_table_info">PRAGMA table_info</a>
* documentation for more details.
* <p>
* Even though SQLite column names are case insensitive, this class uses case sensitive matching.
*
* @hide
*/
@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
@SuppressWarnings({"WeakerAccess", "unused", "TryFinallyCanBeTryWithResources"})
// if you change this class, you must change TableInfoWriter.kt
public class TableInfo {
/**
* The table name.
*/
public final String name;
/**
* Unmodifiable map of columns keyed by column name.
*/
public final Map<String, Column> columns;
public final Set<ForeignKey> foreignKeys;
/**
* Sometimes, Index information is not available (older versions). If so, we skip their
* verification.
*/
@Nullable
public final Set<Index> indices;
@SuppressWarnings("unused")
public TableInfo(String name, Map<String, Column> columns, Set<ForeignKey> foreignKeys,
Set<Index> indices) {
this.name = name;
this.columns = Collections.unmodifiableMap(columns);
this.foreignKeys = Collections.unmodifiableSet(foreignKeys);
this.indices = indices == null ? null : Collections.unmodifiableSet(indices);
}
/**
* For backward compatibility with dbs created with older versions.
*/
@SuppressWarnings("unused")
public TableInfo(String name, Map<String, Column> columns, Set<ForeignKey> foreignKeys) {
this(name, columns, foreignKeys, Collections.<Index>emptySet());
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
TableInfo tableInfo = (TableInfo) o;
if (name != null ? !name.equals(tableInfo.name) : tableInfo.name != null) return false;
if (columns != null ? !columns.equals(tableInfo.columns) : tableInfo.columns != null) {
return false;
}
if (foreignKeys != null ? !foreignKeys.equals(tableInfo.foreignKeys)
: tableInfo.foreignKeys != null) {
return false;
}
if (indices == null || tableInfo.indices == null) {
// if one us is missing index information, seems like we couldn't acquire the
// information so we better skip.
return true;
}
return indices.equals(tableInfo.indices);
}
@Override
public int hashCode() {
int result = name != null ? name.hashCode() : 0;
result = 31 * result + (columns != null ? columns.hashCode() : 0);
result = 31 * result + (foreignKeys != null ? foreignKeys.hashCode() : 0);
// skip index, it is not reliable for comparison.
return result;
}
@Override
public String toString() {
return "TableInfo{"
+ "name='" + name + '\''
+ ", columns=" + columns
+ ", foreignKeys=" + foreignKeys
+ ", indices=" + indices
+ '}';
}
/**
* Reads the table information from the given database.
*
* @param database The database to read the information from.
* @param tableName The table name.
* @return A TableInfo containing the schema information for the provided table name.
*/
@SuppressWarnings("SameParameterValue")
public static TableInfo read(SupportSQLiteDatabase database, String tableName) {
Map<String, Column> columns = readColumns(database, tableName);
Set<ForeignKey> foreignKeys = readForeignKeys(database, tableName);
Set<Index> indices = readIndices(database, tableName);
return new TableInfo(tableName, columns, foreignKeys, indices);
}
private static Set<ForeignKey> readForeignKeys(SupportSQLiteDatabase database,
String tableName) {
Set<ForeignKey> foreignKeys = new HashSet<>();
// this seems to return everything in order but it is not documented so better be safe
Cursor cursor = database.query("PRAGMA foreign_key_list(`" + tableName + "`)");
try {
final int idColumnIndex = cursor.getColumnIndex("id");
final int seqColumnIndex = cursor.getColumnIndex("seq");
final int tableColumnIndex = cursor.getColumnIndex("table");
final int onDeleteColumnIndex = cursor.getColumnIndex("on_delete");
final int onUpdateColumnIndex = cursor.getColumnIndex("on_update");
final List<ForeignKeyWithSequence> ordered = readForeignKeyFieldMappings(cursor);
final int count = cursor.getCount();
for (int position = 0; position < count; position++) {
cursor.moveToPosition(position);
final int seq = cursor.getInt(seqColumnIndex);
if (seq != 0) {
continue;
}
final int id = cursor.getInt(idColumnIndex);
List<String> myColumns = new ArrayList<>();
List<String> refColumns = new ArrayList<>();
for (ForeignKeyWithSequence key : ordered) {
if (key.mId == id) {
myColumns.add(key.mFrom);
refColumns.add(key.mTo);
}
}
foreignKeys.add(new ForeignKey(
cursor.getString(tableColumnIndex),
cursor.getString(onDeleteColumnIndex),
cursor.getString(onUpdateColumnIndex),
myColumns,
refColumns
));
}
} finally {
cursor.close();
}
return foreignKeys;
}
private static List<ForeignKeyWithSequence> readForeignKeyFieldMappings(Cursor cursor) {
final int idColumnIndex = cursor.getColumnIndex("id");
final int seqColumnIndex = cursor.getColumnIndex("seq");
final int fromColumnIndex = cursor.getColumnIndex("from");
final int toColumnIndex = cursor.getColumnIndex("to");
final int count = cursor.getCount();
List<ForeignKeyWithSequence> result = new ArrayList<>();
for (int i = 0; i < count; i++) {
cursor.moveToPosition(i);
result.add(new ForeignKeyWithSequence(
cursor.getInt(idColumnIndex),
cursor.getInt(seqColumnIndex),
cursor.getString(fromColumnIndex),
cursor.getString(toColumnIndex)
));
}
Collections.sort(result);
return result;
}
private static Map<String, Column> readColumns(SupportSQLiteDatabase database,
String tableName) {
Cursor cursor = database
.query("PRAGMA table_info(`" + tableName + "`)");
//noinspection TryFinallyCanBeTryWithResources
Map<String, Column> columns = new HashMap<>();
try {
if (cursor.getColumnCount() > 0) {
int nameIndex = cursor.getColumnIndex("name");
int typeIndex = cursor.getColumnIndex("type");
int notNullIndex = cursor.getColumnIndex("notnull");
int pkIndex = cursor.getColumnIndex("pk");
while (cursor.moveToNext()) {
final String name = cursor.getString(nameIndex);
final String type = cursor.getString(typeIndex);
final boolean notNull = 0 != cursor.getInt(notNullIndex);
final int primaryKeyPosition = cursor.getInt(pkIndex);
columns.put(name, new Column(name, type, notNull, primaryKeyPosition));
}
}
} finally {
cursor.close();
}
return columns;
}
/**
* @return null if we cannot read the indices due to older sqlite implementations.
*/
@Nullable
private static Set<Index> readIndices(SupportSQLiteDatabase database, String tableName) {
Cursor cursor = database.query("PRAGMA index_list(`" + tableName + "`)");
try {
final int nameColumnIndex = cursor.getColumnIndex("name");
final int originColumnIndex = cursor.getColumnIndex("origin");
final int uniqueIndex = cursor.getColumnIndex("unique");
if (nameColumnIndex == -1 || originColumnIndex == -1 || uniqueIndex == -1) {
// we cannot read them so better not validate any index.
return null;
}
HashSet<Index> indices = new HashSet<>();
while (cursor.moveToNext()) {
String origin = cursor.getString(originColumnIndex);
if (!"c".equals(origin)) {
// Ignore auto-created indices
continue;
}
String name = cursor.getString(nameColumnIndex);
boolean unique = cursor.getInt(uniqueIndex) == 1;
Index index = readIndex(database, name, unique);
if (index == null) {
// we cannot read it properly so better not read it
return null;
}
indices.add(index);
}
return indices;
} finally {
cursor.close();
}
}
/**
* @return null if we cannot read the index due to older sqlite implementations.
*/
@Nullable
private static Index readIndex(SupportSQLiteDatabase database, String name, boolean unique) {
Cursor cursor = database.query("PRAGMA index_xinfo(`" + name + "`)");
try {
final int seqnoColumnIndex = cursor.getColumnIndex("seqno");
final int cidColumnIndex = cursor.getColumnIndex("cid");
final int nameColumnIndex = cursor.getColumnIndex("name");
if (seqnoColumnIndex == -1 || cidColumnIndex == -1 || nameColumnIndex == -1) {
// we cannot read them so better not validate any index.
return null;
}
final TreeMap<Integer, String> results = new TreeMap<>();
while (cursor.moveToNext()) {
int cid = cursor.getInt(cidColumnIndex);
if (cid < 0) {
// Ignore SQLite row ID
continue;
}
int seq = cursor.getInt(seqnoColumnIndex);
String columnName = cursor.getString(nameColumnIndex);
results.put(seq, columnName);
}
final List<String> columns = new ArrayList<>(results.size());
columns.addAll(results.values());
return new Index(name, unique, columns);
} finally {
cursor.close();
}
}
/**
* Holds the information about a database column.
*/
@SuppressWarnings("WeakerAccess")
public static class Column {
/**
* The column name.
*/
public final String name;
/**
* The column type affinity.
*/
public final String type;
/**
* Whether or not the column can be NULL.
*/
public final boolean notNull;
/**
* The position of the column in the list of primary keys, 0 if the column is not part
* of the primary key.
* <p>
* This information is only available in API 20+.
* <a href="https://www.sqlite.org/releaselog/3_7_16_2.html">(SQLite version 3.7.16.2)</a>
* On older platforms, it will be 1 if the column is part of the primary key and 0
* otherwise.
* <p>
* The {@link #equals(Object)} implementation handles this inconsistency based on
* API levels os if you are using a custom SQLite deployment, it may return false
* positives.
*/
public final int primaryKeyPosition;
// if you change this constructor, you must change TableInfoWriter.kt
public Column(String name, String type, boolean notNull, int primaryKeyPosition) {
this.name = name;
this.type = type;
this.notNull = notNull;
this.primaryKeyPosition = primaryKeyPosition;
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
Column column = (Column) o;
if (Build.VERSION.SDK_INT >= 20) {
if (primaryKeyPosition != column.primaryKeyPosition) return false;
} else {
if (isPrimaryKey() != column.isPrimaryKey()) return false;
}
if (!name.equals(column.name)) return false;
//noinspection SimplifiableIfStatement
if (notNull != column.notNull) return false;
return type != null ? type.equalsIgnoreCase(column.type) : column.type == null;
}
/**
* Returns whether this column is part of the primary key or not.
*
* @return True if this column is part of the primary key, false otherwise.
*/
public boolean isPrimaryKey() {
return primaryKeyPosition > 0;
}
@Override
public int hashCode() {
int result = name.hashCode();
result = 31 * result + (type != null ? type.hashCode() : 0);
result = 31 * result + (notNull ? 1231 : 1237);
result = 31 * result + primaryKeyPosition;
return result;
}
@Override
public String toString() {
return "Column{"
+ "name='" + name + '\''
+ ", type='" + type + '\''
+ ", notNull=" + notNull
+ ", primaryKeyPosition=" + primaryKeyPosition
+ '}';
}
}
/**
* Holds the information about an SQLite foreign key
*
* @hide
*/
@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
public static class ForeignKey {
@NonNull
public final String referenceTable;
@NonNull
public final String onDelete;
@NonNull
public final String onUpdate;
@NonNull
public final List<String> columnNames;
@NonNull
public final List<String> referenceColumnNames;
public ForeignKey(@NonNull String referenceTable, @NonNull String onDelete,
@NonNull String onUpdate,
@NonNull List<String> columnNames, @NonNull List<String> referenceColumnNames) {
this.referenceTable = referenceTable;
this.onDelete = onDelete;
this.onUpdate = onUpdate;
this.columnNames = Collections.unmodifiableList(columnNames);
this.referenceColumnNames = Collections.unmodifiableList(referenceColumnNames);
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
ForeignKey that = (ForeignKey) o;
if (!referenceTable.equals(that.referenceTable)) return false;
if (!onDelete.equals(that.onDelete)) return false;
if (!onUpdate.equals(that.onUpdate)) return false;
//noinspection SimplifiableIfStatement
if (!columnNames.equals(that.columnNames)) return false;
return referenceColumnNames.equals(that.referenceColumnNames);
}
@Override
public int hashCode() {
int result = referenceTable.hashCode();
result = 31 * result + onDelete.hashCode();
result = 31 * result + onUpdate.hashCode();
result = 31 * result + columnNames.hashCode();
result = 31 * result + referenceColumnNames.hashCode();
return result;
}
@Override
public String toString() {
return "ForeignKey{"
+ "referenceTable='" + referenceTable + '\''
+ ", onDelete='" + onDelete + '\''
+ ", onUpdate='" + onUpdate + '\''
+ ", columnNames=" + columnNames
+ ", referenceColumnNames=" + referenceColumnNames
+ '}';
}
}
/**
* Temporary data holder for a foreign key row in the pragma result. We need this to ensure
* sorting in the generated foreign key object.
*
* @hide
*/
@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
static class ForeignKeyWithSequence implements Comparable<ForeignKeyWithSequence> {
final int mId;
final int mSequence;
final String mFrom;
final String mTo;
ForeignKeyWithSequence(int id, int sequence, String from, String to) {
mId = id;
mSequence = sequence;
mFrom = from;
mTo = to;
}
@Override
public int compareTo(ForeignKeyWithSequence o) {
final int idCmp = mId - o.mId;
if (idCmp == 0) {
return mSequence - o.mSequence;
} else {
return idCmp;
}
}
}
/**
* Holds the information about an SQLite index
*
* @hide
*/
@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
public static class Index {
// should match the value in Index.kt
public static final String DEFAULT_PREFIX = "index_";
public final String name;
public final boolean unique;
public final List<String> columns;
public Index(String name, boolean unique, List<String> columns) {
this.name = name;
this.unique = unique;
this.columns = columns;
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
Index index = (Index) o;
if (unique != index.unique) {
return false;
}
if (!columns.equals(index.columns)) {
return false;
}
if (name.startsWith(Index.DEFAULT_PREFIX)) {
return index.name.startsWith(Index.DEFAULT_PREFIX);
} else {
return name.equals(index.name);
}
}
@Override
public int hashCode() {
int result;
if (name.startsWith(DEFAULT_PREFIX)) {
result = DEFAULT_PREFIX.hashCode();
} else {
result = name.hashCode();
}
result = 31 * result + (unique ? 1 : 0);
result = 31 * result + columns.hashCode();
return result;
}
@Override
public String toString() {
return "Index{"
+ "name='" + name + '\''
+ ", unique=" + unique
+ ", columns=" + columns
+ '}';
}
}
}