Android Room - How can I check if an entity with the same name already exists before inserting?

Viewed 598

I'm creating an app using the mvvm pattern with android room, but I've ran into some trouble validating user input. When a user wants to add an ingredient to the app, they are required to enter a name for this ingredient. I want the app to notify the user if the name is already in use. I have tried some stuff using the Transformations.Map() functions but without any success.

I'm fairly new to the mvvm pattern and LiveData, and I've been stuck on this for quite a while now so any advice would be appreciated.

This is the ingredient entity:

@Entity(tableName = "ingredient")
public class BaseIngredient {

@PrimaryKey(autoGenerate = true)
private int id;

private String name;

private String category;

@ColumnInfo(name = "cooking_time")
private int cookingTime;

@Ignore
public BaseIngredient() {
}

public BaseIngredient(int id, @NonNull String name, @NonNull String category, int cookingTime)
        throws InvalidValueException {
    this.id = id;
    setName(name);
    setCookingTime(cookingTime);
    setCategory(category);
}

public void setName(String name) throws InvalidNameException {
    if (name == null || name.isEmpty())
        throw new InvalidNameException("Name is empty");
    if (!name.matches("[A-z0-9]+( [A-z0-9]+)*"))
        throw new InvalidNameException("Name contains invalid tokens");

    this.name = name;
}

public void setCategory(String category) throws InvalidCategoryException {
    if (category == null || category.isEmpty())
        throw new InvalidCategoryException("Category is empty");
    if (!category.matches("[A-z0-9]+"))
        throw new InvalidCategoryException("Category contains invalid tokens");

    this.category = category;
}

public void setCookingTime(int cookingTime) throws InvalidCookingTimeException {
    if (cookingTime < 1)
        throw new InvalidCookingTimeException("Time must be positive");

    this.cookingTime = cookingTime;
}

/* getters */

public boolean isValid() {
    return name != null && category != null && cookingTime != 0;
}

This is the IngredientRepository I'm using:

private IngredientDao ingredientDao;

private LiveData<List<BaseIngredient>> ingredients;

public IngredientRepository(Application application) {
    LmcfyDatabase database = LmcfyDatabase.getDatabase(application.getApplicationContext());
    ingredientDao = database.ingredientDao();
    ingredients = ingredientDao.getAllIngredients();
}

public LiveData<List<BaseIngredient>> getAllIngredients() {
    return ingredients;
}

public LiveData<List<BaseIngredient>> getIngredientsWithQuery(String query) {
    return ingredientDao.getIngredientsWithQuery("%" + query + "%");
}

public void insert(BaseIngredient ingredient) {
    LmcfyDatabase.databaseWriteExecutor.execute(() -> {
        ingredientDao.insert(ingredient);
    });
}

public LiveData<Integer> getIngredientsWithNameCount(String name) {
    return ingredientDao.getIngredientsWithNameCount(name);
}

The IngredientDao:

@Insert(onConflict = OnConflictStrategy.IGNORE, entity = BaseIngredient.class)
long insert(BaseIngredient ingredient);

@Delete(entity = BaseIngredient.class)
void delete(BaseIngredient ingredient);

@Query("SELECT * FROM ingredient")
LiveData<List<BaseIngredient>> getAllIngredients();

@Query("SELECT * FROM ingredient WHERE name LIKE :query")
LiveData<List<BaseIngredient>> getIngredientsWithQuery(String query);

@Query("SELECT COUNT(id) FROM ingredient WHERE name LIKE :name")
LiveData<Integer> getIngredientsWithNameCount(String name);

And finally the ViewModel that is used to create an Ingredient

    private final IngredientRepository repository;

private final BaseIngredient ingredient;

private final MutableLiveData<String> nameError;

private final MutableLiveData<String> categoryError;

private final MutableLiveData<String> cookingTimeError;

private final MutableLiveData<Boolean> ingredientValidStatus;

public AddIngredientViewModel(@NonNull Application application) {
    super(application);
    repository = new IngredientRepository(application);
    ingredient = new BaseIngredient();

    nameError = new MutableLiveData<>();
    categoryError = new MutableLiveData<>();
    cookingTimeError = new MutableLiveData<>();
    ingredientValidStatus = new MutableLiveData<>();
}

public void onNameEntered(String name) {
    try {
        ingredient.setName(name);
        nameError.setValue(null);
    } catch (InvalidNameException e) {
        nameError.setValue(e.getMessage());
    } finally {
        updateIngredientValid();
    }
}

public void onCategoryEntered(String category) {
    try {
        ingredient.setCategory(category);
        categoryError.setValue(null);
    } catch (InvalidCategoryException e) {
        categoryError.setValue(e.getMessage());
    } finally {
        updateIngredientValid();
    }
}

public void onCookingTimeEntered(int cookingTime) {
    try {
        ingredient.setCookingTime(cookingTime);
        cookingTimeError.setValue(null);
    } catch (InvalidCookingTimeException e) {
        cookingTimeError.setValue(e.getMessage());
    } finally {
        updateIngredientValid();
    }
}

private void updateIngredientValid() {
    ingredientValidStatus.setValue(ingredient.isValid());
}

public boolean saveIngredient() {
    if (ingredient.isValid()) {
        Log.d(getClass().getName(), "saveIngredient: Ingredient is valid");
        repository.insert(ingredient);
        return true;
    } else {
        Log.d(getClass().getName(), "saveIngredient: Ingredient is invalid");
        return false;
    }
}

The onXXEntered() functions in the viewmodel are linked to the textViews in the fragment, and the saveIngredient() function is called when a save button is pressed. The XXError LiveData's are used to display errors to the user.

The real problem lies in the fact that LiveData is async, and the user can change their input and click the save button before the LiveData contains the result from the database. If I want the check the input upon saving it, the 'add activity' will have already finished before the check is done.

Again, any help would be very much appreciated.

1 Answers

I had to do something similar in one of my recent projects. What I did was:

  1. Room cannot create columns with SQLite Unique constraint (if it is not the PrimaryKey - which is your case). So don't initialize the database in your app code using Room. Instead, create a database file outside your application. Add a Unique constraint on the 'name' column. Then add the database file in your project under assets folder. (for example, create a subfolder inside assets - 'db_files' - and copy your pre-created database file under this folder)

  2. I guess you use Singleton pattern for your @DataBase class. Replace your 'getInstance()' method with following:

public static MyDB getInstance(final Context context) {
        if(INSTANCE == null) {
            synchronized (AVListDB.class) {
                INSTANCE = Room.databaseBuilder(context.getApplicationContext(),
                        MyDB.class,"myDB.db")
                        .createFromAsset( "db/myDB.db")
                        .build();

            }
        }
        return INSTANCE;
    }

This creates a copy of your pre-packaged database file under applications database files path.

  1. With unique constraint in place, your @Insert and @Update annotated methods will respect the constraint condition, and will throw a SQLiteConstraintException if you try to insert a previously used name. You can catch this exception, and pass it to your View or ViewModel as you wish (I implemented a simple centralized event publisher component).

I hope that helps.

Related