Relaciones Uno A Uno Con Room

En este tutorial veremos cómo implementar relaciones uno a uno con Room entre nuestras tablas.

Para ello aprenderemos que son los objetos embebidos (@Embedded), como establecer las anotaciones @Relation para la relación 1:1 y establecer claves foráneas.


Que Son Embedded Objects

La anotación @Embedded marca un campo de una @Entity o POJO como un objeto anidado.

Es decir, un objeto que es interpretado como parte de otro.

Si el campo está en una entidad, entonces los atributos del objeto anidado serán tomados como columnas de la tabla.

Ejemplo:

Si removemos las fechas de creación y última actualización de nuestra entidad ShoppingList en otra clase:

public class Life {
    @ColumnInfo(name = "created_date", defaultValue = "CURRENT_TIMESTAMP")
    public String createdDate;

    @ColumnInfo(name = "last_updated", defaultValue = "CURRENT_TIMESTAMP")
    public String lastUpdated;
}

Podemos decirle a Room que aún son parte de la tabla shopping_list usando @Embedded en un campo del tipo que contiene ambos atributos:

@Entity(tableName = "shopping_list")
public class ShoppingList {

    @Embedded
    public Life life;

}

Relaciones Uno A Uno

Una relación uno a uno (1:1) es: una relación donde cada instancia de la entidad padre está asociada con una y solo una instancia de la entidad hija.

Partamos de un ejemplo que aplicaremos en nuestra App de listas de compras.

Diagrama Entidad Relación App Listas De Compras

Decidiremos que la tabla shoping_list ahora tendrá una relación 1:1 con una nueva tabla llamada info.

¿Cómo implementar esta relación en Room?

Seguiremos los siguientes pasos:

Paso 1: Añadir campo de referencia a la clave primaria de la entidad hija. Al crear la entidad Info pondremos el Id de ShoppingList:

@Entity(tableName = "info")
public class Info {
    @NonNull
    @PrimaryKey
    public String id;

    @NonNull
    @ColumnInfo(name = "shopping_list_id")
    public String shoppingListId;

    @ColumnInfo(name = "created_date", defaultValue = "CURRENT_TIMESTAMP")
    public String createdDate;

    @ColumnInfo(name = "last_updated", defaultValue = "CURRENT_TIMESTAMP")
    public String lastUpdated;

    public Info(@NonNull String id, @NonNull String shoppingListId,
                String createdDate, String lastUpdated) {
        this.id = id;
        this.shoppingListId = shoppingListId;
        this.createdDate = createdDate;
        this.lastUpdated = lastUpdated;
    }

}

Paso 2: Crear una clase que contenga una instancia embebida de la clase padre (o POJO parcial de la misma) y otra de la hija anotada con @Relation.

public class ShoppingListAndInfo {
    @Embedded
    public ShoppingListForList shoppingList;

    @Relation(
            parentColumn = "id",
            entityColumn = "shopping_list_id"
    )
    public Info info;
}

Donde, la propiedad parentColumn es para el nombre de la clave primaria del padre y entityColumn para el nombre de la clave foránea en la hija.

Paso 3: Añadir método de retorno en el DAO en una transacción ya que son dos consultas (@Transaction):

@Dao
public abstract class ShoppingListDao {
    @Transaction
    @Query("SELECT id, name, is_favorite FROM shopping_list")
    abstract LiveData<List<ShoppingListAndInfo>> getAll();

    @Transaction
    @Query("SELECT id, name, is_favorite FROM shopping_list WHERE category IN(:categories)")
    abstract LiveData<List<ShoppingListAndInfo>> getShoppingListsByCategories(List<String> categories);
}

Como ves, estamos pasando de interfaz a clase abstracta en el DAO, ya que operar relaciones requiere más de un método en la mayoría de casos.


Declarar Llaves Foráneas

Para especificar una restricción foránea entre dos entidades de Room usaremos la anotación @ForeignKey.

Ubicaremos esta anotación en la entidad hija como parte de la propiedad foreignKeys.

Ejemplo:

Agregar una referencia foránea para shoppingListId en Info:

@Entity(tableName = "info",
        foreignKeys = @ForeignKey(
                entity = ShoppingList.class,
                parentColumns = "id",
                childColumns = "shopping_list_id")
)
public class Info {

    @NonNull
    @ColumnInfo(name = "shopping_list_id")
    public String shoppingListId;

}

Ejemplo De Relaciones Uno A Uno Con Room

En este corto ejemplo mostraremos los resultados del DAO que mapea la relación 1:1 entre las listas de compras y su información temporal.

Para ello aumentaremos el contenido del layout de cada ítem en la actividad principal, agregando un texto para la fecha de creación y mejorando el diseño con CardViews:

Relaciones Uno A Uno Con Room

Puedes descargar el código final con las modificaciones desde el siguiente enlace:

Habrán algunas modificaciones que omitiré, por lo que te vendría muy bien decargarlo si no puedes realizarlas por ti mism@.

Veamos los pasos generales para llegar al resultado deseado:

1. Agregar Entidad A La Base De Datos

Añade a la propiedad entities la nueva entidad que hemos declarado para los metadatos de la listas de compras:

@Database(entities = {ShoppingList.class, Info.class}, version = 4, exportSchema = false)
public abstract class ShoppingListDatabase extends RoomDatabase {
}

Ahí mismo sube el número de versión a 4 para actualizar el esquema.

2. Insertar Lista De Compras Junto A Info

Al mover las columnas de la tabla shopping_list es necesario insertar la lista junto al registro de la info en un método del DAO de la siguiente forma:

@Transaction
public void insertWithInfo(ShoppingListInsert shoppingList, Info info) {
    insertShoppingList(shoppingList);
    insertInfo(info);
}

@Transaction
public void insertAllWithInfos(List<ShoppingListInsert> shoppingLists, List<Info> infos) {
    insertAll(shoppingLists);
    insertAllInfos(infos);
}

@Insert(onConflict = OnConflictStrategy.IGNORE, entity = ShoppingList.class)
abstract void insertShoppingList(ShoppingListInsert shoppingList);

@Insert(onConflict = OnConflictStrategy.IGNORE, entity = ShoppingList.class)
abstract void insertAll(List<ShoppingListInsert> lists);

@Insert(onConflict = OnConflictStrategy.IGNORE)
abstract void insertInfo(Info info);

@Insert(onConflict = OnConflictStrategy.IGNORE)
abstract void insertAllInfos(List<Info> infos);

En este caso insertWithInfo() e insertAllWithInfos() insertan una lista de compras junto a su información de fechas

Al ser dos operaciones de inserción anotamos el método con @Transaction.

Esto modificaría la prepoblación que hacemos en la base de datos:

// Prepoblar base de datos con callback
private static final RoomDatabase.Callback mRoomCallback = new Callback() {
    @Override
    public void onOpen(@NonNull SupportSQLiteDatabase db) {
        super.onCreate(db);

        dbExecutor.execute(() -> {
            ShoppingListDao dao = INSTANCE.shoppingListDao();

            List<ShoppingListInsert> lists = new ArrayList<>();
            List<Info> infos = new ArrayList<>();

            for (int i = 0; i < 5; i++) {

                // Crear lista de compras
                ShoppingListInsert shoppingList = new ShoppingListInsert(
                        String.valueOf((i + 1)),
                        "Lista " + (i + 1)
                );

                // Crear info
                String date = Utils.getCurrentDate();
                Info info = new Info(String.valueOf((i+1)),
                        shoppingList.id, date, date);

                lists.add(shoppingList);
                infos.add(info);
            }

            dao.insertAllWithInfos(lists, infos);
        });
    }
};

Y se propaga desde la actividad principal hacia el repositorio al momento de agregar una nueva lista de compras:

public void insert(ShoppingListInsert shoppingList) {
    String date = Utils.getCurrentDate();
    Info info = new Info(UUID.randomUUID().toString(), shoppingList.id, date, date);
    mRepository.insert(shoppingList, info);
}

3. Crear Transacción Para Marcar Favorito

Otro cambio en el DAO que tenemos que hacer es cambiar el método markFavorite() por dos actualizaciones, ya que intercambiamos el valor de shopping_list.is_favorite y el de info.last_updated al presionar el botón de favorito.

@Transaction
public void markFavorite(String id) {
    updateShoppingListFavorite(id);
    updateInfoLastUpdated(id);
}

@Query("UPDATE shopping_list SET is_favorite= NOT is_favorite WHERE id = :id")
protected abstract void updateShoppingListFavorite(String id);

@Query("UPDATE info SET last_updated = CURRENT_TIMESTAMP WHERE shopping_list_id=:shoppingListId")
protected abstract void updateInfoLastUpdated(String shoppingListId);

4. Actualizar Diseño De Layout

Para llegar al diseño propuesto en el prototipo debemos agregar como tag raíz un CardView que contenga al ConstraintLayout actual. Y dos TextViews para la etiqueta de la fecha de creación y su valor.

La siguiente es la definición XML con los cambios:

<?xml version="1.0" encoding="utf-8"?>
<com.google.android.material.card.MaterialCardView xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_marginBottom="8dp"
    android:layout_marginLeft="8dp"
    android:layout_marginRight="8dp"
    android:layout_height="wrap_content">

    <androidx.constraintlayout.widget.ConstraintLayout
        android:layout_width="match_parent"
        android:layout_height="?listPreferredItemHeightLarge"
        android:padding="@dimen/normal_padding">


        <TextView
            android:id="@+id/name"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:textAppearance="?attr/textAppearanceHeadline6"
            app:layout_constraintBottom_toBottomOf="parent"
            app:layout_constraintEnd_toStartOf="@+id/favorite_button"
            app:layout_constraintHorizontal_bias="0.0"
            app:layout_constraintStart_toStartOf="parent"
            app:layout_constraintTop_toTopOf="parent"
            app:layout_constraintVertical_bias="0.0"
            tools:text="Lista de ejemplo" />

        <TextView
            android:id="@+id/created_date"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:textAppearance="?textAppearanceCaption"
            tools:text="26/05/2020 01:12:54"
            app:layout_constraintBottom_toBottomOf="parent"
            app:layout_constraintEnd_toEndOf="parent"
            app:layout_constraintHorizontal_bias="0.0"
            app:layout_constraintStart_toStartOf="parent"
            app:layout_constraintTop_toBottomOf="@+id/name"
            app:layout_constraintVertical_bias="1.0" />

        <com.google.android.material.checkbox.MaterialCheckBox
            android:id="@+id/favorite_button"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:layout_marginEnd="8dp"
            android:layout_marginRight="8dp"
            android:button="@drawable/sl_favorite_24"
            android:minWidth="0dp"
            android:minHeight="0dp"
            app:buttonTint="@color/favorite_color"
            app:layout_constraintBottom_toBottomOf="parent"
            app:layout_constraintEnd_toStartOf="@+id/delete_button"
            app:layout_constraintTop_toTopOf="parent"
            app:layout_constraintVertical_bias="0.0" />

        <ImageView
            android:id="@+id/delete_button"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            app:layout_constraintBottom_toBottomOf="parent"
            app:layout_constraintEnd_toEndOf="parent"
            app:layout_constraintTop_toTopOf="parent"
            app:layout_constraintVertical_bias="0.0"
            app:srcCompat="@drawable/ic_delete_24" />
    </androidx.constraintlayout.widget.ConstraintLayout>
</com.google.android.material.card.MaterialCardView>

5. Usar Clase De Relación Uno A Uno

Reemplaza en el adaptador la lista de datos ShoppingListForList por ShoppingListAndInfo. Luego bindea la fecha de creación en el ViewHolder.

public class ShoppingListViewHolder extends RecyclerView.ViewHolder {
    private final TextView mNameText;
    private final CheckBox mFavoriteButton;
    private final ImageView mDeleteButton;
    private TextView mCreatedDateText;

    public ShoppingListViewHolder(@NonNull View itemView) {
        super(itemView);
        mNameText = itemView.findViewById(R.id.name);
        mCreatedDateText = itemView.findViewById(R.id.created_date);
        mFavoriteButton = itemView.findViewById(R.id.favorite_button);
        mDeleteButton = itemView.findViewById(R.id.delete_button);

        // Setear eventos
        mFavoriteButton.setOnClickListener(this::manageEvents);
        mDeleteButton.setOnClickListener(this::manageEvents);
        itemView.setOnClickListener(this::manageEvents);
    }

    private void manageEvents(View view) {
        if (mItemListener != null) {
            ShoppingListAndInfo clickedItem = mShoppingLists.get(getAdapterPosition());

            // Manejar evento de click en Favorito
            if (view.getId() == R.id.favorite_button) {
                mItemListener.onFavoriteIconClicked(clickedItem);
                return;
            } else if (view.getId() == R.id.delete_button) {
                mItemListener.onDeleteIconClicked(clickedItem);
                return;
            }

            mItemListener.onClick(clickedItem);
        }
    }

    public void bind(ShoppingListAndInfo item) {
        mNameText.setText(item.shoppingList.name);
        mFavoriteButton.setChecked(item.shoppingList.favorite);
        mCreatedDateText.setText(item.info.createdDate);
    }
}

6. Actualiza El ViewModel Y Repositorio

Propaga el cambio en el consturctor de ShoppingListViewModel para que el repositorio use como tipo de retorno la nueva entidad de relación en los métodos getAll() y getShoppingListsWithCategories().

public LiveData<List<ShoppingListAndInfo>> getShoppingLists() {
    return mShoppingListDao.getAll();
}

public LiveData<List<ShoppingListAndInfo>> getShoppingListsWithCategories(List<String> categories) {
    return mShoppingListDao.getShoppingListsByCategories(categories);
}

Una vez realizados los cambios, ejecuta el proyecto y verás los registros finales de la relación uno a uno en la interfaz:


Siguiente tutorial: Relaciones Uno A Muchos Con Room

Únete Al Discord De Develou

Si tienes problemas con el código de este tutorial, preguntas, recomendaciones o solo deseas discutir sobre desarrollo Android conmigo y otros desarrolladores, únete a la comunidad de Discord de Develou y siéntete libre de participar como gustes. ¡Te espero!