Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Added

- SQL autocomplete now suggests column names before a FROM clause is written, using all cached schema columns as fallback
- Eager column cache warming after schema load for faster autocomplete
- MCP query safety: three-tier classification with server-side confirmation for write and destructive queries

### Fixed

- Schema-qualified table names (e.g. `public.users`) now correctly resolve in autocomplete

## [0.34.0] - 2026-04-22

### Added
Expand Down
34 changes: 21 additions & 13 deletions TablePro/Core/Autocomplete/SQLCompletionProvider.swift
Original file line number Diff line number Diff line change
Expand Up @@ -166,7 +166,7 @@ final class SQLCompletionProvider {
items.append(distinctItem)
}
// Function-arg items: columns, functions, value keywords
items += await schemaProvider.allColumnsInScope(for: context.tableReferences)
items += await columnItems(for: context.tableReferences)
items += SQLKeywords.functionItems()
items += filterKeywords(["NULL", "TRUE", "FALSE"])
if funcName.uppercased() != "COUNT" {
Expand All @@ -192,7 +192,7 @@ final class SQLCompletionProvider {
sortPriority: 60
))
}
items += await schemaProvider.allColumnsInScope(for: context.tableReferences)
items += await columnItems(for: context.tableReferences)
items += SQLKeywords.functionItems()
items += filterKeywords([
"DISTINCT", "ALL", "AS", "FROM", "CASE", "WHEN",
Expand All @@ -202,7 +202,7 @@ final class SQLCompletionProvider {

case .on:
// HP-3: ON clause — prioritize columns from joined tables
items += await schemaProvider.allColumnsInScope(for: context.tableReferences)
items += await columnItems(for: context.tableReferences)
// Add qualified column suggestions (table.column) for join conditions
for ref in context.tableReferences {
let qualifier = ref.alias ?? ref.tableName
Expand All @@ -225,7 +225,7 @@ final class SQLCompletionProvider {

case .where_, .and, .having:
// HP-8: Columns, operators, logical keywords + clause transitions
items += await schemaProvider.allColumnsInScope(for: context.tableReferences)
items += await columnItems(for: context.tableReferences)
items += SQLKeywords.operatorItems()
items += filterKeywords([
"AND", "OR", "NOT", "IN", "LIKE", "ILIKE", "BETWEEN", "IS",
Expand All @@ -242,15 +242,15 @@ final class SQLCompletionProvider {

case .groupBy:
// Columns + clause transitions
items += await schemaProvider.allColumnsInScope(for: context.tableReferences)
items += await columnItems(for: context.tableReferences)
items += filterKeywords([
"HAVING", "ORDER BY", "LIMIT",
"UNION", "INTERSECT", "EXCEPT"
])

case .orderBy:
// Columns + sort direction + clause transitions
items += await schemaProvider.allColumnsInScope(for: context.tableReferences)
items += await columnItems(for: context.tableReferences)
items += filterKeywords([
"ASC", "DESC", "NULLS FIRST", "NULLS LAST",
"LIMIT", "OFFSET",
Expand Down Expand Up @@ -297,7 +297,7 @@ final class SQLCompletionProvider {
distinctItem.sortPriority = 20
items.append(distinctItem)
}
items += await schemaProvider.allColumnsInScope(for: context.tableReferences)
items += await columnItems(for: context.tableReferences)
items += SQLKeywords.functionItems()
if isCountFunction {
// DISTINCT already added above with boosted priority
Expand All @@ -308,14 +308,14 @@ final class SQLCompletionProvider {

case .caseExpression:
// Inside CASE expression
items += await schemaProvider.allColumnsInScope(for: context.tableReferences)
items += await columnItems(for: context.tableReferences)
items += filterKeywords(["WHEN", "THEN", "ELSE", "END", "AND", "OR", "IS", "NULL", "TRUE", "FALSE"])
items += SQLKeywords.operatorItems()
items += SQLKeywords.functionItems()

case .inList:
// Inside IN (...) list - suggest values, subqueries, columns
items += await schemaProvider.allColumnsInScope(for: context.tableReferences)
items += await columnItems(for: context.tableReferences)
items += filterKeywords(["SELECT", "NULL", "TRUE", "FALSE"])
items += SQLKeywords.functionItems()

Expand Down Expand Up @@ -378,7 +378,7 @@ final class SQLCompletionProvider {

case .returning:
// After RETURNING (PostgreSQL) - suggest columns
items += await schemaProvider.allColumnsInScope(for: context.tableReferences)
items += await columnItems(for: context.tableReferences)
items += filterKeywords(["*"])

case .union:
Expand All @@ -387,11 +387,11 @@ final class SQLCompletionProvider {

case .using:
// After USING in JOIN - suggest columns
items += await schemaProvider.allColumnsInScope(for: context.tableReferences)
items += await columnItems(for: context.tableReferences)

case .window:
// After OVER/PARTITION BY - suggest columns and window keywords
items += await schemaProvider.allColumnsInScope(for: context.tableReferences)
items += await columnItems(for: context.tableReferences)
items += filterKeywords([
"PARTITION BY", "ORDER BY", "ASC", "DESC",
"ROWS", "RANGE", "GROUPS", "BETWEEN", "UNBOUNDED",
Expand All @@ -410,7 +410,7 @@ final class SQLCompletionProvider {
items += filterKeywords(["ON"])
} else {
// After ON tablename (inside parens) — suggest columns
items = await schemaProvider.allColumnsInScope(for: context.tableReferences)
items = await columnItems(for: context.tableReferences)
items += filterKeywords(["USING", "BTREE", "HASH", "GIN", "GIST"])
}

Expand Down Expand Up @@ -479,6 +479,14 @@ final class SQLCompletionProvider {
}
}

/// Columns from explicit table references, or all cached schema columns as fallback
private func columnItems(for references: [TableReference]) async -> [SQLCompletionItem] {
if references.isEmpty {
return await schemaProvider.allColumnsFromCachedTables()
}
return await schemaProvider.allColumnsInScope(for: references)
}

/// Filter to specific keywords
private func filterKeywords(_ keywords: [String]) -> [SQLCompletionItem] {
keywords.map { SQLCompletionItem.keyword($0) }
Expand Down
39 changes: 24 additions & 15 deletions TablePro/Core/Autocomplete/SQLContextAnalyzer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -265,14 +265,14 @@ final class SQLContextAnalyzer {

private static let tableRefRegexes: [NSRegularExpression] = {
let patterns = [
"(?i)\\bFROM\\s+[`\"']?([\\w]+)[`\"']?" +
"(?i)\\bFROM\\s+[`\"']?([\\w.]+)[`\"']?" +
"(?:\\s+(?:AS\\s+)?[`\"']?([\\w]+)[`\"']?)?",
"(?i)(?:LEFT|RIGHT|INNER|OUTER|CROSS|FULL)?\\s*(?:OUTER)?\\s*JOIN\\s+" +
"[`\"']?([\\w]+)[`\"']?(?:\\s+(?:AS\\s+)?[`\"']?([\\w]+)[`\"']?)?",
"(?i)\\bUPDATE\\s+[`\"']?([\\w]+)[`\"']?" +
"[`\"']?([\\w.]+)[`\"']?(?:\\s+(?:AS\\s+)?[`\"']?([\\w]+)[`\"']?)?",
"(?i)\\bUPDATE\\s+[`\"']?([\\w.]+)[`\"']?" +
"(?:\\s+(?:AS\\s+)?[`\"']?([\\w]+)[`\"']?)?",
"(?i)\\bINSERT\\s+INTO\\s+[`\"']?([\\w]+)[`\"']?",
"(?i)\\bCREATE\\s+(?:UNIQUE\\s+)?INDEX\\s+\\w+\\s+ON\\s+[`\"']?([\\w]+)[`\"']?"
"(?i)\\bINSERT\\s+INTO\\s+[`\"']?([\\w.]+)[`\"']?",
"(?i)\\bCREATE\\s+(?:UNIQUE\\s+)?INDEX\\s+\\w+\\s+ON\\s+[`\"']?([\\w.]+)[`\"']?"
]
return patterns.compactMap { try? NSRegularExpression(pattern: $0) }
}()
Expand Down Expand Up @@ -780,29 +780,38 @@ final class SQLContextAnalyzer {
}
}

private static let tableRefKeywords: Set<String> = [
"LEFT", "RIGHT", "INNER", "OUTER", "FULL", "CROSS", "NATURAL",
"JOIN", "ON", "AND", "OR", "WHERE", "SELECT", "FROM", "AS"
]

/// Strip schema prefix from a potentially schema-qualified name
private static func stripSchemaPrefix(_ raw: String) -> String {
let ns = raw as NSString
let dotRange = ns.range(of: ".", options: .backwards)
guard dotRange.location != NSNotFound else { return raw }
let start = dotRange.location + 1
guard start < ns.length else { return raw }
return ns.substring(from: start)
}

/// Extract all table references (table names and aliases) from the query
private func extractTableReferences(from query: String) -> [TableReference] {
var references: [TableReference] = []
var seen = Set<TableReference>()

// SQL keywords that should NOT be treated as table names
let sqlKeywords: Set<String> = [
"LEFT", "RIGHT", "INNER", "OUTER", "FULL", "CROSS", "NATURAL",
"JOIN", "ON", "AND", "OR", "WHERE", "SELECT", "FROM", "AS"
]

let nsRange = NSRange(location: 0, length: (query as NSString).length)

// Uses pre-compiled static regexes for performance
for regex in Self.tableRefRegexes {
regex.enumerateMatches(in: query, range: nsRange) { match, _, _ in
guard let match = match else { return }

let tableNSRange = match.range(at: 1)
guard tableNSRange.location != NSNotFound else { return }

let tableName = (query as NSString).substring(with: tableNSRange)
guard !sqlKeywords.contains(tableName.uppercased()) else { return }
let rawName = (query as NSString).substring(with: tableNSRange)
let tableName = Self.stripSchemaPrefix(rawName)
guard !Self.tableRefKeywords.contains(tableName.uppercased()) else { return }

var alias: String?
if match.numberOfRanges > 2 {
Expand All @@ -811,7 +820,7 @@ final class SQLContextAnalyzer {
let aliasCandidate = (query as NSString).substring(
with: aliasNSRange
)
if !sqlKeywords.contains(aliasCandidate.uppercased()) {
if !Self.tableRefKeywords.contains(aliasCandidate.uppercased()) {
alias = aliasCandidate
}
}
Expand Down
90 changes: 90 additions & 0 deletions TablePro/Core/Autocomplete/SQLSchemaProvider.swift
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ actor SQLSchemaProvider {
private var lastRetryAttempt: Date?
private let retryCooldown: TimeInterval = 30
private var loadTask: Task<Void, Never>?
private var eagerColumnTask: Task<Void, Never>?

// Store a weak driver reference to avoid retaining it after disconnect (MEM-9)
private weak var cachedDriver: (any DatabaseDriver)?
Expand Down Expand Up @@ -67,6 +68,7 @@ actor SQLSchemaProvider {
private func setLoadedTables(_ newTables: [TableInfo]) {
tables = newTables
isLoading = false
startEagerColumnLoad()
}

private func setLoadError(_ error: Error) {
Expand Down Expand Up @@ -136,12 +138,45 @@ actor SQLSchemaProvider {
}

func resetForDatabase(_ database: String?, tables newTables: [TableInfo], driver: DatabaseDriver) {
eagerColumnTask?.cancel()
eagerColumnTask = nil
self.tables = newTables
self.columnCache.removeAll()
self.columnAccessOrder.removeAll()
self.cachedDriver = driver
self.isLoading = false
self.lastLoadError = nil
startEagerColumnLoad()
}

// MARK: - Eager Column Loading

private func startEagerColumnLoad() {
guard !tables.isEmpty, let driver = cachedDriver else { return }
eagerColumnTask?.cancel()
let tableCount = tables.count
eagerColumnTask = Task {
Self.logger.info("[schema] eager column load starting tableCount=\(tableCount)")
do {
let allColumns = try await driver.fetchAllColumns()
guard !Task.isCancelled else { return }
self.populateColumnCache(allColumns)
Self.logger.info("[schema] eager column load complete cachedCount=\(self.columnCache.count)")
} catch {
guard !Task.isCancelled else { return }
Self.logger.debug("[schema] eager column load failed: \(error.localizedDescription)")
}
}
}

private func populateColumnCache(_ allColumns: [String: [ColumnInfo]]) {
for (tableName, columns) in allColumns {
let key = tableName.lowercased()
guard columnCache[key] == nil else { continue }
guard columnAccessOrder.count < maxCachedTables else { break }
columnCache[key] = columns
columnAccessOrder.append(key)
}
}

/// Find table name from alias
Expand Down Expand Up @@ -278,4 +313,59 @@ actor SQLSchemaProvider {
}
}
}

/// Get completion items for all columns from cached tables (zero network).
/// Used as fallback when no table references exist in the current statement.
func allColumnsFromCachedTables() async -> [SQLCompletionItem] {
guard !columnCache.isEmpty else { return [] }

let canonicalNames = Dictionary(
tables.map { ($0.name.lowercased(), $0.name) },
uniquingKeysWith: { first, _ in first }
)

var allEntries: [(table: String, col: ColumnInfo)] = []
var nameCount: [String: Int] = [:]

for (key, columns) in columnCache {
let tableName = canonicalNames[key] ?? key
for col in columns {
allEntries.append((table: tableName, col: col))
nameCount[col.name.lowercased(), default: 0] += 1
}
}

// swiftlint:disable:next large_tuple
var itemDataBuilder: [(
label: String, insertText: String, type: String, table: String,
isPK: Bool, isNullable: Bool, defaultValue: String?, comment: String?
)] = []

for entry in allEntries {
let isAmbiguous = (nameCount[entry.col.name.lowercased()] ?? 0) > 1
let label = isAmbiguous ? "\(entry.table).\(entry.col.name)" : entry.col.name
let insertText = isAmbiguous ? "\(entry.table).\(entry.col.name)" : entry.col.name

itemDataBuilder.append((
label: label, insertText: insertText, type: entry.col.dataType,
table: entry.table, isPK: entry.col.isPrimaryKey,
isNullable: entry.col.isNullable, defaultValue: entry.col.defaultValue,
comment: entry.col.comment
))
}

let itemData = itemDataBuilder

return await MainActor.run {
itemData.map {
var item = SQLCompletionItem.column(
$0.label, dataType: $0.type, tableName: $0.table,
isPrimaryKey: $0.isPK, isNullable: $0.isNullable,
defaultValue: $0.defaultValue, comment: $0.comment
)
item.sortPriority = 150
return item
}
}
}
}
2 changes: 2 additions & 0 deletions TablePro/Views/Editor/SQLCompletionAdapter.swift
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,8 @@ final class SQLCompletionAdapter: CodeSuggestionDelegate {
case .from, .join, .into, .set, .insertColumns, .on,
.alterTableColumn, .returning, .using, .dropObject, .createIndex:
break // Allow empty-prefix completions for these browseable contexts
case .select where !context.sqlContext.isAfterComma:
break // Allow after SELECT keyword, but not after each comma
default:
return nil
}
Expand Down
6 changes: 3 additions & 3 deletions TableProTests/Core/Autocomplete/CompletionEngineTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -143,13 +143,13 @@ struct CompletionEngineTests {
#expect(result == nil)
}

@Test("Completions are limited")
@Test("Completions are limited to maxSuggestions for the clause type")
func testCompletionsLimited() async {
let text = "SELECT "
let text = "SEL"
let result = await engine.getCompletions(text: text, cursorPosition: text.count)
#expect(result != nil)
if let result = result {
#expect(result.items.count <= 20)
#expect(result.items.count <= 40)
}
}

Expand Down
Loading
Loading