null

WhoAsked: Пишем простое объектно-реляционное отображение на kotlin

Что такое объектно-реляционное отображение, или ORM? Говоря простым языком, это программный продукт, который позволяет управлять данными в реляционных базах данных при помощи средств языка программирования. Известными примерами ORM могут являться Hibernate или Sprig Data JPA. В последнее время, множество проблем, с которыми приходилось сталкиваться, были связаны именно с особенностями реализации той или иной системы ORM. Чтобы понять, откуда может быть столько нюансов в работе с этими системами, было принято решение сделать минимальную реализацию подобной штуки самому.

Для начала определимся с границами применения. Наша реализация будет поддерживать 6 базовых операций:

* createTable - создать таблицу

* insert - добавить объекты в коллекцию

* selectById - достать объект из коллекции по id

* selectAll - достать все объекты из коллекции

* update - обновить объект в коллекции

* delete - удалить объект из коллекции

По сути, нам нужно придумать способ трансляции метаинформации о Java-классах в текстовые SQL-запросы и обратно. При такой постановке вопроса в голове сразу всплывает термин рефлексия. И не зря, именно на него мы будем, во многом, полагаться. Рефлексия, это своебразный API, с помощью которого мы можем производить с Java-объектами и их экземплярами различные операции на уровне метаинформации. Получать доступ ко всем параметрам, вне зависимости от модификаторов доступа, проверять наличие аннотаций, конструировать объекты и многое другое.

Начнем именно с определения кастомных аннотаций. Для данного небольшого проекта, мы определим три аннотации: @Table, уровня класса, которая объявляет, что данный класс может быть представлен в виде таблицы в реляционной базе данных, и мо можем с ним работать, @PrimaryKey, уровня поля, которая объявляет данное поле первичным ключом таблицы, и @NotNull, тоже уровня поля, которая устанавливает констрейнт not null на данное поле.

annotation class Table(val name: String)
@Target(AnnotationTarget.FIELD)
annotation class PrimaryKey()
@Target(AnnotationTarget.FIELD)
annotation class NotNull()

С аннотацией Table также свяжем аттрибут name. Мы могли бы использовать имя класса с помощью рефлексии, но такой подход даст нам больше гибкости в настройке отображения, к примеру, в названиях таблиц и Java-классов принято использовать разный casing.

Первая функция, которую мы реализуем - createTable. В первую очередь, с помощью рефлексии проверяем, есть ли у переданного класса аннотация Table. Если есть, достаем аттрибут name, если нет - бросаем ошибку. Все классы, с которыми мы собираемся работать, должны быть помечены этой аннотацией. Далее фетчим все таблицы текущей базы данных, если находим с таким названием, выходим, таблица уже создана. Если нет, при помощи рефлексии обходим поля класса и конструируем SQL create table запрос. Для каждого поля проверяем аннотации PrimaryKey и NotNull, где необходимо вставляем соответствующие констрейнты. Отдельно стоит заметить, что нам нужен метод convertType, который переводит некоторые Java-типы к типам SQL.

 fun convertType(s: String): String {
    when (s) {
      "java.lang.String" -> return "text"
      "int" -> return "integer"
      "boolean" -> return "boolean"
      "double" -> return "double precision"
      else -> return "text"
    }
  }

  fun createTable(t: Class<*>) {
    val name = t.annotations.find { it is Table }?.let { (it as Table).name }
        ?: throw IllegalArgumentException("Class should have a 'Table' annotation")
    val rs = db.metaData.getTables(null, "public", "%", null)
    val tables = ArrayList<String>()
    while (rs.next())
      tables.add(rs.getString(3).substringBefore("_"))
    if (name in tables) return
    val fields = t.declaredFields
    db.createStatement().executeUpdate("create table $name (${fields.map {
      "${it.name} " +
          if (it.annotations.any { it is PrimaryKey }) { " serial primary key" } else "${convertType(it.type.typeName)}" +
          if (it.annotations.any { it is PrimaryKey }) "" else "" +
              if (it.annotations.any { it is NotNull }) " not null" else ""
    }.joinToString(", ")})")
  }

Далее, реализуем процедуру insert. Для начала, как обычно, проверяем аннотацию Table на классе переданного объекта. Это касается всех реализуемых процедур. Далее, при помощи рефлексии заполняем две коллекции - названий полей (за исключением первичного ключа) и их значений. Затем, ищем в базе данных нужную таблицу, и, наконец, конструируем SQL insert into запрос, довольно просто.

  fun insert(obj: Any) {
    val tableName = obj::class.java.annotations.find { it is Table }?.let { (it as Table).name }
        ?: throw IllegalArgumentException("Object should be represented as table")
    val fields = obj::class.java.declaredFields.filterNot { it.annotations.any { it is PrimaryKey } }
    val values = fields.map {
      it.isAccessible = true
      if (!(it.type.name in (arrayOf("int", "double", "boolean"))))
        "'${it.get(obj)}'"
      else it.get(obj)
    }
    val rs = db.metaData.getTables(null, "public", "%", null)
    val tables = ArrayList<String>()
    val fullTables = ArrayList<String>()
    while (rs.next())
      fullTables.add(rs.getString(3))
    while (rs.next())
      tables.add(rs.getString(3).substringBefore("_"))
    var table = ""
    fullTables.map { if (it.substringBefore("_").equals(tableName.toLowerCase())) table = it }
    val statement = "insert into $tableName (${ fields.map{ it.name }.joinToString(", ") }) values (${ values.map { it }.joinToString(", ") }) returning id"
    println(statement)
      db.createStatement().execute(statement)
  }

Следующая процедура - selectById. Это первая параметризированная процедура, что значит, что помимо самих аргументов, она еще принимает класс, для которого она будет запущена (на этом этапе абстрактный T). Почему так? Потому что это первая процедура, которая должна действительно вернуть объект искомого класса. Поэтому мы объявляет T, объект которого планируем возвращать. Как обычно, проверяем аннотацию Table и достаем имя таблицы. Далее выполняем простейший запрос - "select * from $tableName where id = $id" . Дальше остается только замапить полученные значения на поля класса и сконструировать инстанцию, которую планируем возвращать. Делаем это при помощи рефлексии.

  fun <T> selectById(t: Class<T>, id: Int): T {
    val tableName = t.annotations.find { it is Table }?.let { (it as Table).name }
        ?: throw IllegalArgumentException("Object should be represented as table")
    var statement = "select * from $tableName where id = $id"
    val result = db.createStatement().executeQuery(statement)
    val fields = t.declaredFields
    fields.map { it.isAccessible = true }
    val f = t.newInstance()
    while (result.next())
      for (i in 0..fields.size - 1)
        when (fields[i].type.name) {
          "java.lang.String" -> fields[i].set(f, result.getString(i + 1))
          "double" -> fields[i].set(f, result.getDouble(i + 1))
          "int" -> fields[i].set(f, result.getInt(i + 1))
          "javafx.scene.paint.Color" -> fields[i].set(f, Color.valueOf(result.getString(i + 1)))
          "java.time.ZonedDateTime" -> fields[i].set(f, ZonedDateTime.parse(result.getString(i + 1)))
        }
    return f
  }

Далее реализуем похожую процедуру - selectAll. Логика почти такая же, только запрос превращается в еще более простой "select * from $tableName" , а потом маппинг и составление результирующих объектов происходит в цикле.

 fun <T> selectAll(t: Class<T>): List<T> {
    val tableName = t.annotations.find { it is Table }?.let { (it as Table).name }
        ?: throw IllegalArgumentException("Object should be represented as table")
    var statement = "select * from $tableName"
    val result = db.createStatement().executeQuery(statement)
    val resArray = ArrayList<String>()
    val answer = ArrayList<ArrayList<String>>()
      while (result.next()) {
        for (cName in t.declaredFields) {
          resArray.add(result.getString(cName.name))
          println(cName.name)
        }
      }
      while (resArray.size != 0) {
        answer.add(resArray.subList(0, t.declaredFields.size).toCollection(ArrayList()))
        for (i in 0..(t.declaredFields.size - 1)) {
          resArray.remove(resArray[0])
        }
      }
    print(answer)
    val fields = t.declaredFields
    for (field in fields) {
      println(field.type.name)
      field.isAccessible = true
    }
    var res = listOf<T>()
    for (element in answer) {
      val f = t.newInstance()
      for (i in 0..fields.size - 1)
        when (fields[i].type.name) {
          "java.lang.String" -> fields[i].set(f, element[i])
          "double" -> fields[i].set(f, element[i].toDouble())
          "int" -> fields[i].set(f, element[i].toInt())
          "javafx.scene.paint.Color" -> fields[i].set(f, Color.valueOf(element[i]))
          "java.time.ZonedDateTime" -> fields[i].set(f, ZonedDateTime.parse(element[i]))
        }
      res += f
    }
    return res
  }

Далее, реализуем процедуру update. Из соображений упрощение, у нас не будет возможности обновить несколько полей - функция принимает имя поля и новое значение. Бойлерплейт код по доставанию имени таблицы, и затем исполнение простого запроса - "update $tableName set $column = $tValue where id = $id".

  fun update(t: Class<*>, column: String, value: Any, id: Int){
    val tableName = t.annotations.find { it is Table }?.let { (it as Table).name }
        ?: throw IllegalArgumentException("Object should be represented as table")
    var tValue = ""
    if (value.javaClass.typeName.equals("java.lang.String"))
      tValue = "'$value'"
    else tValue = value.toString()
    val statement = "update $tableName set $column = $tValue where id = $id"
    println(statement)
    db.createStatement().execute(statement)
  }

И, напоследок, реализуем процедуру delete. Она будет принимать опциональный параметр id - передали, значит хотим удалить по этому ключу, нет - хотим удалить все объекты. После получения имени таблицы готовим стейтмент - "delete from $tableName [where id = $id]" . Все просто.

  fun delete(t: Class<*>, id: Int? = null) {
    val tableName = t.annotations.find { it is Table }?.let { (it as Table).name }
        ?: throw IllegalArgumentException("Object should be represented as table")
    var statement = "delete from $tableName"
    if (id != null)
      statement += " where id = $id"
    db.createStatement().execute(statement)
  }

___

Зачем мы это все сделали? Ну, лично я, кажется, теперь лучше понимаю, откуда ростут ноги у многих особенностей и фрустрирующих ограничений ORM, которые я использую, а еще об этом просто интересно было написать. Вот так вот, до новых WhoAsked.