Initial implementation of SwiftDBAI

Chat with any SQLite database using natural language. Built on
AnyLanguageModel (HuggingFace) for LLM-agnostic provider support
and GRDB for SQLite access.

Core features:
- Auto schema introspection from sqlite_master (zero config)
- NL → SQL generation via any AnyLanguageModel provider
- Three rendering modes: text summary, data table, Swift Charts
- Drop-in DataChatView (SwiftUI) and headless ChatEngine
- Operation allowlist with read-only default
- Mutation policy with per-table control
- ToolExecutionDelegate for destructive operation confirmation
- Multi-turn conversation context
- 352 tests across 24 suites, all passing

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Krishna Kumar
2026-04-04 09:30:56 -05:00
commit b1724fe7ca
55 changed files with 15506 additions and 0 deletions

View File

@@ -0,0 +1,164 @@
// ChatViewTests.swift
// SwiftDBAITests
//
// Tests for ChatView, ChatViewModel, and MessageBubbleView integration
// with ScrollableDataTableView.
import Testing
import Foundation
@testable import SwiftDBAI
@Suite("SchemaReadiness Tests")
struct SchemaReadinessTests {
@Test("SchemaReadiness isReady returns true only for ready state")
func isReadyProperty() {
#expect(SchemaReadiness.idle.isReady == false)
#expect(SchemaReadiness.loading.isReady == false)
#expect(SchemaReadiness.ready(tableCount: 3).isReady == true)
#expect(SchemaReadiness.failed("error").isReady == false)
}
}
@Suite("ChatViewModel Tests")
struct ChatViewModelTests {
@Test("Messages with query results produce DataTable-compatible data")
func messageWithQueryResultHasTableData() {
// A ChatMessage with a queryResult should have the data needed
// for ScrollableDataTableView rendering
let result = QueryResult(
columns: ["id", "name", "score"],
rows: [
["id": .integer(1), "name": .text("Alice"), "score": .real(95.5)],
["id": .integer(2), "name": .text("Bob"), "score": .real(87.3)],
],
sql: "SELECT id, name, score FROM users",
executionTime: 0.01
)
let message = ChatMessage(
role: .assistant,
content: "Found 2 users.",
queryResult: result,
sql: "SELECT id, name, score FROM users"
)
// Verify queryResult is present and can be converted to DataTable
#expect(message.queryResult != nil)
#expect(message.queryResult!.columns.count == 3)
#expect(message.queryResult!.rows.count == 2)
// Verify DataTable conversion works (this is what MessageBubbleView does)
let dataTable = DataTable(message.queryResult!)
#expect(dataTable.columnCount == 3)
#expect(dataTable.rowCount == 2)
#expect(dataTable.columns[0].name == "id")
#expect(dataTable.columns[1].name == "name")
#expect(dataTable.columns[2].name == "score")
}
@Test("Messages without query results do not trigger table rendering")
func messageWithoutQueryResult() {
let message = ChatMessage(
role: .assistant,
content: "Hello! How can I help?",
queryResult: nil,
sql: nil
)
#expect(message.queryResult == nil)
}
@Test("Empty query results do not trigger table rendering")
func emptyQueryResult() {
let result = QueryResult(
columns: [],
rows: [],
sql: "SELECT * FROM empty_table",
executionTime: 0.001
)
let message = ChatMessage(
role: .assistant,
content: "No results found.",
queryResult: result,
sql: "SELECT * FROM empty_table"
)
// Even though queryResult exists, it has no columns/rows
// MessageBubbleView checks both conditions before showing the table
#expect(message.queryResult != nil)
#expect(message.queryResult!.columns.isEmpty)
#expect(message.queryResult!.rows.isEmpty)
}
@Test("Mutation results do not trigger table rendering")
func mutationQueryResult() {
let result = QueryResult(
columns: [],
rows: [],
sql: "INSERT INTO users (name) VALUES ('Charlie')",
executionTime: 0.005,
rowsAffected: 1
)
let message = ChatMessage(
role: .assistant,
content: "Successfully inserted 1 row.",
queryResult: result,
sql: "INSERT INTO users (name) VALUES ('Charlie')"
)
// Mutation results have empty columns no table shown
#expect(message.queryResult!.columns.isEmpty)
}
@Test("Error messages never have query results")
func errorMessageHasNoQueryResult() {
let message = ChatMessage(
role: .error,
content: "SELECT operations are not allowed."
)
#expect(message.queryResult == nil)
#expect(message.role == .error)
}
@Test("DataTable preserves column order from QueryResult")
func dataTableColumnOrder() {
let result = QueryResult(
columns: ["date", "revenue", "category"],
rows: [
["date": .text("2024-01-01"), "revenue": .real(1500.0), "category": .text("Electronics")],
],
sql: "SELECT date, revenue, category FROM sales",
executionTime: 0.02
)
let dataTable = DataTable(result)
#expect(dataTable.columnNames == ["date", "revenue", "category"])
}
@Test("Large result sets are renderable as DataTable")
func largeResultSet() {
var rows: [[String: QueryResult.Value]] = []
for i in 0..<500 {
rows.append([
"id": .integer(Int64(i)),
"value": .real(Double(i) * 1.5),
])
}
let result = QueryResult(
columns: ["id", "value"],
rows: rows,
sql: "SELECT id, value FROM big_table",
executionTime: 0.15
)
let dataTable = DataTable(result)
#expect(dataTable.rowCount == 500)
#expect(dataTable.columnCount == 2)
}
}