Привет, Хабр!
Уже много лет я занимаюсь Swift-разработкой, причем значительная часть моих open-source проектов выросла из желания сделать повседневную работу с кодом максимально простой, красивой и приятной. Когда-то так появился CodyFire, про который я рассказывал здесь в 2018 году, а позднее SwifQL, Bridges и целый набор других библиотек, которые я использую преимущественно в серверных Swift-приложениях.
За время работы на серверной стороне я привык к PostgreSQL, к его мощному SQL и к тому, что с данными можно делать всё необходимое на уровне самой базы. При этом благодаря своим библиотекам я уже мог описывать таблицы обычными Swift-моделями, строить SQL с помощью type-safe key paths и пользоваться декларативным синтаксисом с result builders, который мне особенно нравится в современном Swift.
В результате получился такой способ работы с БД, от которого мне совершенно не хотелось отказываться при переключении на iOS или macOS разработку. В идеале я хотел бы писать модели и запросы практически одинаково независимо от того, выполняется код на сервере или внутри приложения, и при этом иметь на клиенте сопоставимую мощность для работы с данными.
С самим Swift API больших проблем я не видел, потому что уже имел SwifQL (нынешний SwiftStream/SQL), наработки из Bridges и хорошее представление о том, как всё это должно выглядеть. А вот с выбором движка оказалось значительно интереснее.
В поисках PostgreSQL для мобильных приложений
Первое, что приходит в голову при желании получить на клиенте возможности PostgreSQL, это попробовать использовать сам PostgreSQL. Но здесь есть одна существенная проблема: его архитектура рассчитана на работу в качестве серверной СУБД со своим отдельным процессом и управлением соединениями, а мне нужен движок, который можно просто встроить внутрь приложения, как SQLite.
Я поискал существующие решения и проекты, пытавшиеся адаптировать PostgreSQL для мобильных устройств, но готового варианта, который меня бы устроил, не нашел. Чтобы получить полноценную embedded-базу на основе PostgreSQL с нормальным жизненным циклом внутри приложения, пришлось бы серьезно заниматься адаптацией самого движка, а это уже совершенно другой проект, которым я заниматься не собирался.
Среди существующих embedded-баз, конечно, есть SQLite и множество замечательных инструментов вокруг неё, включая GRDB, Core Data, SwiftData и другие способы организации локального хранения. С самим SQLite у меня нет никаких принципиальных проблем, тем более что он прекрасно справляется с огромным количеством задач. Но в данном случае мне хотелось исследовать другой вариант: получить гораздо более богатые возможности самого SQL-движка и при этом не отказываться от привычного PostgreSQL-подобного синтаксиса.
Именно во время этих поисков я познакомился с DuckDB, который оказался удивительно близок к тому, что мне было нужно.
Разработчики DuckDB создали полноценную реляционную СУБД, которая по умолчанию работает прямо внутри процесса приложения и не требует отдельного database server. При этом её SQL dialect основан на PostgreSQL, а возможности движка выходят далеко за рамки простых операций сохранения и чтения отдельных записей: сложные запросы, мощные агрегации, window functions, богатая система типов, транзакции, векторизованное выполнение запросов и эффективная работа с большими объемами данных.
Изначально DuckDB создавался прежде всего для аналитических нагрузок, и это важно понимать: он не является ни портом PostgreSQL, ни полной заменой SQLite для любых сценариев использования. Но для моих целей сочетание embedded-архитектуры, мощного SQL и знакомого синтаксиса оказалось настолько удачным, что захотелось немедленно попробовать перенести на него весь мой серверный подход к работе с базами данных.
У DuckDB уже есть официальный Swift client, но он предоставляет преимущественно базовые возможности взаимодействия с движком. Я же хотел значительно более богатый API, в котором использовались бы современные возможности Swift и который по красоте кода был бы максимально близок к тому, что я уже привык использовать в своих серверных проектах.
Поэтому я взял SwiftStream/SQL, перенес подходы из Bridges, добавил поддержку моделей, Codable, async/await, миграций, транзакций и других привычных вещей и начал постепенно собирать всё это поверх DuckDB.
В результате появилась библиотека Duck, которая недавно добралась до первой стабильной версии. Под капотом используется настоящий DuckDB 2.0.0, а поверх него я постарался реализовать тот самый удобный и красивый способ работы с базами данных на Swift, ради которого всё и затевалось.
Модели и работа с данными
Для начала посмотрим, как может выглядеть самая обычная модель:
import Duck
import Foundation
struct Todo: Table {
@Column("id") var id: UUID
@Column("name") var name: String
@Column("created_at") var createdAt: Date
@Column("done_at") var doneAt: Date?
@Column("deleted_at") var deletedAt: Date?
init() {}
init(
id: UUID = UUID(),
name: String,
createdAt: Date = Date(),
doneAt: Date? = nil,
deletedAt: Date? = nil
) {
self.id = id
self.name = name
self.createdAt = createdAt
self.doneAt = doneAt
self.deletedAt = deletedAt
}
}Для тех, кто уже знаком с моими SQL-библиотеками, здесь всё должно быть достаточно привычно. Мы описываем обычную Swift-структуру, соответствующую протоколу Table, а @Column связывает её свойства с колонками в базе данных и предоставляет типизированные ссылки для последующих запросов.
Поскольку Duck использует DatabaseIdentifier, нам даже не обязательно явно открывать connection для каждой простой операции. Например, определим идентификатор базы, которая будет храниться в Application Support:
private var appDatabaseDirectory: URL {
FileManager.default
.urls(
for: .applicationSupportDirectory,
in: .userDomainMask
)[0]
}
extension DatabaseIdentifier {
static var app: Self {
Self(
path: appDatabaseDirectory
.appendingPathComponent("Todos.duckdb")
.path
)
}
}Сам DatabaseIdentifier не открывает нативное соединение при создании. Он описывает, с какой базой мы собираемся работать, а все необходимые ресурсы будут получены уже при выполнении операций.
Теперь после создания схемы можно выполнить обычный INSERT:
let todo = Todo(name: "Написать статью на Хабр")
try await todo.insertNonReturning(on: .app)Или сразу получить сохраненную модель:
let inserted = try await todo.insert(on: .app)UPDATE работает с измененными полями модели:
var updated = inserted
updated.doneAt = Date()
updated = try await updated.update(
on: \.$id,
on: .app
)А получить все записи из таблицы можно вообще одной строкой:
let todos = try await Todo.all(on: .app)Всё довольно просто, но именно этого я и добивался. Мне не нужно каждый раз вручную открывать connection, строить INSERT и потом декодировать результат, если речь идет об обычной работе с моделью.
При этом вся эта простота никак не ограничивает возможность писать полноценный SQL.
SQL, который мне хотелось иметь и на клиенте
Одна из моих любимых особенностей Duck заключается в том, что для построения запросов здесь используется непосредственно SwiftStream/SQL, поэтому API для работы с SQL на сервере и на клиенте получается практически одинаковым.
Например, нам нужно получить все неудаленные Todo, отсортированные по дате создания:
let todos = try await Todo
.query
.where(Todo.$deletedAt == nil)
.orderBy(.asc(Todo.$createdAt))
.all(on: .app)Duck сразу вернет массив [Todo], поскольку тип результата здесь известен из модели.
Но мне особенно нравится другой способ описания того же запроса:
let query = SQL {
Select {
\Todo.$id
\Todo.$name
\Todo.$createdAt
\Todo.$doneAt
\Todo.$deletedAt
}
From(Todo.table)
Where {
\Todo.$deletedAt == nil
}
OrderBy {
OrderByItem.asc(\Todo.$createdAt)
}
}
let todos = try await query.all(
decoding: Todo.self,
on: .app
)Это обычная декларация SQL, только вместо ручного составления строки мы используем Swift Result Builder. Сам запрос по своей структуре остается практически идентичен тому, который я написал бы для PostgreSQL, а Swift позволяет проверить типы колонок и корректно передать значения через prepared bindings.
Такой способ особенно приятен, когда запрос начинает усложняться. Например, можно воспользоваться обычным Swift control flow внутри Where:
let includeCompleted = false
let query = SQL {
Select {
\Todo.$id
\Todo.$name
\Todo.$createdAt
\Todo.$doneAt
\Todo.$deletedAt
}
From(Todo.table)
Where {
\Todo.$deletedAt == nil
if !includeCompleted {
\Todo.$doneAt == nil
}
}
OrderBy {
OrderByItem.asc(\Todo.$createdAt)
}
}Этот же DSL я могу использовать для сложных JOIN'ов, группировок, агрегатов и других SQL-конструкций.
Еще одна моя любимая возможность SwiftStream/SQL заключается в том, что запросы можно разбивать на переиспользуемые компоненты и собирать из них другие запросы, практически не усложняя синтаксис.
Например, вынесем фильтрацию Todo в отдельную структуру:
struct TodoFilter: SQLQuery {
let includeCompleted: Bool
var query: Query {
Where {
\Todo.$deletedAt == nil
if !includeCompleted {
\Todo.$doneAt == nil
}
}
}
}Теперь используем этот фильтр в обычном SELECT:
let query = SQL {
Select(\Todo.$id, \Todo.$name)
From(Todo.table)
TodoFilter(includeCompleted: false)
OrderBy(.asc(\Todo.$createdAt))
}И тот же фильтр в запросе для подсчета записей:
let query = SQL {
Select(Fn.count(\Todo.$id) => "count")
From(Todo.table)
TodoFilter(includeCompleted: false)
}Точно так же можно вынести и целый запрос:
struct TodosQuery: SQLQuery {
let includeCompleted: Bool
var query: Query {
Select {
\Todo.$id
\Todo.$name
\Todo.$createdAt
\Todo.$doneAt
\Todo.$deletedAt
}
From(Todo.table)
TodoFilter(includeCompleted: includeCompleted)
OrderBy(.asc(\Todo.$createdAt))
}
}И теперь использовать его в разных местах приложения, передавая только необходимые параметры:
let todos = try await TodosQuery(
includeCompleted: false
).all(decoding: Todo.self, on: .app)Особенно приятно, что композиция не требует никакой дополнительной инфраструктуры: целые запросы, их отдельные части и обычные SQL-выражения можно описывать как Swift-структуры, а затем свободно комбинировать между собой.
Так же вовсе не обязательно каждый раз выбирать все поля таблицы только ради последующего декодирования в Codable.
Допустим экрану нужны только id и name. Создадим небольшую модель:
struct TodoSummary: Decodable {
let id: UUID
let name: String
}И выберем ровно нужные колонки:
let summaries = try await Todo
.select(\.$id, \.$name)
.where(\Todo.$deletedAt == nil)
.all(
decoding: TodoSummary.self,
on: .app
)Тот же принцип работает для совершенно произвольного SQL result. Например, результат агрегирующего запроса можно декодировать в отдельную структуру:
struct TodoCount: Decodable {
let count: Int64
}
let query = SQL {
Select(
Fn.cast(Fn.count(\Todo.$id), .bigint) => "count"
)
From(Todo.table)
Where(\Todo.$deletedAt == nil)
}
let count = try await query.first(
decoding: TodoCount.self,
on: .app
)?.count ?? 0Таким образом Codable здесь не пытается диктовать, какой запрос вы должны написать. Мы описываем нужный SQL, выбираем интересующие нас поля и декодируем их в подходящую Swift-модель, а если нужен полный контроль над результатом, всегда остается возможность работать непосредственно с QueryResult и ResultRow.
Миграции и транзакции
Я уже показывал, как можно работать с Todo, но для этого в базе, разумеется, должна существовать соответствующая таблица.
Здесь мне тоже хотелось сохранить подход, к которому я привык на сервере, поэтому в Duck используется декларативная система миграций:
extension Todo {
struct Create: Migration {
let name = "Todo.Create"
var up: MigrationPlan {
CreateTable("todo") {
NewColumn("id", .uuid).primaryKey()
NewColumn("name", .text).notNull()
NewColumn("created_at", .timestamptz).notNull()
NewColumn("done_at", .timestamptz)
NewColumn("deleted_at", .timestamptz)
}
}
var down: MigrationPlan {
DropTableBuilder<Todo>()
}
}
}При первоначальной подготовке приложения создаем директорию для базы и выполняем миграции:
try FileManager.default.createDirectory(
at: appDatabaseDirectory,
withIntermediateDirectories: true
)
_ = try await DatabaseIdentifier.app
.migrator([Todo.Create()])
.migrate()Duck сохраняет историю выполненных миграций и применяет только те, которые еще не были выполнены. Поддерживаются up и down, группировка по batches и выполнение дополнительной работы внутри миграции.
Транзакции тоже получились вполне привычными:
let connection = try await DatabaseIdentifier.app.connect()
try await connection.transaction { transaction in
let first = Todo(name: "Первый")
let second = Todo(name: "Второй")
try await first.insertNonReturning(on: transaction)
try await second.insertNonReturning(on: transaction)
}При успешном завершении closure выполняется COMMIT, а ошибка или отмена task приведет к ROLLBACK.
Есть и возможность явно открыть транзакцию через connection.transaction() и самостоятельно управлять её завершением. Такой вариант полезен, когда операции невозможно удобно объединить в одну closure, но требует явно вызвать commit() или rollback().
Еще немного возможностей самого DuckDB
Для обычных операций с моделями всех предыдущих примеров вполне достаточно, но наиболее интересные возможности движка проявляются при работе с большими объемами данных и более сложными типами.
Например, Duck поддерживает native LIST, поэтому массив Swift-значений необязательно предварительно сериализовывать в JSON.
Опишем пользователя с набором тегов:
struct User: Table {
static let tableName = "users"
@Column("id") var id: Int64
@Column("tags") var tags: [String]
init() {}
init(id: Int64, tags: [String]) {
self.id = id
self.tags = tags
}
}В схеме поле tags можно создать как настоящий DuckDB LIST:
NewColumn("tags", .list(.varchar)).notNull()Теперь с ним работают обычные model APIs:
let user = User(
id: 1,
tags: ["swift", "duckdb"]
)
let inserted = try await user.insert(on: .app)
let loaded = try await User
.query
.where(\User.$id == inserted.id)
.first(on: .app)В первой версии поддерживаются одномерные LIST и один непосредственный уровень keyed STRUCT. Более сложные рекурсивные комбинации пока оставлены на будущее.
Массовая загрузка данных
DuckDB умеет эффективно загружать большие наборы строк через Appender, и мне хотелось сделать эту возможность доступной как на низком уровне, так и через обычные Swift-модели.
Для коллекции моделей всё довольно просто:
let todos = [
Todo(name: "One"),
Todo(name: "Two"),
Todo(name: "Three")
]
try await todos.batchInsert(on: .app)Но можно работать и непосредственно с Appender:
let todoTable = DB.Table(Todo.tableName)
let connection = try await DatabaseIdentifier.app.connect()
try await connection.appender(
to: todoTable,
columns: ["id", "name", "created_at"]
) { appender in
for name in importedTodoNames {
try await appender.append(
UUID(),
name,
Date()
)
}
}Для атомарной загрузки всех строк можно использовать transaction.appender, тогда финальный COMMIT будет принадлежать внешней транзакции.
Точно так же доступны reusable prepared statements, позволяющие подготовить SQL один раз и многократно выполнять его с разными параметрами.
Реактивные SQL-запросы для SwiftUI и UIKit
Еще одна возможность, которую мне очень хотелось иметь на клиенте, это наблюдение непосредственно за результатом SQL-запроса.
Например, в SwiftUI у нас есть обычный список Todo, и мы хотим, чтобы он автоматически обновлялся при изменениях в базе:
@State private var todos: [Todo] = []С Duck для этого достаточно создать observation прямо из запроса:
.task {
do {
for try await result in try Todo
.select
.where(Todo.$deletedAt == nil)
.orderBy(.asc(Todo.$createdAt))
.observe(on: .app)
{
todos = try result.all(decoding: Todo.self)
}
} catch is CancellationError {
// SwiftUI отменил task
} catch {
print("Duck error:", error)
}
}Всё это работает через обычный AsyncSequence<QueryResult>, поэтому никакой зависимости от SwiftUI внутри самого Duck нет. Такой же sequence можно использовать из UIKit, консольного приложения или любого другого Swift-кода.
При отмене Task отмена передается внутрь observation, его worker завершает работу и освобождает подписку.
При этом я специально не делал из observation поток всех событий базы данных. Его задача состоит в том, чтобы предоставлять последнее актуальное состояние результата запроса, поэтому последовательные invalidations могут объединяться, а одинаковые QueryResult вообще не публикуются повторно.
Есть и важная граница: observation реагирует на изменения, которые выполняются через тот же логический DatabaseIdentifier внутри процесса. Это не файловый мониторинг и не механизм синхронизации с другими процессами.
Эффективность работы observation
После релиза мне задали хороший вопрос: насколько точно Duck определяет, что изменение данных действительно влияет на наблюдаемый запрос?
Сейчас в Duck 1.0 invalidation работает довольно консервативно, поэтому даже изменение совершенно другой таблицы может привести к повторному выполнению запроса. Одинаковые результаты Duck повторно не отправляет, но лишнее выполнение SQL всё равно происходит.
Поскольку я сам планирую использовать Duck в приложении с миллионами строк и огромным количеством операций записи, для версии 1.1 я уже запланировал более точное отслеживание изменений, включая predicate-aware оптимизации, а также полноценные бенчмарки на специально сгенерированных миллионных таблицах под высокой нагрузкой.
Concurrency и Connection Pooling
При работе с несколькими параллельными задачами можно использовать connection pool:
let pool = try await DatabaseIdentifier.app
.pool(maximumConnections: 4)И выполнять запросы на полученном соединении:
let todos = try await pool.connection { connection in
try await Todo
.query
.where(\Todo.$deletedAt == nil)
.all(on: connection)
}Connection'ы создаются лениво до указанного лимита, а каждый отдельный Connection является actor'ом и владеет собственным native DuckDB connection.
Таким образом работа внутри одного connection сериализуется, но независимые соединения не обязаны проходить через одну глобальную очередь выполнения.
Транзакции также можно выполнять непосредственно через pool:
try await pool.transaction { transaction in
let todo = Todo(name: "Inserted through pool")
try await todo.insertNonReturning(on: transaction)
}Мне было важно, чтобы Swift Concurrency здесь не просто присутствовал ради модного API, а действительно корректно отражал ownership, lifetime и ограничения нативного database engine.
Заменит ли Duck SQLite?
Думаю, что нет, и такой задачи я перед собой не ставлю.
SQLite является отличным embedded-движком, а GRDB предоставляет замечательный Swift API для работы с ним. Если приложение занимается преимущественно обычным CRUD и прекрасно решает свои задачи на SQLite, я не вижу причин срочно отказываться от этого стека.
Duck интересен мне прежде всего тогда, когда хочется использовать внутри приложения более мощный SQL engine с возможностями, к которым я привык в серверной разработке. При этом сам DuckDB ориентирован на аналитические запросы и достаточно крупные операции с данными, поэтому для приложения с большим количеством мелких транзакций и постоянными точечными обновлениями производительность обязательно нужно проверять на реальной нагрузке, а не автоматически переносить ожидания от PostgreSQL или SQLite.
Есть и вопрос размера приложения. Я специально собрал два минимальных SwiftUI проекта: пустой получился около 2 MB, а аналогичный проект с подключенным Duck около 42 MB.
Речь идет о размерах локальных сборок, реальный размер загрузки из App Store с учетом сжатия и thinning я отдельно пока не измерял.
Лично меня такие цифры совершенно не пугают, особенно когда современные приложения вполне спокойно могут занимать по 300–700 MB. Но если размер критичен и вам достаточно возможностей SQLite, дополнительный вес DuckDB может быть совершенно неоправданным.
Как попробовать
Duck распространяется через Swift Package Manager, поэтому достаточно добавить зависимость:
.package(
url: "https://github.com/SwiftStream/Duck",
from: "1.0.0"
)И импортировать библиотеку:
import DuckSwiftStream/SQL уже реэкспортируется самим Duck, поэтому дополнительно импортировать SQL в обычном приложении не требуется.
Сейчас поддерживается iOS15+ и macOS12+. Сам по себе движок работает и на множестве других платформ, но я их пока не проверял, хотя по-моему всё должно без проблем скомпилироваться и работать.
На Apple платформах используется готовый XCFramework DuckDB, поэтому при обычной сборке приложения не нужно каждый раз компилировать весь C++ движок, а это экономит 2-3 минуты на холодной сборке. Сейчас на холодную компиляция SwiftUI аппки занимает всего 10 секунд.
В репозитории также есть готовые SwiftUI и UIKit примеры, где можно посмотреть работу моделей, миграций, запросов и реактивного обновления интерфейса.
Заключение
Duck пока только в начале своего развития, хотя уже в первой стабильной версии получилось реализовать довольно много того, что мне хотелось получить от современной базы данных на Swift.
Сам DuckDB предоставляет огромное количество интересных возможностей, и помимо производительности reactive observation в планах как минимум остаются более сложные native types, проверка работы на других платформах и другие вещи, до которых со временем доберусь.
Но больше всего я доволен именно тем, что удалось перенести свой привычный подход к моделям и SQL из серверной разработки на клиент, практически не меняя способ написания кода.
В одном месте приложения можно спокойно написать:
let todos = try await Todo.all(on: .app)а в другом использовать тот же SwiftStream/SQL для сложных запросов с несколькими таблицами, условиями и агрегациями, оставаясь в пределах одного и того же API.
В общем, надеюсь, что Duck будет полезен и другим Swift-разработчикам, которые любят SQL, хотят попробовать возможности DuckDB внутри своих приложений или просто ищут более интересный способ работы с локальными данными.
Буду рад вашим отзывам и идеям. Особенно интересно услышать тех, кто уже использует DuckDB или работает с большими локальными базами на iOS и macOS.
Это проект с открытым исходным кодом и распространяется под лицензией MIT.
Ознакомиться с исходниками и поставить звезду можно в репозитории SwiftStream/Duck
Поддержите проект поделившись ссылкой на эту публикацию с друзьями и коллегами
Будем очень рады вам в нашем Discord и Телеграм
Михаил Исаев
Основатель Swift Stream

Комментарии 0