SwiftDBAI: natural language queries for any SQLite database

Drop-in SwiftUI chat view, headless ChatEngine, LLM-agnostic via
AnyLanguageModel. Read-only by default with configurable allowlists.
Robust SQL parser with 63 tests. Includes demo app with GitHub stars dataset.
This commit is contained in:
Krishna Kumar
2026-04-04 09:30:56 -05:00
commit fcd752466a
80 changed files with 18265 additions and 0 deletions

View File

@@ -0,0 +1,38 @@
// DatabaseSeeder.swift
// SwiftDBAIDemo
//
// Copies the bundled GitHub stars database to the Documents directory.
// The database contains real star counts for ~2000 top GitHub repos,
// fetched live from the GitHub API.
import Foundation
enum DatabaseSeeder {
/// Returns the path to the GitHub stars database, copying from bundle if needed.
static func seedIfNeeded() throws -> String {
let url = URL.documentsDirectory.appending(path: "github_stars.sqlite")
let path = url.path(percentEncoded: false)
// If the database already exists, just return the path.
if FileManager.default.fileExists(atPath: path) {
return path
}
// Copy bundled database to Documents
guard let bundledURL = Bundle.main.url(forResource: "github_stars", withExtension: "sqlite") else {
throw SeederError.bundledDatabaseNotFound
}
try FileManager.default.copyItem(at: bundledURL, to: url)
return path
}
enum SeederError: LocalizedError {
case bundledDatabaseNotFound
var errorDescription: String? {
"Could not find github_stars.sqlite in app bundle."
}
}
}

View File

@@ -0,0 +1,253 @@
// DemoLanguageModel.swift
// SwiftDBAIDemo
//
// A mock LanguageModel that returns canned SQL for common GitHub repo queries.
// Pattern-matches natural language questions about GitHub stars, languages,
// and repository metadata.
import AnyLanguageModel
import Foundation
struct DemoLanguageModel: LanguageModel {
typealias UnavailableReason = Never
func respond<Content>(
within session: LanguageModelSession,
to prompt: Prompt,
generating type: Content.Type,
includeSchemaInPrompt: Bool,
options: GenerationOptions
) async throws -> LanguageModelSession.Response<Content> where Content: Generable {
let promptText = prompt.description.lowercased()
let responseText: String
if promptText.contains("row") && (promptText.contains("column") || promptText.contains("|")) {
responseText = deriveSummary(from: prompt.description)
} else {
responseText = deriveSQL(from: promptText)
}
let rawContent = GeneratedContent(kind: .string(responseText))
let content = try Content(rawContent)
return LanguageModelSession.Response(
content: content,
rawContent: rawContent,
transcriptEntries: [][...]
)
}
func streamResponse<Content>(
within session: LanguageModelSession,
to prompt: Prompt,
generating type: Content.Type,
includeSchemaInPrompt: Bool,
options: GenerationOptions
) -> sending LanguageModelSession.ResponseStream<Content> where Content: Generable {
let rawContent = GeneratedContent(kind: .string("SELECT full_name, stars FROM repos ORDER BY stars DESC LIMIT 10"))
let content = try! Content(rawContent)
return LanguageModelSession.ResponseStream(content: content, rawContent: rawContent)
}
// MARK: - SQL Pattern Matching
private func deriveSQL(from prompt: String) -> String {
let q = extractLastQuestion(from: prompt)
// Specific repo lookups
if q.contains("react") && !q.contains("react-native") && !q.contains("react native") {
return "SELECT full_name, stars, forks, language, description FROM repos WHERE name = 'react' OR full_name LIKE '%/react' ORDER BY stars DESC LIMIT 5"
}
// How many stars does X have
if q.contains("how many stars") || q.contains("stars does") || q.contains("stars for") {
return "SELECT full_name, stars, forks, language FROM repos ORDER BY stars DESC LIMIT 10"
}
// Language breakdown MUST come before "most popular" to avoid collision
if q.contains("language") && (q.contains("breakdown") || q.contains("distribution") || q.contains("popular") || q.contains("most")) {
return """
SELECT language, COUNT(*) AS repo_count,
SUM(stars) AS total_stars,
ROUND(AVG(stars)) AS avg_stars
FROM repos WHERE language IS NOT NULL AND language != ''
GROUP BY language
ORDER BY total_stars DESC
LIMIT 15
"""
}
// Most starred / top repos
if q.contains("most starred") || q.contains("most popular") || q.contains("top repo") || q.contains("top 10") || q.contains("most stars") {
return """
SELECT full_name, stars, forks, language
FROM repos ORDER BY stars DESC LIMIT 10
"""
}
// Language-specific queries
if q.contains("python") && (q.contains("repo") || q.contains("project")) {
return """
SELECT full_name, stars, forks, description
FROM repos WHERE language = 'Python'
ORDER BY stars DESC LIMIT 10
"""
}
if q.contains("swift") && (q.contains("repo") || q.contains("project")) {
return """
SELECT full_name, stars, forks, description
FROM repos WHERE language = 'Swift'
ORDER BY stars DESC LIMIT 10
"""
}
if q.contains("rust") && (q.contains("repo") || q.contains("project")) {
return """
SELECT full_name, stars, forks, description
FROM repos WHERE language = 'Rust'
ORDER BY stars DESC LIMIT 10
"""
}
if q.contains("typescript") && (q.contains("repo") || q.contains("project")) {
return """
SELECT full_name, stars, forks, description
FROM repos WHERE language = 'TypeScript'
ORDER BY stars DESC LIMIT 10
"""
}
// Count queries
if q.contains("how many repo") || q.contains("how many project") || q.contains("total repo") {
return "SELECT COUNT(*) AS total_repos FROM repos"
}
if q.contains("how many language") {
return "SELECT COUNT(DISTINCT language) AS total_languages FROM repos WHERE language IS NOT NULL AND language != ''"
}
// Stars threshold queries
if q.contains("100k") || q.contains("100,000") || q.contains("100000") {
return """
SELECT full_name, stars, language
FROM repos WHERE stars > 100000
ORDER BY stars DESC
"""
}
// Forks
if q.contains("most forked") || q.contains("most forks") {
return """
SELECT full_name, forks, stars, language
FROM repos ORDER BY forks DESC LIMIT 10
"""
}
// Created / oldest / newest
if q.contains("oldest") || q.contains("first") {
return """
SELECT full_name, created_at, stars, language
FROM repos ORDER BY created_at ASC LIMIT 10
"""
}
if q.contains("newest") || q.contains("recent") || q.contains("latest") {
return """
SELECT full_name, created_at, stars, language
FROM repos ORDER BY created_at DESC LIMIT 10
"""
}
// Microsoft / Google / Meta specific
if q.contains("microsoft") {
return """
SELECT full_name, stars, forks, language
FROM repos WHERE owner = 'microsoft'
ORDER BY stars DESC
"""
}
if q.contains("google") {
return """
SELECT full_name, stars, forks, language
FROM repos WHERE owner = 'google'
ORDER BY stars DESC
"""
}
if q.contains("facebook") || q.contains("meta") {
return """
SELECT full_name, stars, forks, language
FROM repos WHERE owner = 'facebook'
ORDER BY stars DESC
"""
}
// Compare
if q.contains("vs") || q.contains("versus") || q.contains("compare") {
return """
SELECT full_name, stars, forks, language
FROM repos ORDER BY stars DESC LIMIT 20
"""
}
// Default
return """
SELECT full_name, stars, language
FROM repos ORDER BY stars DESC LIMIT 10
"""
}
private func extractLastQuestion(from prompt: String) -> String {
let lines = prompt.components(separatedBy: "\n")
// First pass: find lines ending with "?" (most likely user questions)
// Take the LAST one (most recent question)
var lastQuestion: String?
for line in lines {
let trimmed = line.trimmingCharacters(in: .whitespaces)
if trimmed.hasSuffix("?") && trimmed.count < 200 && trimmed.count > 5 {
lastQuestion = trimmed.lowercased()
}
}
if let q = lastQuestion { return q }
// Fallback: walk backwards looking for short non-SQL lines
for line in lines.reversed() {
let trimmed = line.trimmingCharacters(in: .whitespaces)
guard !trimmed.isEmpty, trimmed.count > 3, trimmed.count < 100 else { continue }
let lower = trimmed.lowercased()
if lower.hasPrefix("select ") || lower.hasPrefix("create ") { continue }
if lower.contains("integer") || lower.contains("text not") { continue }
if lower.contains("respond with only") { continue }
return lower
}
return prompt.lowercased()
}
// MARK: - Summary Generation
private func deriveSummary(from rawPrompt: String) -> String {
let lines = rawPrompt.components(separatedBy: "\n")
let dataLines = lines.filter { $0.contains("|") || $0.contains(",") }
let rowCount = max(dataLines.count - 1, 0)
let lower = rawPrompt.lowercased()
if lower.contains("total_repos") || lower.contains("total_languages") || lower.contains("count(") {
if let countLine = dataLines.last {
let num = countLine.trimmingCharacters(in: .whitespacesAndNewlines)
.components(separatedBy: "|").last?
.trimmingCharacters(in: .whitespacesAndNewlines) ?? "\(rowCount)"
return "The count is \(num)."
}
}
if lower.contains("avg_stars") || lower.contains("group by") || lower.contains("total_stars") {
return "Here's the breakdown across programming languages."
}
if lower.contains("forks") && lower.contains("order by forks") {
return "These are the most forked repositories on GitHub."
}
if rowCount == 0 {
return "No repositories matched your query."
}
if rowCount == 1 {
return "Here's what I found."
}
if rowCount <= 5 {
return "Found \(rowCount) repositories."
}
return "Here are the top \(rowCount) repositories."
}
}

View File

@@ -0,0 +1,70 @@
// OllamaWithSystemPrompt.swift
// SwiftDBAIDemo
//
// Wraps OllamaLanguageModel to prepend session instructions into the user
// prompt, working around AnyLanguageModel's Ollama adapter not forwarding
// system messages.
import AnyLanguageModel
import Foundation
/// Wrapper that injects session instructions into every Ollama request.
struct OllamaWithSystemPrompt: LanguageModel {
typealias UnavailableReason = Never
private let inner: OllamaLanguageModel
init(baseURL: URL = OllamaLanguageModel.defaultBaseURL, model: String) {
self.inner = OllamaLanguageModel(baseURL: baseURL, model: model)
}
func respond<Content>(
within session: LanguageModelSession,
to prompt: Prompt,
generating type: Content.Type,
includeSchemaInPrompt: Bool,
options: GenerationOptions
) async throws -> LanguageModelSession.Response<Content> where Content: Generable {
let userText = prompt.description
let instructionText = session.instructions?.description ?? ""
let combinedText: String
if instructionText.isEmpty {
combinedText = userText
} else {
combinedText = """
[System Instructions]
\(instructionText)
[User Message]
\(userText)
"""
}
let plainSession = LanguageModelSession(model: inner)
let combinedPrompt = Prompt(combinedText)
return try await inner.respond(
within: plainSession,
to: combinedPrompt,
generating: type,
includeSchemaInPrompt: includeSchemaInPrompt,
options: options
)
}
func streamResponse<Content>(
within session: LanguageModelSession,
to prompt: Prompt,
generating type: Content.Type,
includeSchemaInPrompt: Bool,
options: GenerationOptions
) -> sending LanguageModelSession.ResponseStream<Content> where Content: Generable {
inner.streamResponse(
within: session,
to: prompt,
generating: type,
includeSchemaInPrompt: includeSchemaInPrompt,
options: options
)
}
}

View File

@@ -0,0 +1,261 @@
// SwiftDBAIDemoApp.swift
// SwiftDBAIDemo
//
// Showcase app demonstrating all SwiftDBAI UI variants and presentation modes.
import SwiftUI
import SwiftDBAI
@main
struct SwiftDBAIDemoApp: App {
@State private var databasePath: String?
@State private var setupError: String?
private let context = """
This is a database of the top ~2000 most-starred GitHub \
repositories. Each repo has: full_name (owner/name), stars, \
forks, language (programming language), description, \
open_issues, created_at date, and topics. \
Star counts are real and current as of April 2026.
"""
var body: some Scene {
WindowGroup {
Group {
if let path = databasePath {
ShowcaseTabView(databasePath: path, context: context)
} else if let error = setupError {
ContentUnavailableView(
"Database Setup Failed",
systemImage: "exclamationmark.triangle",
description: Text(error)
)
} else {
ProgressView("Setting up database...")
}
}
.task {
do {
let path = try DatabaseSeeder.seedIfNeeded()
databasePath = path
} catch {
setupError = error.localizedDescription
}
}
}
}
}
struct ShowcaseTabView: View {
let databasePath: String
let context: String
@State private var showSheet = false
@State private var showFullScreen = false
var body: some View {
TabView {
// Tab 1: Default theme
DataChatView(
databasePath: databasePath,
model: DemoLanguageModel(),
allowlist: .readOnly,
additionalContext: context
)
.tabItem { Label("Default", systemImage: "bubble.left.and.text.bubble.right") }
// Tab 2: Dark theme
DataChatView(
databasePath: databasePath,
model: DemoLanguageModel(),
allowlist: .readOnly,
additionalContext: context
)
.chatViewConfiguration(.dark)
.tabItem { Label("Dark", systemImage: "moon.fill") }
// Tab 3: Compact theme
DataChatView(
databasePath: databasePath,
model: DemoLanguageModel(),
allowlist: .readOnly,
additionalContext: context
)
.chatViewConfiguration(.compact)
.tabItem { Label("Compact", systemImage: "rectangle.compress.vertical") }
// Tab 4: Custom styling
DataChatView(
databasePath: databasePath,
model: DemoLanguageModel(),
allowlist: .readOnly,
additionalContext: context
)
.chatViewConfiguration(customConfig)
.tabItem { Label("Custom", systemImage: "paintbrush") }
// Tab 5: Presentation modes
PresentationShowcase(databasePath: databasePath, context: context)
.tabItem { Label("Present", systemImage: "rectangle.portrait.and.arrow.forward") }
// Tab 6: Tool calling API
ToolDemoView(databasePath: databasePath)
.tabItem { Label("Tool", systemImage: "wrench") }
}
}
private var customConfig: ChatViewConfiguration {
var config = ChatViewConfiguration.default
config.userBubbleColor = .purple
config.userTextColor = .white
config.accentColor = .purple
config.inputPlaceholder = "Search GitHub repos..."
config.emptyStateTitle = "Explore GitHub Data"
config.emptyStateSubtitle = "Ask about stars, forks, languages, and trends"
config.emptyStateIcon = "star.circle"
config.assistantAvatarIcon = "sparkles"
config.assistantAvatarColor = .purple
return config
}
}
struct PresentationShowcase: View {
let databasePath: String
let context: String
@State private var showSheet = false
@State private var showFullScreen = false
var body: some View {
NavigationStack {
List {
Section("Sheet Presentations") {
Button("Show as Sheet") {
showSheet = true
}
Button("Show Full Screen") {
showFullScreen = true
}
}
Section("Navigation") {
NavigationLink("Push DataChatView") {
DataChatView(
databasePath: databasePath,
model: DemoLanguageModel(),
allowlist: .readOnly,
additionalContext: context
)
.navigationTitle("Chat")
}
}
Section("Info") {
LabeledContent("DataChatSheet", value: "Nav + Done button")
LabeledContent("DataChatViewController", value: "UIKit bridge")
LabeledContent(".dataChatSheet()", value: "View modifier")
LabeledContent(".dataChatFullScreen()", value: "View modifier")
}
}
.navigationTitle("Presentation Modes")
}
.sheet(isPresented: $showSheet) {
DataChatSheet(
databasePath: databasePath,
model: DemoLanguageModel(),
additionalContext: context,
title: "GitHub Stars"
)
}
.dataChatFullScreen(
isPresented: $showFullScreen,
databasePath: databasePath,
model: DemoLanguageModel(),
additionalContext: context
)
}
}
struct ToolDemoView: View {
let databasePath: String
@State private var tool: DatabaseTool?
@State private var sqlInput = "SELECT full_name, stars FROM repos ORDER BY stars DESC LIMIT 5"
@State private var result: ToolResult?
@State private var error: String?
@State private var showSchema = false
var body: some View {
NavigationStack {
List {
if let tool {
Section("Schema") {
Button(showSchema ? "Hide Schema" : "Show Schema") {
showSchema.toggle()
}
if showSchema {
Text(tool.schemaContext)
.font(.caption2.monospaced())
}
}
Section("SQL Query") {
TextField("Enter SQL", text: $sqlInput, axis: .vertical)
.font(.footnote.monospaced())
.lineLimit(3...6)
Button("Execute") {
do {
result = try tool.execute(sql: sqlInput)
error = nil
} catch {
self.error = error.localizedDescription
result = nil
}
}
.disabled(sqlInput.isEmpty)
}
if let error {
Section("Error") {
Text(error)
.foregroundStyle(.red)
.font(.footnote)
}
}
if let result {
Section("Result (\(result.rowCount) rows, \(String(format: "%.1fms", result.executionTime * 1000)))") {
Text(result.markdownTable)
.font(.caption2.monospaced())
}
Section("JSON Response") {
Text(result.jsonString)
.font(.caption2.monospaced())
.lineLimit(15)
}
}
Section("OpenAI Tool Definition") {
Text(toolDefinitionJSON(tool))
.font(.caption2.monospaced())
.lineLimit(10)
}
} else {
ProgressView("Loading database...")
}
}
.navigationTitle("DatabaseTool API")
}
.task {
do {
tool = try await DatabaseTool(databasePath: databasePath)
} catch {
self.error = error.localizedDescription
}
}
}
private func toolDefinitionJSON(_ tool: DatabaseTool) -> String {
let def = tool.openAIFunctionDefinition
if let data = try? JSONSerialization.data(withJSONObject: def, options: [.prettyPrinted, .sortedKeys]),
let str = String(data: data, encoding: .utf8) {
return str
}
return "{}"
}
}

View File

@@ -0,0 +1,26 @@
name: SwiftDBAIDemo
options:
bundleIdPrefix: com.swiftdbai.demo
deploymentTarget:
iOS: "17.0"
xcodeVersion: "16.0"
createIntermediateGroups: true
packages:
SwiftDBAI:
path: ../..
targets:
SwiftDBAIDemo:
type: application
platform: iOS
sources:
- SwiftDBAIDemo
settings:
base:
PRODUCT_BUNDLE_IDENTIFIER: com.swiftdbai.demo
MARKETING_VERSION: "1.0"
CURRENT_PROJECT_VERSION: "1"
SWIFT_VERSION: "6.0"
INFOPLIST_GENERATION: true
GENERATE_INFOPLIST_FILE: true
dependencies:
- package: SwiftDBAI