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,301 @@
// TextSummaryRendererTests.swift
// SwiftDBAI
import AnyLanguageModel
import Testing
import Foundation
@testable import SwiftDBAI
@Suite("TextSummaryRenderer")
struct TextSummaryRendererTests {
// MARK: - QueryResult.Value Tests
@Test("Value description renders correctly")
func valueDescriptions() {
#expect(QueryResult.Value.text("hello").description == "hello")
#expect(QueryResult.Value.integer(42).description == "42")
#expect(QueryResult.Value.real(3.14).description == "3.14")
#expect(QueryResult.Value.null.description == "NULL")
#expect(QueryResult.Value.blob(Data([0x01, 0x02])).description == "<2 bytes>")
}
@Test("Value doubleValue extracts numeric values")
func valueDoubleValues() {
#expect(QueryResult.Value.integer(42).doubleValue == 42.0)
#expect(QueryResult.Value.real(3.14).doubleValue == 3.14)
#expect(QueryResult.Value.text("100").doubleValue == 100.0)
#expect(QueryResult.Value.text("not a number").doubleValue == nil)
#expect(QueryResult.Value.null.doubleValue == nil)
#expect(QueryResult.Value.blob(Data()).doubleValue == nil)
}
@Test("Value isNull works correctly")
func valueIsNull() {
#expect(QueryResult.Value.null.isNull == true)
#expect(QueryResult.Value.text("").isNull == false)
#expect(QueryResult.Value.integer(0).isNull == false)
}
// MARK: - QueryResult Tests
@Test("Empty result has correct properties")
func emptyResult() {
let result = QueryResult(
columns: ["id", "name"],
rows: [],
sql: "SELECT id, name FROM users",
executionTime: 0.01
)
#expect(result.rowCount == 0)
#expect(result.isAggregate == false)
#expect(result.tabularDescription == "(empty result set)")
}
@Test("Single aggregate result is detected")
func aggregateDetection() {
let result = QueryResult(
columns: ["COUNT(*)"],
rows: [["COUNT(*)": .integer(42)]],
sql: "SELECT COUNT(*) FROM users",
executionTime: 0.01
)
#expect(result.isAggregate == true)
}
@Test("Multi-row result is not aggregate")
func nonAggregateDetection() {
let result = QueryResult(
columns: ["name"],
rows: [
["name": .text("Alice")],
["name": .text("Bob")],
],
sql: "SELECT name FROM users",
executionTime: 0.01
)
#expect(result.isAggregate == false)
}
@Test("Tabular description formats correctly")
func tabularDescription() {
let result = QueryResult(
columns: ["id", "name"],
rows: [
["id": .integer(1), "name": .text("Alice")],
["id": .integer(2), "name": .text("Bob")],
],
sql: "SELECT id, name FROM users",
executionTime: 0.01
)
let desc = result.tabularDescription
#expect(desc.contains("id | name"))
#expect(desc.contains("1 | Alice"))
#expect(desc.contains("2 | Bob"))
}
@Test("values(forColumn:) extracts column values")
func valuesForColumn() {
let result = QueryResult(
columns: ["name"],
rows: [
["name": .text("Alice")],
["name": .text("Bob")],
],
sql: "SELECT name FROM users",
executionTime: 0.01
)
let values = result.values(forColumn: "name")
#expect(values.count == 2)
#expect(values[0] == .text("Alice"))
}
// MARK: - Local Summary Tests (no LLM required)
@Test("Local summary for empty result")
func localSummaryEmpty() {
let result = makeResult(columns: ["id"], rows: [])
let renderer = makeMockRenderer()
let summary = renderer.localSummary(result: result, userQuestion: "Any users?")
#expect(summary == "No results found for your query.")
}
@Test("Local summary for single aggregate")
func localSummarySingleAggregate() {
let result = makeResult(
columns: ["COUNT(*)"],
rows: [["COUNT(*)": .integer(42)]]
)
let renderer = makeMockRenderer()
let summary = renderer.localSummary(result: result, userQuestion: "How many?")
#expect(summary.contains("42"))
}
@Test("Local summary for multiple aggregates")
func localSummaryMultipleAggregates() {
let result = makeResult(
columns: ["COUNT(*)", "AVG(price)"],
rows: [["COUNT(*)": .integer(10), "AVG(price)": .real(25.5)]]
)
let renderer = makeMockRenderer()
let summary = renderer.localSummary(result: result, userQuestion: "Stats?")
#expect(summary.contains("count"))
#expect(summary.contains("average price"))
}
@Test("Local summary for single record")
func localSummarySingleRecord() {
let result = makeResult(
columns: ["name", "email"],
rows: [["name": .text("Alice"), "email": .text("alice@example.com")]]
)
let renderer = makeMockRenderer()
let summary = renderer.localSummary(result: result, userQuestion: "Who?")
#expect(summary.contains("1 result"))
#expect(summary.contains("Alice"))
}
@Test("Local summary for multiple records with name column")
func localSummaryMultipleWithNames() {
let result = makeResult(
columns: ["name", "age"],
rows: [
["name": .text("Alice"), "age": .integer(30)],
["name": .text("Bob"), "age": .integer(25)],
["name": .text("Charlie"), "age": .integer(35)],
["name": .text("Diana"), "age": .integer(28)],
]
)
let renderer = makeMockRenderer()
let summary = renderer.localSummary(result: result, userQuestion: "List users")
#expect(summary.contains("4 results"))
#expect(summary.contains("Alice"))
#expect(summary.contains("1 more"))
}
@Test("Local summary for mutation result")
func localSummaryMutation() {
let result = QueryResult(
columns: [],
rows: [],
sql: "INSERT INTO users (name) VALUES ('Test')",
executionTime: 0.01,
rowsAffected: 1
)
let renderer = makeMockRenderer()
let summary = renderer.localSummary(result: result, userQuestion: "Add user")
#expect(summary == "Successfully inserted 1 row.")
}
@Test("Local summary for delete mutation")
func localSummaryDelete() {
let result = QueryResult(
columns: [],
rows: [],
sql: "DELETE FROM users WHERE id = 5",
executionTime: 0.01,
rowsAffected: 3
)
let renderer = makeMockRenderer()
let summary = renderer.localSummary(result: result, userQuestion: "Delete old users")
#expect(summary == "Successfully deleted 3 rows.")
}
@Test("Local summary for update mutation")
func localSummaryUpdate() {
let result = QueryResult(
columns: [],
rows: [],
sql: "UPDATE users SET active = 0 WHERE id = 1",
executionTime: 0.01,
rowsAffected: 1
)
let renderer = makeMockRenderer()
let summary = renderer.localSummary(result: result, userQuestion: "Deactivate user")
#expect(summary == "Successfully updated 1 row.")
}
// MARK: - LLM-based Summary Tests (using MockLanguageModel)
@Test("Summarize with LLM returns mock response for multi-row results")
func summarizeWithLLM() async throws {
let result = makeResult(
columns: ["name", "age"],
rows: [
["name": .text("Alice"), "age": .integer(30)],
["name": .text("Bob"), "age": .integer(25)],
]
)
let mockModel = MockLanguageModel(responseText: "There are 2 users: Alice (30) and Bob (25).")
let renderer = TextSummaryRenderer(model: mockModel)
let summary = try await renderer.summarize(result: result, userQuestion: "List all users")
#expect(summary == "There are 2 users: Alice (30) and Bob (25).")
}
@Test("Summarize returns empty result message without calling LLM")
func summarizeEmptyResult() async throws {
let result = makeResult(columns: ["id"], rows: [])
let renderer = makeMockRenderer()
let summary = try await renderer.summarize(result: result, userQuestion: "Find users")
#expect(summary == "No results found for your query.")
}
@Test("Summarize returns direct aggregate without calling LLM")
func summarizeAggregate() async throws {
let result = makeResult(
columns: ["COUNT(*)"],
rows: [["COUNT(*)": .integer(42)]]
)
let renderer = makeMockRenderer()
let summary = try await renderer.summarize(result: result, userQuestion: "How many?")
#expect(summary.contains("42"))
}
@Test("Summarize mutation returns template without calling LLM")
func summarizeMutation() async throws {
let result = QueryResult(
columns: [],
rows: [],
sql: "UPDATE users SET name = 'Test' WHERE id = 1",
executionTime: 0.01,
rowsAffected: 1
)
let renderer = makeMockRenderer()
let summary = try await renderer.summarize(result: result, userQuestion: "Update user")
#expect(summary == "Successfully updated 1 row.")
}
@Test("Summarize passes context to LLM prompt")
func summarizeWithContext() async throws {
let result = makeResult(
columns: ["total"],
rows: [
["total": .real(100.0)],
["total": .real(200.0)],
]
)
let mockModel = MockLanguageModel(responseText: "The totals are 100 and 200.")
let renderer = TextSummaryRenderer(model: mockModel)
let summary = try await renderer.summarize(
result: result,
userQuestion: "Show totals",
context: "Amounts are in USD"
)
#expect(summary == "The totals are 100 and 200.")
}
// MARK: - Helpers
private func makeResult(
columns: [String],
rows: [[String: QueryResult.Value]],
sql: String = "SELECT * FROM test"
) -> QueryResult {
QueryResult(columns: columns, rows: rows, sql: sql, executionTime: 0.01)
}
/// Creates a renderer with a mock model (for localSummary tests that don't hit the LLM).
private func makeMockRenderer() -> TextSummaryRenderer {
TextSummaryRenderer(model: MockLanguageModel())
}
}