TableInfo.java

/*
 * 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 androidx.room.util;

import android.database.Cursor;
import android.os.Build;

import androidx.annotation.IntDef;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.RestrictTo;
import androidx.room.ColumnInfo;
import androidx.sqlite.db.SupportSQLiteDatabase;

import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Locale;
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_PREFIX)
@SuppressWarnings({"WeakerAccess", "unused", "TryFinallyCanBeTryWithResources",
        "SimplifiableIfStatement"})
// if you change this class, you must change TableInfoValidationWriter.kt
public final class TableInfo {

    /**
     * Identifies from where the info object was created.
     */
    @Retention(RetentionPolicy.SOURCE)
    @IntDef(value = {CREATED_FROM_UNKNOWN, CREATED_FROM_ENTITY, CREATED_FROM_DATABASE})
    @interface CreatedFrom {
    }

    /**
     * Identifier for when the info is created from an unknown source.
     */
    public static final int CREATED_FROM_UNKNOWN = 0;

    /**
     * Identifier for when the info is created from an entity definition, such as generated code
     * by the compiler or at runtime from a schema bundle, parsed from a schema JSON file.
     */
    public static final int CREATED_FROM_ENTITY = 1;

    /**
     * Identifier for when the info is created from the database itself, reading information from a
     * PRAGMA, such as table_info.
     */
    public static final int CREATED_FROM_DATABASE = 2;

    /**
     * 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 instanceof TableInfo)) 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"); int defaultValueIndex = cursor.getColumnIndex("dflt_value"); 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); final String defaultValue = cursor.getString(defaultValueIndex); columns.put(name, new Column(name, type, notNull, primaryKeyPosition, defaultValue, CREATED_FROM_DATABASE)); } } } 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 final class Column { /** * The column name. */ public final String name; /** * The column type affinity. */ public final String type; /** * The column type after it is normalized to one of the basic types according to * https://www.sqlite.org/datatype3.html Section 3.1. * <p> * This is the value Room uses for equality check. */ @ColumnInfo.SQLiteTypeAffinity public final int affinity; /** * 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; /** * The default value of this column. */ public final String defaultValue; @CreatedFrom private final int mCreatedFrom; /** * @deprecated Use {@link Column#Column(String, String, boolean, int, String, int)} instead. */ @Deprecated public Column(String name, String type, boolean notNull, int primaryKeyPosition) { this(name, type, notNull, primaryKeyPosition, null, CREATED_FROM_UNKNOWN); } // if you change this constructor, you must change TableInfoWriter.kt public Column(String name, String type, boolean notNull, int primaryKeyPosition, String defaultValue, @CreatedFrom int createdFrom) { this.name = name; this.type = type; this.notNull = notNull; this.primaryKeyPosition = primaryKeyPosition; this.affinity = findAffinity(type); this.defaultValue = defaultValue; this.mCreatedFrom = createdFrom; } /** * Implements https://www.sqlite.org/datatype3.html section 3.1 * * @param type The type that was given to the sqlite * @return The normalized type which is one of the 5 known affinities */ @ColumnInfo.SQLiteTypeAffinity private static int findAffinity(@Nullable String type) { if (type == null) { return ColumnInfo.BLOB; } String uppercaseType = type.toUpperCase(Locale.US); if (uppercaseType.contains("INT")) { return ColumnInfo.INTEGER; } if (uppercaseType.contains("CHAR") || uppercaseType.contains("CLOB") || uppercaseType.contains("TEXT")) { return ColumnInfo.TEXT; } if (uppercaseType.contains("BLOB")) { return ColumnInfo.BLOB; } if (uppercaseType.contains("REAL") || uppercaseType.contains("FLOA") || uppercaseType.contains("DOUB")) { return ColumnInfo.REAL; } // sqlite returns NUMERIC here but it is like a catch all. We already // have UNDEFINED so it is better to use UNDEFINED for consistency. return ColumnInfo.UNDEFINED; } @Override public boolean equals(Object o) { if (this == o) return true; if (!(o instanceof Column)) 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; // Only validate default value if it was defined in an entity, i.e. if the info // from the compiler itself has it. b/136019383 if (mCreatedFrom == CREATED_FROM_ENTITY && column.mCreatedFrom == CREATED_FROM_DATABASE && (defaultValue != null && !defaultValue.equals(column.defaultValue))) { return false; } else if (mCreatedFrom == CREATED_FROM_DATABASE && column.mCreatedFrom == CREATED_FROM_ENTITY && (column.defaultValue != null && !column.defaultValue.equals(defaultValue))) { return false; } else if (mCreatedFrom != CREATED_FROM_UNKNOWN && mCreatedFrom == column.mCreatedFrom && (defaultValue != null ? !defaultValue.equals(column.defaultValue) : column.defaultValue != null)) { return false; } return affinity == column.affinity; } /** * 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 + affinity; result = 31 * result + (notNull ? 1231 : 1237); result = 31 * result + primaryKeyPosition; // Default value is not part of the hashcode since we conditionally check it for // equality which would break the equals + hashcode contract. // result = 31 * result + (defaultValue != null ? defaultValue.hashCode() : 0); return result; } @Override public String toString() { return "Column{" + "name='" + name + '

'
+ ", type='" + type + '

'
+ ", affinity='" + affinity + '

'
+ ", notNull=" + notNull + ", primaryKeyPosition=" + primaryKeyPosition + ", defaultValue='" + defaultValue + '

'
+ '}'; } } /** * Holds the information about an SQLite foreign key * * @hide */ @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX) public static final 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 instanceof ForeignKey)) 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_PREFIX) 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(@NonNull 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_PREFIX) public static final 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 instanceof Index)) 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 + '}'; } } }