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:
164
Sources/SwiftDBAI/Schema/DatabaseSchema.swift
Normal file
164
Sources/SwiftDBAI/Schema/DatabaseSchema.swift
Normal file
@@ -0,0 +1,164 @@
|
||||
// DatabaseSchema.swift
|
||||
// SwiftDBAI
|
||||
//
|
||||
// Auto-introspected SQLite schema model types.
|
||||
|
||||
import Foundation
|
||||
|
||||
/// Complete schema representation of an SQLite database.
|
||||
public struct DatabaseSchema: Sendable, Equatable {
|
||||
/// All tables in the database, keyed by table name.
|
||||
public let tables: [String: TableSchema]
|
||||
|
||||
/// Ordered table names (preserves discovery order).
|
||||
public let tableNames: [String]
|
||||
|
||||
/// Returns a compact text description suitable for LLM system prompts.
|
||||
public var schemaDescription: String {
|
||||
var lines: [String] = []
|
||||
for name in tableNames {
|
||||
guard let table = tables[name] else { continue }
|
||||
lines.append(table.descriptionForLLM)
|
||||
}
|
||||
return lines.joined(separator: "\n\n")
|
||||
}
|
||||
|
||||
/// Returns a description suitable for LLM system prompts.
|
||||
/// Alias for `schemaDescription` for API compatibility.
|
||||
public func describeForLLM() -> String {
|
||||
schemaDescription
|
||||
}
|
||||
|
||||
public init(tables: [String: TableSchema], tableNames: [String]) {
|
||||
self.tables = tables
|
||||
self.tableNames = tableNames
|
||||
}
|
||||
}
|
||||
|
||||
/// Schema for a single SQLite table.
|
||||
public struct TableSchema: Sendable, Equatable {
|
||||
public let name: String
|
||||
public let columns: [ColumnSchema]
|
||||
public let primaryKey: [String]
|
||||
public let foreignKeys: [ForeignKeySchema]
|
||||
public let indexes: [IndexSchema]
|
||||
|
||||
/// Text description for embedding in LLM prompts.
|
||||
public var descriptionForLLM: String {
|
||||
var parts: [String] = []
|
||||
let colDefs = columns.map { col in
|
||||
var def = " \(col.name) \(col.type)"
|
||||
if col.isPrimaryKey { def += " PRIMARY KEY" }
|
||||
if col.isNotNull { def += " NOT NULL" }
|
||||
if let defaultValue = col.defaultValue { def += " DEFAULT \(defaultValue)" }
|
||||
return def
|
||||
}
|
||||
parts.append("TABLE \(name) (\n\(colDefs.joined(separator: ",\n"))\n)")
|
||||
|
||||
if !foreignKeys.isEmpty {
|
||||
let fkDescs = foreignKeys.map {
|
||||
" FOREIGN KEY (\($0.fromColumn)) REFERENCES \($0.toTable)(\($0.toColumn))"
|
||||
}
|
||||
parts.append("FOREIGN KEYS:\n\(fkDescs.joined(separator: "\n"))")
|
||||
}
|
||||
|
||||
if !indexes.isEmpty {
|
||||
let idxDescs = indexes.map {
|
||||
" INDEX \($0.name) ON (\($0.columns.joined(separator: ", ")))\($0.isUnique ? " UNIQUE" : "")"
|
||||
}
|
||||
parts.append("INDEXES:\n\(idxDescs.joined(separator: "\n"))")
|
||||
}
|
||||
|
||||
return parts.joined(separator: "\n")
|
||||
}
|
||||
|
||||
public init(
|
||||
name: String,
|
||||
columns: [ColumnSchema],
|
||||
primaryKey: [String],
|
||||
foreignKeys: [ForeignKeySchema],
|
||||
indexes: [IndexSchema]
|
||||
) {
|
||||
self.name = name
|
||||
self.columns = columns
|
||||
self.primaryKey = primaryKey
|
||||
self.foreignKeys = foreignKeys
|
||||
self.indexes = indexes
|
||||
}
|
||||
}
|
||||
|
||||
/// Schema for a single column.
|
||||
public struct ColumnSchema: Sendable, Equatable {
|
||||
/// Column position (0-based).
|
||||
public let cid: Int
|
||||
/// Column name.
|
||||
public let name: String
|
||||
/// Declared SQLite type (e.g. "TEXT", "INTEGER", "REAL", "BLOB").
|
||||
public let type: String
|
||||
/// Whether the column has a NOT NULL constraint.
|
||||
public let isNotNull: Bool
|
||||
/// Default value expression, if any.
|
||||
public let defaultValue: String?
|
||||
/// Whether this column is part of the primary key.
|
||||
public let isPrimaryKey: Bool
|
||||
|
||||
public init(
|
||||
cid: Int,
|
||||
name: String,
|
||||
type: String,
|
||||
isNotNull: Bool,
|
||||
defaultValue: String?,
|
||||
isPrimaryKey: Bool
|
||||
) {
|
||||
self.cid = cid
|
||||
self.name = name
|
||||
self.type = type
|
||||
self.isNotNull = isNotNull
|
||||
self.defaultValue = defaultValue
|
||||
self.isPrimaryKey = isPrimaryKey
|
||||
}
|
||||
}
|
||||
|
||||
/// Schema for a foreign key relationship.
|
||||
public struct ForeignKeySchema: Sendable, Equatable {
|
||||
/// Column in the source table.
|
||||
public let fromColumn: String
|
||||
/// Referenced table name.
|
||||
public let toTable: String
|
||||
/// Referenced column name.
|
||||
public let toColumn: String
|
||||
/// ON UPDATE action (e.g. "CASCADE", "NO ACTION").
|
||||
public let onUpdate: String
|
||||
/// ON DELETE action.
|
||||
public let onDelete: String
|
||||
|
||||
public init(
|
||||
fromColumn: String,
|
||||
toTable: String,
|
||||
toColumn: String,
|
||||
onUpdate: String,
|
||||
onDelete: String
|
||||
) {
|
||||
self.fromColumn = fromColumn
|
||||
self.toTable = toTable
|
||||
self.toColumn = toColumn
|
||||
self.onUpdate = onUpdate
|
||||
self.onDelete = onDelete
|
||||
}
|
||||
}
|
||||
|
||||
/// Schema for a database index.
|
||||
public struct IndexSchema: Sendable, Equatable {
|
||||
/// Index name.
|
||||
public let name: String
|
||||
/// Whether the index enforces uniqueness.
|
||||
public let isUnique: Bool
|
||||
/// Columns included in the index, in order.
|
||||
public let columns: [String]
|
||||
|
||||
public init(name: String, isUnique: Bool, columns: [String]) {
|
||||
self.name = name
|
||||
self.isUnique = isUnique
|
||||
self.columns = columns
|
||||
}
|
||||
}
|
||||
153
Sources/SwiftDBAI/Schema/SchemaIntrospector.swift
Normal file
153
Sources/SwiftDBAI/Schema/SchemaIntrospector.swift
Normal file
@@ -0,0 +1,153 @@
|
||||
// SchemaIntrospector.swift
|
||||
// SwiftDBAI
|
||||
//
|
||||
// Auto-introspects SQLite database schema using GRDB.
|
||||
|
||||
import GRDB
|
||||
|
||||
/// Introspects an SQLite database schema by querying sqlite_master and PRAGMA statements.
|
||||
///
|
||||
/// Usage:
|
||||
/// ```swift
|
||||
/// let dbPool = try DatabasePool(path: "path/to/db.sqlite")
|
||||
/// let schema = try await SchemaIntrospector.introspect(database: dbPool)
|
||||
/// print(schema.schemaDescription)
|
||||
/// ```
|
||||
public struct SchemaIntrospector: Sendable {
|
||||
|
||||
// MARK: - Public API
|
||||
|
||||
/// Introspects the full schema of the given database.
|
||||
///
|
||||
/// Discovers all user tables (excluding sqlite_ internal tables),
|
||||
/// their columns, primary keys, foreign keys, and indexes.
|
||||
///
|
||||
/// - Parameter database: A GRDB `DatabaseReader` (DatabasePool or DatabaseQueue).
|
||||
/// - Returns: A complete `DatabaseSchema` representation.
|
||||
public static func introspect(database: any DatabaseReader) async throws -> DatabaseSchema {
|
||||
try await database.read { db in
|
||||
try introspect(db: db)
|
||||
}
|
||||
}
|
||||
|
||||
/// Synchronous introspection within an existing database access context.
|
||||
///
|
||||
/// - Parameter db: A GRDB `Database` instance from within a read/write block.
|
||||
/// - Returns: A complete `DatabaseSchema` representation.
|
||||
public static func introspect(db: Database) throws -> DatabaseSchema {
|
||||
let tableNames = try fetchTableNames(db: db)
|
||||
var tables: [String: TableSchema] = [:]
|
||||
|
||||
for tableName in tableNames {
|
||||
let columns = try fetchColumns(db: db, table: tableName)
|
||||
let primaryKey = try fetchPrimaryKey(db: db, table: tableName)
|
||||
let foreignKeys = try fetchForeignKeys(db: db, table: tableName)
|
||||
let indexes = try fetchIndexes(db: db, table: tableName)
|
||||
|
||||
// Mark columns that are part of the primary key
|
||||
let pkSet = Set(primaryKey)
|
||||
let annotatedColumns = columns.map { col in
|
||||
ColumnSchema(
|
||||
cid: col.cid,
|
||||
name: col.name,
|
||||
type: col.type,
|
||||
isNotNull: col.isNotNull,
|
||||
defaultValue: col.defaultValue,
|
||||
isPrimaryKey: pkSet.contains(col.name)
|
||||
)
|
||||
}
|
||||
|
||||
tables[tableName] = TableSchema(
|
||||
name: tableName,
|
||||
columns: annotatedColumns,
|
||||
primaryKey: primaryKey,
|
||||
foreignKeys: foreignKeys,
|
||||
indexes: indexes
|
||||
)
|
||||
}
|
||||
|
||||
return DatabaseSchema(tables: tables, tableNames: tableNames)
|
||||
}
|
||||
|
||||
// MARK: - Private Helpers
|
||||
|
||||
/// Fetches all user table names from sqlite_master.
|
||||
private static func fetchTableNames(db: Database) throws -> [String] {
|
||||
let sql = """
|
||||
SELECT name FROM sqlite_master
|
||||
WHERE type = 'table'
|
||||
AND name NOT LIKE 'sqlite_%'
|
||||
ORDER BY name
|
||||
"""
|
||||
return try String.fetchAll(db, sql: sql)
|
||||
}
|
||||
|
||||
/// Fetches column metadata for a table using PRAGMA table_info.
|
||||
private static func fetchColumns(db: Database, table: String) throws -> [ColumnSchema] {
|
||||
let sql = "PRAGMA table_info(\(table.quotedDatabaseIdentifier))"
|
||||
let rows = try Row.fetchAll(db, sql: sql)
|
||||
return rows.map { row in
|
||||
ColumnSchema(
|
||||
cid: row["cid"],
|
||||
name: row["name"],
|
||||
type: (row["type"] as String?) ?? "",
|
||||
isNotNull: row["notnull"] == 1,
|
||||
defaultValue: row["dflt_value"],
|
||||
isPrimaryKey: row["pk"] != 0
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Fetches primary key columns for a table.
|
||||
private static func fetchPrimaryKey(db: Database, table: String) throws -> [String] {
|
||||
let sql = "PRAGMA table_info(\(table.quotedDatabaseIdentifier))"
|
||||
let rows = try Row.fetchAll(db, sql: sql)
|
||||
return rows
|
||||
.filter { ($0["pk"] as Int) > 0 }
|
||||
.sorted { ($0["pk"] as Int) < ($1["pk"] as Int) }
|
||||
.map { $0["name"] }
|
||||
}
|
||||
|
||||
/// Fetches foreign key relationships for a table.
|
||||
private static func fetchForeignKeys(db: Database, table: String) throws -> [ForeignKeySchema] {
|
||||
let sql = "PRAGMA foreign_key_list(\(table.quotedDatabaseIdentifier))"
|
||||
let rows = try Row.fetchAll(db, sql: sql)
|
||||
return rows.map { row in
|
||||
ForeignKeySchema(
|
||||
fromColumn: row["from"],
|
||||
toTable: row["table"],
|
||||
toColumn: row["to"],
|
||||
onUpdate: row["on_update"] ?? "NO ACTION",
|
||||
onDelete: row["on_delete"] ?? "NO ACTION"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Fetches indexes and their columns for a table.
|
||||
private static func fetchIndexes(db: Database, table: String) throws -> [IndexSchema] {
|
||||
let indexListSQL = "PRAGMA index_list(\(table.quotedDatabaseIdentifier))"
|
||||
let indexRows = try Row.fetchAll(db, sql: indexListSQL)
|
||||
|
||||
var indexes: [IndexSchema] = []
|
||||
for indexRow in indexRows {
|
||||
let indexName: String = indexRow["name"]
|
||||
let isUnique: Bool = indexRow["unique"] == 1
|
||||
|
||||
// Skip auto-generated indexes for primary keys
|
||||
if indexName.hasPrefix("sqlite_autoindex_") { continue }
|
||||
|
||||
let infoSQL = "PRAGMA index_info(\(indexName.quotedDatabaseIdentifier))"
|
||||
let infoRows = try Row.fetchAll(db, sql: infoSQL)
|
||||
let columns: [String] = infoRows
|
||||
.sorted { ($0["seqno"] as Int) < ($1["seqno"] as Int) }
|
||||
.map { $0["name"] }
|
||||
|
||||
indexes.append(IndexSchema(
|
||||
name: indexName,
|
||||
isUnique: isUnique,
|
||||
columns: columns
|
||||
))
|
||||
}
|
||||
return indexes
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user