Типобезопасный SQL на Kotlin
Экспрессивность — интересное свойство языков программирования. С помощью простого комбинирования выражений можно добиться впечатляющих результатов. Некоторые языки осмысленно отвергают идеи выразительности, но Kotlin точно не является таким языком.
С помощью базовых конструкций языка и небольшого количества сахара мы попытаемся воссоздать SQL в синтаксисе Kotlin настолько близко, насколько это возможно.
Ссылка на GitHub для нетерпеливых
Нашей целью будет помочь программисту отловить определенное подмножество ошибок на этапе компиляции. Kotlin, являясь строготипизованным языком, поможет нам уйти от невалидных выражений в структуре SQL запроса. Как бонус, мы получим еще защиту от опечаток и помощь от IDE в написании запросов. Исправить недостатки SQL полностью не получится, но устранить некоторые проблемные места вполне возможно.
Данная статья расскажет про библиотеку на Kotlin, которая позволяет писать SQL запросы в синтаксисе Kotlin. Также, мы немного посмотрим на внутренности библиотеки, чтобы понять как это работает.
SQL расшифровывается как Structured Query Language, т.е. структура у запросов присутствует, хотя синтаксис оставляет желать лучшего — язык создавался, чтобы им мог воспользоваться любой пользователь, даже не имеющий навыков программирования.
Однако, под SQL скрывается довольно мощный фундамент в виде теории реляционных баз данных — там всё очень логично. Чтобы понять структуру запросов, обратимся к простой выборке:
SELECT id, name -- проекция (projection), π(id, name)
FROM employees -- источник (table)
WHERE organization_id = 1 -- выборка с предикатом (predicate), σ(organization_id = 1)
Что важно понять: запрос состоит из трех последовательных частей. Каждая из этих частей во-первых — зависит от предыдущей, во-вторых — подразумевает ограниченный набор выражений для продолжения запроса. На самом деле даже не совсем так: выражение FROM тут явно является первичным по отношению к SELECT, т.к. то, какой набор полей мы можем выбрать, напрямую зависит от таблицы, из которой производится выборка, но никак не наоборот.
Итак, FROM первичен по отношению к любым другим конструкциям языка запросов. Именно из этого выражения возникают все возможные варианты продолжения запроса. В Kotlin мы отразим это через функцию from (T), которая будет принимать на вход объект, представляющий из себя таблицу, у которой есть набор колонок.
object Employees : Table("employees") {
val id = Column("id")
val name = Column("name")
val organizationId = Column("organization_id")
}
Функция вернет объект, который содержит в себе методы, отражающие возможное продолжение запроса. Конструкция from всегда идет самой первой, перед любыми другими выражениями, поэтому она предполагает большое количество продожений, включая завершающий SELECT (в противоположность SQL, где SELECT всегда идет перед FROM). Код, эквивалентный SQL-запросу выше будет выглядеть следующим образом:
from(Employees)
.where { e -> e.organizationId eq 1 }
.select { e -> e.id .. e.name }
Интересно, что таким образом мы можем предотвратить невалидный SQL еще во время компиляции. Каждое выражение, каждый вызов метода в цепочке предполагает ограниченное число продолжений. Мы можем контролировать валидность запроса средствами языка Kotlin. Как пример — выражение where не предполагает после себя продолжения в виде еще одного where и, тем более, from, а вот конструкции groupBy, having, orderBy, limit, offset и завершающий select все являются валидными.
Лямбды, переданные в качестве агрументов операторам where и select призваны сконструировать предикат и проекцию соответственно (мы уже упоминали их ранее). На вход лямбде передается таблица, чтобы можно было обращаться к колонкам. Важно, что типобезопасность сохраняется и на этом уровне — с помощью перегрузки операторов мы можем добиться того, что предикат в конечном итоге будет представлять из себя псевдобулевое выражение, которое не скомпилируется при наличии синтаксической ошибки или ошибки, связанной с типами. То же самое касается и проекции.
fun where(predicate: (T) -> Predicate): WhereClause
fun select(projection: (T) -> Iterable): SelectStatement
Реляционные базы данных позволяют работать с множеством таблиц и связями между ними. Было бы хорошо дать возможность разработчику работать с JOIN и в нашей библиотеке. Благо, реляционная модель хорошо ложится на всё, что было описанно ранее — нужно лишь добавить метод join, который добавит вторую таблицу в наше выражение.
fun join(table2: T2): JoinClause
JOIN, в данном случае, будет иметь методы, аналогичные тем, что предоставляет выражение FROM, с тем лишь отличием, что лямбды проекции и предикатов будут принимать по два параметра для возможности обращения к колонкам обеих таблиц.
from(Employees)
.join(Organizations).on { e, o -> o.id eq e.organizationId }
.where { e, o -> e.organizationId eq 1 }
.select { e, o -> e.id .. e.name .. o.name }
Data manipulation language — средство языка SQL, которое позволяет помимо запросов к таблицам осуществлять вставку, модификацию и удаление данных. Эти конструкции хорошо вписываются в нашу модель. Для поддержки update и delete нам понадобится всего-лишь дополнить выражения from и where вариантом с вызовом соответствующих методов. Для поддержки insert, введем дополнительную функцию into.
from(Employees)
.where { e -> e.id eq 1 }
.update { e -> e.name("John Doe") }
from(Employees)
.where { e -> e.id eq 0 }
.delete()
into(Employees)
.insert { e -> e.name("John Doe") .. e.organizationId(1) }
SQL работает со структурированными данными в виде таблиц. Таблицы требуют описания перед началом работы с ними. Эта часть языка называется Data definition language.
Операторы CREATE TABLE и DROP TABLE реализованы аналогично — функция over будет служить стартовой точкой.
over(Employees)
.create {
integer(it.id).primaryKey(autoIncrement = true)..
text(it.name).unique().notNull()..
integer(it.organizationId).foreignKey(references = Organizations.id)
}
over(Employees).drop()