Pull to refresh

Сила @RawQuery. Сокращаем код DAO на 90%

Reading time6 min
Views5.8K

1 Мотивация

Я знаю как сократить код ваших Dao в 50-90%.

2 Вступление

Зачастую при написании приложения на платформу Android для сохранения данных используются популярная библиотека Room. В целом видимых проблем с ней не возникает когда у нас используется например 3 таблицы, но по мере роста бд становится заметным постоянное дублирование кода базовых CRUD операций. Для базовых Insert, Update и Delete операций зачастую просто копируется код.

@Dao
interface UserDao {
  @Insert
  fun insert(user: User)
  
  @Update
  fun update(user: User)
}

@Dao
interface CarDao {
  @Insert
  fun insert(car: Car)
  
  @Update
  fun update(car: Car)
}

Решить это можно с помощью наследования Dao, как указано здесь. Решение выглядит так:

interface InsertDao<T> {
  @Insert
  fun insert(item: T)
}

interface UpdateDao<T> {
  @Update
  fun update(item: T)
}

interface SaveDao<T> : InsertDao<T>, UpdateDao<T>

@Dao
interface UserDao : SaveDao<User>

@Dao
interface CarDao : SaveDao<Car>

Данный подход вполне себе обеспечивает Interface Segregation Principle из SOLID. Это правильно обеспечивать только нужной частью dao, если нам нужно выполнить только Insert без Update, мы просто обеспечиваем необходимый объект сущностью. Пример с InsertDao<User>:

class SomeClass<T>(private val insertDao: InsertDao<T>) {
  
  fun saveSomething(item: T) {
    insertDao.insert(item)
  }
  
}

Но весь Interface Segregation Principle рушится как только добавляются функции с аннотацией Query:

@Dao
interface UserDao : SaveDao<User> {

  @Query("SELECT * FROM USER")
  fun getAllUsers(): List<User>
  
}

@Dao
interface CarDao : SaveDao<Car> {

  @Query("SELECT * FROM CAR")
  fun getAllCars(): List<Car>
  
}

В таком случае мы можем воспользоваться решением предложенным там же в комментариях. И переработать код следующим образом:

interface GetAllDao<T> {
  
  @JvmSuppressWildcards
  fun getAll(): List<T>
  
}

@Dao
interface UserDao : SaveDao<User>, GetAllDao<User> {

  @Query("SELECT * FROM USER")
  @JvmSuppressWildcards
  override fun getAll(): List<User>
  
}

@Dao
interface CarDao : SaveDao<Car>, GetAllDao<Car> {

  @Query("SELECT * FROM CAR")
  @JvmSuppressWildcards
  override fun getAll(): List<Car>
  
}

Таким образом нам все еще удается придерживаться Interface Segregation Principle ценой добавления аннотации JvmSuppressWildcards. Но от повторяющегося кода мы так далеко и не ушли, ведь все Read операции из базы данных будут копироваться в каждой Dao да еще и базовую для них создавать надо.

Главная проблема состоит в том что мы не можем использовать Query в интерфейсе GetAllDao. Причина проста, у нас нет имени таблицы, над которой проводится операция. Рефлексия нам тут тоже не помощник, попробовав ее использовать:

interface GetAllDao<T> {
  
  @Query("SELECT * FROM T::class.java.simpleName)
  fun getAll(): List<T>
  
}

Получаем сразу две ошибки от компилятора:

  • An annotation argument must be a compile-time constant

  • Cannot use 'T' as reified type parameter. Use a class instead.


3 RawQuery в действии

Решить вышеописанную проблему можно используя аннотацию RawQuery. Поскольку большинство разработчиков не знакомы с ней ввиду 0% использования в большинстве проектов вкратце изложу суть.

RawQuery - одна из базовых аннотаций для функций внутри тела Dao с помощью которой можно создавать query к бд на Runtime.

Использование:

interface SomeDao {
  
  @RawQuery
  suspend fun getAll(query: SimpleSQLiteQuery): List<SomeEntity>
  
}

Где SimpleSqliteQuery - уже готовый запрос к бд.

Но это еще не все, ведь SimpleSqliteQuery нужно еще создать. Логика использования очень проста, объект создается следующим образом для простого запроса:

val sqliteRawQuery = SimpleSqliteQuery("SELECT * FROM TABLE_NAME")

И для запроса с аргументами:

val query = "SELECT * FROM TABLE_NAME WHERE id = ?"
val args = listOf(13)
val sqliteRawQuery = SimpleSqliteQuery(query, args)

Таким образом разобравшись как работает SimpleSqliteQuery можем приступить к конечной реализации.

Сперва переработаем наши Dao заменив Query на RawQuery.

interface GetAllDao<T> {
  
  @RawQuery
  fun getAll(): List<T>
  
}

@Dao
interface UserDao : SaveDao<User>, GetAllDao<User>

@Dao
interface CarDao : SaveDao<Car>, GetAllDao<Car>

Повторяющегося кода в Dao стало значительно меньше и это учитывая что у нас была только одна такая функция только в двух Dao.

Далее я предлагаю использовать Helper для работы с Dao поскольку он инкапсулирует в себе логику создания запроса. Создадим базовый класс Helper.

abstract class Helper<T>(private val classType: Class<T>) {
  
  protected val table: String
    get() = classType.simpleName

  protected abstract val query: String

  protected val sqliteQuery: SimpleSQLiteQuery
    get() = SimpleSQLiteQuery(query, getBindArgs())

  protected open fun getBindArgs(): Array<Any> = arrayOf()

}

Дальше станет понятно зачем он нужен и как работает).

Следующим шагом создадим GetAllDaoHelper для получения данных из бд.

class GetAllDaoHelper<T> @Inject constructor(
  private val itemDao: GetAllDao<T>,
  classType: Class<T>,
) : Helper<T>(classType) {

  override val query = "SELECT * FROM $table"

  suspend fun getAll() : List<T> {
    return itemDao.getAll(sqliteQuery)
  }

}

Теперь разберем по порядку, что происходит. Переменная query инкапсулирует в себе запрос к базе данных. И при этом использует поле table.

override val query = "SELECT * FROM $table"

Поле table получает свое значение из переменной конструктора в базовом классе

abstract class Helper<T>(private val classType: Class<T>) {

    //.....

  protected val table: String
    get() = classType.simpleName
  
  	//......
}

Здесь simpleName представляет простое имя класса T, которое было указано при объявлении, то есть если класс называется Car, то и его simpleName будет Car.

Переменная classType же приходит к нам в конструтор, об этом позже.

Вернувшись к GetAllItemsDaoHelper мы еще видим метод getAll()

class GetAllItemsDaoHelper<T> @Inject constructor(
  private val itemDao: GetAllDao<T>,
  //..
) : Helper<T>(..) {

  //..

  suspend fun getAll() : List<T> {
    return itemDao.getAll(sqliteQuery)
  }

}

Как мы видим он использует уже известный GetAllDao c аргументом sqliteQuery.

abstract class Helper<T>(private val classType: Class<T>) {

  protected abstract val query: String

  //..

  protected val sqliteQuery: SimpleSQLiteQuery
    get() = SimpleSQLiteQuery(query, getBindArgs())

  protected open fun getBindArgs(): Array<Any> = arrayOf()

}

Тут SimpleSqliteQuery создается уже по известной нам схеме, а метод getBindArgs не обязателен для переопределения, но может использоваться в случае использования аргументов в query.

Таким образом взглянув на класс GetAllDaoHelper остается лишь один вопрос, откуда мы берем переменную classType.

class GetAllItemsDaoHelper<T> @Inject constructor(
  //..
  classType: Class<T>,
) : Helper<T>(classType) {

Ответ: из Dependency Injection. В данном примере я использую Hilt DI, ввиду простоты его работы с Generics в отличие от Koin. Таким образом когда мы добавляем сущность в базу данных необходимо внести в граф так же ее Class<Entity>, а именно по нашему примеру:

@Module
@InstallIn(SingletonComponent::class)
object EntityModule {

  @Provides
  fun provideCarClass(): Class<Car> = Car::class.java

  @Provides
  fun provideUserClass(): Class<User> = User::class.java

  //И так далее

}

Уж не говорю что для работы программы GetAllDao<Car> и GetAllDao<User> тоже должны быть добавлены в граф.


4. Итоги

Данный подход имеет как плюсы так и минусы, не все были ранее описаны.

«Простейшая форма дублирования — куски одинакового кода. Программа выглядит так, словно у программиста дрожат руки, и он снова и снова вставляет один и тот же фрагмент.» - Роберт Мартин.

Начнем с очевидных и наиболее значительных плюсов.

  1. Используя выше описанные рекомендации значительно уменьшается дублирование кода Dao. Последствия дублирования кода вы сами знаете.

  2. Простота добавления новых Dao сведена к минимуму и при наличии повторяющихся действий нужно просто наследоваться от необходимых интерфейсов. А это означает прямое следование принципам Interface Segregation Principle и Open-Closed Principle.

  3. Упрощенное тестирование(Если интересно что конкретно это значит, я могу рассказать это в следующей статье).

Теперь к минусам в порядке важности:

  1. Ошибки на runtime. Главная проблема такого подхода в том, что ошибки возникают не на стадии компиляции, а на стадии работы программы. Как по мне этот минус нивелируется при качественном покрытии тестами;

  2. При использовании Observable запросов (c возвращаемым типом Flow, PagingSource и т.д.) необходимо напрямую указывать за какими таблицами надо наблюдать;

interface GetPagingSourceDao<T : Any> {

  @RawQuery
  fun getPagingSource(query: SimpleSQLiteQuery): PagingSource<Int, T>

}

@Dao
interface CharacterDao : GetPagingSourceDao<Character> {

  @RawQuery(observedEntities = [Character::class])
  override fun getPagingSource(
    query: SimpleSQLiteQuery
  ): PagingSource<Int, Charater>

}
  1. Непонимание в глазах нового человека. Поскольку с RawQuery знакомо мало людей, новый человек взглянув на этот код не сразу поймет к чему;

  2. Необходимо добавлять в DI Class<Entity> для каждой сущности которая использует RawQuery через предложенный мной Helper;

  3. Дополнительный уровень абстракции над Dao(как по мне не является проблемой, но в больших программах может усложнить понимание);

  4. RawQuery функция обязательно должна что-то возвращать. (Особо не проблема, но при кастомном Update или Delete надо указывать хотя он и не нужен);

  5. TableName в Entity менять нельзя ставить кастомный, иначе логика simpleName не будет работать(не является проблемой как по мне но кому-то может не понравиться).

Мой playground где я использую этот подход https://github.com/HalfAPum/Playground

Tags:
Hubs:
+6
Comments6

Articles