Duck: DuckDB для Swift. Мощный SQL прямо в iOS и macOS приложениях

—

от автора

Привет, Хабр!

Уже много лет я занимаюсь 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 Duckimport Foundationstruct 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 = insertedupdated.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 = falselet 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 Duck

SwiftStream/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

ссылка на оригинал статьи https://habr.com/ru/articles/1107762/