From 6188e9c0019f738a52cf0d1a5d61c8be02c1bb72 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ng=C3=B4=20Qu=E1=BB=91c=20=C4=90=E1=BA=A1t?= Date: Thu, 23 Apr 2026 12:02:40 +0700 Subject: [PATCH 1/4] feat: suggest columns in SELECT before FROM clause is written Closes #840 --- CHANGELOG.md | 9 ++ .../Autocomplete/SQLCompletionProvider.swift | 34 ++++--- .../Autocomplete/SQLContextAnalyzer.swift | 39 ++++---- .../Core/Autocomplete/SQLSchemaProvider.swift | 90 +++++++++++++++++++ .../Views/Editor/SQLCompletionAdapter.swift | 2 + 5 files changed, 146 insertions(+), 28 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9f5c8cdf4..c102b70ef 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### 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 + +### Fixed + +- Schema-qualified table names (e.g. `public.users`) now correctly resolve in autocomplete + ## [0.34.0] - 2026-04-22 ### Added diff --git a/TablePro/Core/Autocomplete/SQLCompletionProvider.swift b/TablePro/Core/Autocomplete/SQLCompletionProvider.swift index 5cb659213..f80dea1e5 100644 --- a/TablePro/Core/Autocomplete/SQLCompletionProvider.swift +++ b/TablePro/Core/Autocomplete/SQLCompletionProvider.swift @@ -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" { @@ -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", @@ -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 @@ -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", @@ -242,7 +242,7 @@ 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" @@ -250,7 +250,7 @@ final class SQLCompletionProvider { 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", @@ -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 @@ -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() @@ -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: @@ -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", @@ -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"]) } @@ -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) } diff --git a/TablePro/Core/Autocomplete/SQLContextAnalyzer.swift b/TablePro/Core/Autocomplete/SQLContextAnalyzer.swift index 122132048..d825656fa 100644 --- a/TablePro/Core/Autocomplete/SQLContextAnalyzer.swift +++ b/TablePro/Core/Autocomplete/SQLContextAnalyzer.swift @@ -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) } }() @@ -780,20 +780,28 @@ final class SQLContextAnalyzer { } } + private static let tableRefKeywords: Set = [ + "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() - // SQL keywords that should NOT be treated as table names - let sqlKeywords: Set = [ - "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 } @@ -801,8 +809,9 @@ final class SQLContextAnalyzer { 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 { @@ -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 } } diff --git a/TablePro/Core/Autocomplete/SQLSchemaProvider.swift b/TablePro/Core/Autocomplete/SQLSchemaProvider.swift index 292ba95ea..9556e6e86 100644 --- a/TablePro/Core/Autocomplete/SQLSchemaProvider.swift +++ b/TablePro/Core/Autocomplete/SQLSchemaProvider.swift @@ -23,6 +23,7 @@ actor SQLSchemaProvider { private var lastRetryAttempt: Date? private let retryCooldown: TimeInterval = 30 private var loadTask: Task? + private var eagerColumnTask: Task? // Store a weak driver reference to avoid retaining it after disconnect (MEM-9) private weak var cachedDriver: (any DatabaseDriver)? @@ -67,6 +68,7 @@ actor SQLSchemaProvider { private func setLoadedTables(_ newTables: [TableInfo]) { tables = newTables isLoading = false + startEagerColumnLoad() } private func setLoadError(_ error: Error) { @@ -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 @@ -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 + } + } + } } diff --git a/TablePro/Views/Editor/SQLCompletionAdapter.swift b/TablePro/Views/Editor/SQLCompletionAdapter.swift index a8bc61f7e..44674c698 100644 --- a/TablePro/Views/Editor/SQLCompletionAdapter.swift +++ b/TablePro/Views/Editor/SQLCompletionAdapter.swift @@ -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 } From a4dab1dce235331c4e6a0131431dd9fe5d6937e9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ng=C3=B4=20Qu=E1=BB=91c=20=C4=90=E1=BA=A1t?= Date: Thu, 23 Apr 2026 13:13:54 +0700 Subject: [PATCH 2/4] test: add comprehensive coverage for column fallback autocomplete --- .../Autocomplete/CompletionEngineTests.swift | 6 +- .../SQLCompletionProviderTests.swift | 85 ++++ .../SQLContextAnalyzerTests.swift | 87 ++++ .../SQLSchemaProviderFallbackTests.swift | 472 ++++++++++++++++++ 4 files changed, 647 insertions(+), 3 deletions(-) create mode 100644 TableProTests/Core/Autocomplete/SQLSchemaProviderFallbackTests.swift diff --git a/TableProTests/Core/Autocomplete/CompletionEngineTests.swift b/TableProTests/Core/Autocomplete/CompletionEngineTests.swift index 221f1cfa3..8b4f6a1ff 100644 --- a/TableProTests/Core/Autocomplete/CompletionEngineTests.swift +++ b/TableProTests/Core/Autocomplete/CompletionEngineTests.swift @@ -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) } } diff --git a/TableProTests/Core/Autocomplete/SQLCompletionProviderTests.swift b/TableProTests/Core/Autocomplete/SQLCompletionProviderTests.swift index 08b1dab55..a089151f8 100644 --- a/TableProTests/Core/Autocomplete/SQLCompletionProviderTests.swift +++ b/TableProTests/Core/Autocomplete/SQLCompletionProviderTests.swift @@ -963,4 +963,89 @@ struct SQLCompletionProviderTests { #expect(inIdx < insertIdx, "IN should rank above INSERT for prefix 'IN'") } } + + // MARK: - Column Fallback Without FROM + + @Test("SELECT without FROM returns keywords and functions") + func testSelectWithoutFromReturnsItems() async { + let text = "SELECT " + let (items, _) = await provider.getCompletions(text: text, cursorPosition: text.count) + #expect(!items.isEmpty) + let hasStar = items.contains { $0.label == "*" } + #expect(hasStar, "SELECT without FROM should include * wildcard") + } + + @Test("SELECT with prefix without FROM returns filtered items") + func testSelectWithPrefixNoFrom() async { + let text = "SELECT DIS" + let (items, _) = await provider.getCompletions(text: text, cursorPosition: text.count) + let hasDistinct = items.contains { $0.label == "DISTINCT" } + #expect(hasDistinct) + } + + @Test("WHERE clause without FROM returns operators and keywords") + func testWhereWithoutFrom() async { + let text = "SELECT * WHERE AN" + let (items, _) = await provider.getCompletions(text: text, cursorPosition: text.count) + let hasAnd = items.contains { $0.label == "AND" } + #expect(hasAnd, "WHERE without FROM should include AND when filtered by prefix") + } + + @Test("FROM clause still returns table and keyword items") + func testFromClauseStillWorks() async { + let text = "SELECT * FROM " + let (items, _) = await provider.getCompletions(text: text, cursorPosition: text.count) + #expect(!items.isEmpty) + let hasJoin = items.contains { $0.label == "JOIN" || $0.label == "LEFT JOIN" } + #expect(hasJoin) + } + + @Test("ORDER BY without explicit FROM returns keywords") + func testOrderByWithoutFrom() async { + let text = "SELECT * ORDER BY " + let (items, _) = await provider.getCompletions(text: text, cursorPosition: text.count) + let hasAsc = items.contains { $0.label == "ASC" } + let hasDesc = items.contains { $0.label == "DESC" } + #expect(hasAsc, "ORDER BY should include ASC") + #expect(hasDesc, "ORDER BY should include DESC") + } + + @Test("GROUP BY without FROM returns transition keywords") + func testGroupByWithoutFrom() async { + let text = "SELECT * GROUP BY " + let (items, _) = await provider.getCompletions(text: text, cursorPosition: text.count) + let hasHaving = items.contains { $0.label == "HAVING" } + #expect(hasHaving) + } + + @Test("SELECT with FROM preserves explicit table column resolution") + func testSelectWithFromPreservesExplicit() async { + let text = "SELECT * FROM users WHERE " + let (items, _) = await provider.getCompletions(text: text, cursorPosition: text.count) + #expect(!items.isEmpty) + } + + @Test("CASE expression without FROM returns CASE keywords") + func testCaseWithoutFrom() async { + let text = "SELECT CASE " + let (items, _) = await provider.getCompletions(text: text, cursorPosition: text.count) + #expect(!items.isEmpty) + } + + @Test("Parse-ahead: cursor before FROM still detects table references") + func testParseAheadCursorBeforeFrom() async { + let text = "SELECT FROM users" + // Cursor at position 7 (after "SELECT ") + let (items, context) = await provider.getCompletions(text: text, cursorPosition: 7) + #expect(context.clauseType == .select) + #expect(context.tableReferences.contains { $0.tableName == "users" }) + } + + @Test("Function arg without FROM returns function items") + func testFunctionArgWithoutFrom() async { + let text = "SELECT COUNT(" + let (items, _) = await provider.getCompletions(text: text, cursorPosition: text.count) + let hasStar = items.contains { $0.label == "*" } + #expect(hasStar, "COUNT( should suggest *") + } } diff --git a/TableProTests/Core/Autocomplete/SQLContextAnalyzerTests.swift b/TableProTests/Core/Autocomplete/SQLContextAnalyzerTests.swift index abb6eb337..7e9e6cac3 100644 --- a/TableProTests/Core/Autocomplete/SQLContextAnalyzerTests.swift +++ b/TableProTests/Core/Autocomplete/SQLContextAnalyzerTests.swift @@ -846,4 +846,91 @@ struct SQLContextAnalyzerTests { #expect(context.clauseType == .select) #expect(context.nestingLevel > 0) } + + // MARK: - Schema-Qualified Table Name Tests + + @Test("FROM with schema-qualified table strips schema prefix") + func testFromSchemaQualifiedTable() { + let query = "SELECT * FROM public.users" + let context = analyzer.analyze(query: query, cursorPosition: query.count) + #expect(context.tableReferences.contains { $0.tableName == "users" }) + #expect(!context.tableReferences.contains { $0.tableName == "public" }) + #expect(!context.tableReferences.contains { $0.tableName == "public.users" }) + } + + @Test("FROM with multi-level schema strips to last component") + func testFromMultiLevelSchemaQualifiedTable() { + let query = "SELECT * FROM catalog.schema.users" + let context = analyzer.analyze(query: query, cursorPosition: query.count) + #expect(context.tableReferences.contains { $0.tableName == "users" }) + } + + @Test("JOIN with schema-qualified table strips schema prefix") + func testJoinSchemaQualifiedTable() { + let query = "SELECT * FROM users JOIN public.orders ON orders.id = users.order_id" + let context = analyzer.analyze(query: query, cursorPosition: query.count) + #expect(context.tableReferences.contains { $0.tableName == "orders" }) + } + + @Test("UPDATE with schema-qualified table strips schema prefix") + func testUpdateSchemaQualifiedTable() { + let query = "UPDATE public.users SET name = 'test'" + let context = analyzer.analyze(query: query, cursorPosition: query.count) + #expect(context.tableReferences.contains { $0.tableName == "users" }) + } + + @Test("INSERT INTO with schema-qualified table strips schema prefix") + func testInsertIntoSchemaQualifiedTable() { + let query = "INSERT INTO public.users (name) VALUES ('test')" + let context = analyzer.analyze(query: query, cursorPosition: query.count) + #expect(context.tableReferences.contains { $0.tableName == "users" }) + } + + @Test("FROM without schema prefix still works normally") + func testFromWithoutSchemaPrefix() { + let query = "SELECT * FROM users" + let context = analyzer.analyze(query: query, cursorPosition: query.count) + #expect(context.tableReferences.contains { $0.tableName == "users" }) + } + + @Test("Backtick-quoted schema-qualified table extracts reference") + func testBacktickQuotedSchemaQualifiedTable() { + let query = "SELECT * FROM `public.users`" + let context = analyzer.analyze(query: query, cursorPosition: query.count) + #expect(context.tableReferences.contains { $0.tableName == "users" }) + } + + @Test("Schema-qualified table with alias extracts both correctly") + func testSchemaQualifiedTableWithAlias() { + let query = "SELECT * FROM public.users u" + let context = analyzer.analyze(query: query, cursorPosition: query.count) + #expect(context.tableReferences.contains { $0.tableName == "users" && $0.alias == "u" }) + } + + @Test("Multiple schema-qualified tables in FROM and JOIN both extracted") + func testMultipleSchemaQualifiedTables() { + let query = "SELECT * FROM public.users JOIN schema2.orders ON orders.user_id = users.id" + let context = analyzer.analyze(query: query, cursorPosition: query.count) + #expect(context.tableReferences.contains { $0.tableName == "users" }) + #expect(context.tableReferences.contains { $0.tableName == "orders" }) + } + + // MARK: - Parse-Ahead Table Reference Tests + + @Test("Table references extracted from full statement even when cursor is before FROM") + func testTableRefsExtractedAheadOfCursor() { + let query = "SELECT FROM users" + // Cursor at position 7 (after "SELECT ") + let context = analyzer.analyze(query: query, cursorPosition: 7) + #expect(context.tableReferences.contains { $0.tableName == "users" }) + } + + @Test("All table references extracted from full statement regardless of cursor position") + func testAllTableRefsExtractedAheadOfCursor() { + let query = "SELECT na FROM users JOIN orders" + // Cursor at position 9 (after "SELECT na") + let context = analyzer.analyze(query: query, cursorPosition: 9) + #expect(context.tableReferences.contains { $0.tableName == "users" }) + #expect(context.tableReferences.contains { $0.tableName == "orders" }) + } } diff --git a/TableProTests/Core/Autocomplete/SQLSchemaProviderFallbackTests.swift b/TableProTests/Core/Autocomplete/SQLSchemaProviderFallbackTests.swift new file mode 100644 index 000000000..ebae96b45 --- /dev/null +++ b/TableProTests/Core/Autocomplete/SQLSchemaProviderFallbackTests.swift @@ -0,0 +1,472 @@ +// +// SQLSchemaProviderFallbackTests.swift +// TableProTests +// +// Tests for allColumnsFromCachedTables() fallback completion +// and eager column loading via populateColumnCache. +// + +import Foundation +@testable import TablePro +import Testing + +// MARK: - Mock Driver + +private final class MockFallbackDriver: DatabaseDriver, @unchecked Sendable { + let connection: DatabaseConnection + var status: ConnectionStatus = .connected + var serverVersion: String? { nil } + + var tablesToReturn: [TableInfo] = [] + var columnsPerTable: [String: [ColumnInfo]] = [:] + var fetchColumnsCallCount = 0 + var fetchAllColumnsCallCount = 0 + + init(connection: DatabaseConnection = TestFixtures.makeConnection()) { + self.connection = connection + } + + func connect() async throws {} + func disconnect() {} + func testConnection() async throws -> Bool { true } + func applyQueryTimeout(_ seconds: Int) async throws {} + + func execute(query: String) async throws -> QueryResult { + QueryResult(columns: [], columnTypes: [], rows: [], rowsAffected: 0, executionTime: 0, error: nil) + } + + func executeParameterized(query: String, parameters: [Any?]) async throws -> QueryResult { + QueryResult(columns: [], columnTypes: [], rows: [], rowsAffected: 0, executionTime: 0, error: nil) + } + + func fetchRowCount(query: String) async throws -> Int { 0 } + + func fetchRows(query: String, offset: Int, limit: Int) async throws -> QueryResult { + QueryResult(columns: [], columnTypes: [], rows: [], rowsAffected: 0, executionTime: 0, error: nil) + } + + func fetchTables() async throws -> [TableInfo] { + tablesToReturn + } + + func fetchColumns(table: String) async throws -> [ColumnInfo] { + fetchColumnsCallCount += 1 + return columnsPerTable[table.lowercased()] ?? [] + } + + func fetchAllColumns() async throws -> [String: [ColumnInfo]] { + fetchAllColumnsCallCount += 1 + return columnsPerTable + } + + func fetchIndexes(table: String) async throws -> [IndexInfo] { [] } + func fetchForeignKeys(table: String) async throws -> [ForeignKeyInfo] { [] } + func fetchApproximateRowCount(table: String) async throws -> Int? { nil } + + func fetchTableDDL(table: String) async throws -> String { "" } + func fetchViewDefinition(view: String) async throws -> String { "" } + + func fetchTableMetadata(tableName: String) async throws -> TableMetadata { + TableMetadata( + tableName: tableName, dataSize: nil, indexSize: nil, totalSize: nil, + avgRowLength: nil, rowCount: nil, comment: nil, engine: nil, + collation: nil, createTime: nil, updateTime: nil + ) + } + + func fetchDatabases() async throws -> [String] { [] } + + func fetchDatabaseMetadata(_ database: String) async throws -> DatabaseMetadata { + DatabaseMetadata( + id: database, name: database, tableCount: nil, sizeBytes: nil, + lastAccessed: nil, isSystemDatabase: false, icon: "cylinder" + ) + } + + func createDatabase(name: String, charset: String, collation: String?) async throws {} + func cancelQuery() throws {} + func beginTransaction() async throws {} + func commitTransaction() async throws {} + func rollbackTransaction() async throws {} +} + +// MARK: - Helper + +/// Populate the column cache by calling getColumns for each table in the driver. +/// This is deterministic (no timing dependency on eager load tasks). +private func populateCache( + provider: SQLSchemaProvider, + driver: MockFallbackDriver +) async { + for tableName in driver.columnsPerTable.keys { + _ = await provider.getColumns(for: tableName) + } +} + +// MARK: - Tests + +@Suite("SQLSchemaProvider Fallback Columns", .serialized) +@MainActor +struct SQLSchemaProviderFallbackTests { + // MARK: - allColumnsFromCachedTables + + @Test("Empty cache returns no fallback columns") + func emptyCache() async { + let provider = SQLSchemaProvider() + let items = await provider.allColumnsFromCachedTables() + #expect(items.isEmpty) + } + + @Test("Single table columns have plain labels") + func singleTablePlainLabels() async { + let driver = MockFallbackDriver() + driver.tablesToReturn = [TestFixtures.makeTableInfo(name: "users")] + driver.columnsPerTable = [ + "users": [ + TestFixtures.makeColumnInfo(name: "id"), + TestFixtures.makeColumnInfo(name: "name", dataType: "VARCHAR", isPrimaryKey: false) + ] + ] + + let provider = SQLSchemaProvider() + await provider.loadSchema(using: driver, connection: TestFixtures.makeConnection()) + await populateCache(provider: provider, driver: driver) + + let items = await provider.allColumnsFromCachedTables() + #expect(items.count == 2) + + let labels = items.map(\.label).sorted() + #expect(labels == ["id", "name"]) + // No table prefix since there's only one table + #expect(!labels.contains(where: { $0.contains(".") })) + } + + @Test("Ambiguous columns get table-qualified labels") + func ambiguousColumnsQualified() async { + let driver = MockFallbackDriver() + driver.tablesToReturn = [ + TestFixtures.makeTableInfo(name: "users"), + TestFixtures.makeTableInfo(name: "orders") + ] + driver.columnsPerTable = [ + "users": [TestFixtures.makeColumnInfo(name: "id")], + "orders": [TestFixtures.makeColumnInfo(name: "id")] + ] + + let provider = SQLSchemaProvider() + await provider.loadSchema(using: driver, connection: TestFixtures.makeConnection()) + await populateCache(provider: provider, driver: driver) + + let items = await provider.allColumnsFromCachedTables() + #expect(items.count == 2) + + let labels = Set(items.map(\.label)) + #expect(labels.contains("users.id")) + #expect(labels.contains("orders.id")) + } + + @Test("Unique columns stay plain with multiple tables") + func uniqueColumnsPlain() async { + let driver = MockFallbackDriver() + driver.tablesToReturn = [ + TestFixtures.makeTableInfo(name: "users"), + TestFixtures.makeTableInfo(name: "orders") + ] + driver.columnsPerTable = [ + "users": [TestFixtures.makeColumnInfo(name: "name", dataType: "VARCHAR", isPrimaryKey: false)], + "orders": [TestFixtures.makeColumnInfo(name: "total", dataType: "DECIMAL", isPrimaryKey: false)] + ] + + let provider = SQLSchemaProvider() + await provider.loadSchema(using: driver, connection: TestFixtures.makeConnection()) + await populateCache(provider: provider, driver: driver) + + let items = await provider.allColumnsFromCachedTables() + #expect(items.count == 2) + + let labels = Set(items.map(\.label)) + #expect(labels.contains("name")) + #expect(labels.contains("total")) + #expect(!labels.contains(where: { $0.contains(".") })) + } + + @Test("InsertText matches label for ambiguous columns") + func insertTextMatchesLabelAmbiguous() async { + let driver = MockFallbackDriver() + driver.tablesToReturn = [ + TestFixtures.makeTableInfo(name: "users"), + TestFixtures.makeTableInfo(name: "orders") + ] + driver.columnsPerTable = [ + "users": [TestFixtures.makeColumnInfo(name: "id")], + "orders": [TestFixtures.makeColumnInfo(name: "id")] + ] + + let provider = SQLSchemaProvider() + await provider.loadSchema(using: driver, connection: TestFixtures.makeConnection()) + await populateCache(provider: provider, driver: driver) + + let items = await provider.allColumnsFromCachedTables() + for item in items { + #expect(item.insertText == item.label) + } + } + + @Test("InsertText is plain column name for unique columns") + func insertTextPlainForUnique() async { + let driver = MockFallbackDriver() + driver.tablesToReturn = [TestFixtures.makeTableInfo(name: "users")] + driver.columnsPerTable = [ + "users": [TestFixtures.makeColumnInfo(name: "email", dataType: "VARCHAR", isPrimaryKey: false)] + ] + + let provider = SQLSchemaProvider() + await provider.loadSchema(using: driver, connection: TestFixtures.makeConnection()) + await populateCache(provider: provider, driver: driver) + + let items = await provider.allColumnsFromCachedTables() + #expect(items.count == 1) + #expect(items[0].insertText == "email") + #expect(items[0].label == "email") + } + + @Test("Fallback columns have sortPriority 150") + func fallbackSortPriority() async { + let driver = MockFallbackDriver() + driver.tablesToReturn = [ + TestFixtures.makeTableInfo(name: "users"), + TestFixtures.makeTableInfo(name: "orders") + ] + driver.columnsPerTable = [ + "users": [TestFixtures.makeColumnInfo(name: "id"), TestFixtures.makeColumnInfo(name: "name", dataType: "VARCHAR", isPrimaryKey: false)], + "orders": [TestFixtures.makeColumnInfo(name: "total", dataType: "DECIMAL", isPrimaryKey: false)] + ] + + let provider = SQLSchemaProvider() + await provider.loadSchema(using: driver, connection: TestFixtures.makeConnection()) + await populateCache(provider: provider, driver: driver) + + let items = await provider.allColumnsFromCachedTables() + #expect(!items.isEmpty) + for item in items { + #expect(item.sortPriority == 150) + } + } + + @Test("Fallback items have column kind") + func fallbackItemsAreColumns() async { + let driver = MockFallbackDriver() + driver.tablesToReturn = [TestFixtures.makeTableInfo(name: "users")] + driver.columnsPerTable = [ + "users": [ + TestFixtures.makeColumnInfo(name: "id"), + TestFixtures.makeColumnInfo(name: "name", dataType: "VARCHAR", isPrimaryKey: false) + ] + ] + + let provider = SQLSchemaProvider() + await provider.loadSchema(using: driver, connection: TestFixtures.makeConnection()) + await populateCache(provider: provider, driver: driver) + + let items = await provider.allColumnsFromCachedTables() + #expect(!items.isEmpty) + for item in items { + #expect(item.kind == .column) + } + } + + @Test("Mixed ambiguous and unique columns labelled correctly") + func mixedAmbiguousAndUnique() async { + let driver = MockFallbackDriver() + driver.tablesToReturn = [ + TestFixtures.makeTableInfo(name: "users"), + TestFixtures.makeTableInfo(name: "orders") + ] + driver.columnsPerTable = [ + "users": [ + TestFixtures.makeColumnInfo(name: "id"), + TestFixtures.makeColumnInfo(name: "name", dataType: "VARCHAR", isPrimaryKey: false) + ], + "orders": [ + TestFixtures.makeColumnInfo(name: "id"), + TestFixtures.makeColumnInfo(name: "total", dataType: "DECIMAL", isPrimaryKey: false) + ] + ] + + let provider = SQLSchemaProvider() + await provider.loadSchema(using: driver, connection: TestFixtures.makeConnection()) + await populateCache(provider: provider, driver: driver) + + let items = await provider.allColumnsFromCachedTables() + #expect(items.count == 4) + + let labels = Set(items.map(\.label)) + // "id" is ambiguous -> table-qualified + #expect(labels.contains("users.id")) + #expect(labels.contains("orders.id")) + // "name" and "total" are unique -> plain + #expect(labels.contains("name")) + #expect(labels.contains("total")) + } + + @Test("Column name deduplication is case insensitive") + func caseInsensitiveDedup() async { + let driver = MockFallbackDriver() + driver.tablesToReturn = [ + TestFixtures.makeTableInfo(name: "users"), + TestFixtures.makeTableInfo(name: "orders") + ] + driver.columnsPerTable = [ + "users": [TestFixtures.makeColumnInfo(name: "ID")], + "orders": [TestFixtures.makeColumnInfo(name: "id")] + ] + + let provider = SQLSchemaProvider() + await provider.loadSchema(using: driver, connection: TestFixtures.makeConnection()) + await populateCache(provider: provider, driver: driver) + + let items = await provider.allColumnsFromCachedTables() + #expect(items.count == 2) + + // Both should be table-qualified since "ID" and "id" collide case-insensitively + let labels = Set(items.map(\.label)) + #expect(labels.contains("users.ID")) + #expect(labels.contains("orders.id")) + } + + @Test("Fallback items preserve column metadata in detail string") + func fallbackItemsPreserveMetadata() async { + let driver = MockFallbackDriver() + driver.tablesToReturn = [TestFixtures.makeTableInfo(name: "users")] + driver.columnsPerTable = [ + "users": [ + TestFixtures.makeColumnInfo(name: "id", dataType: "INT", isNullable: false, isPrimaryKey: true) + ] + ] + + let provider = SQLSchemaProvider() + await provider.loadSchema(using: driver, connection: TestFixtures.makeConnection()) + await populateCache(provider: provider, driver: driver) + + let items = await provider.allColumnsFromCachedTables() + #expect(items.count == 1) + + let item = items[0] + // detail should contain PK, NOT NULL, and INT + #expect(item.detail?.contains("PK") == true) + #expect(item.detail?.contains("NOT NULL") == true) + #expect(item.detail?.contains("INT") == true) + } + + // MARK: - Eager Column Loading + + @Test("resetForDatabase triggers eager column load via fetchAllColumns") + func resetForDatabaseTriggersEagerLoad() async throws { + let driver = MockFallbackDriver() + driver.tablesToReturn = [ + TestFixtures.makeTableInfo(name: "users"), + TestFixtures.makeTableInfo(name: "orders") + ] + driver.columnsPerTable = [ + "users": [TestFixtures.makeColumnInfo(name: "id")], + "orders": [TestFixtures.makeColumnInfo(name: "order_id")] + ] + + let provider = SQLSchemaProvider() + await provider.resetForDatabase( + "testdb", + tables: driver.tablesToReturn, + driver: driver + ) + + // Wait for the background eager load task to complete + try await Task.sleep(nanoseconds: 300_000_000) + + // Eager load should have called fetchAllColumns + #expect(driver.fetchAllColumnsCallCount >= 1) + + // The cache should now be populated -- getColumns should NOT trigger fetchColumns + let fetchCountBefore = driver.fetchColumnsCallCount + let columns = await provider.getColumns(for: "users") + #expect(!columns.isEmpty) + #expect(driver.fetchColumnsCallCount == fetchCountBefore) + } + + @Test("Eager load does not overwrite manually cached columns") + func eagerLoadDoesNotOverwriteCache() async throws { + let driver = MockFallbackDriver() + driver.tablesToReturn = [ + TestFixtures.makeTableInfo(name: "users"), + TestFixtures.makeTableInfo(name: "orders") + ] + driver.columnsPerTable = [ + "users": [ + TestFixtures.makeColumnInfo(name: "id"), + TestFixtures.makeColumnInfo(name: "email", dataType: "VARCHAR", isPrimaryKey: false) + ], + "orders": [TestFixtures.makeColumnInfo(name: "order_id")] + ] + + let provider = SQLSchemaProvider() + await provider.loadSchema(using: driver, connection: TestFixtures.makeConnection()) + + // Manually cache "users" columns via getColumns + let manualColumns = await provider.getColumns(for: "users") + #expect(manualColumns.count == 2) + + // Now trigger eager load -- it should NOT overwrite "users" cache entry + await provider.resetForDatabase("testdb", tables: driver.tablesToReturn, driver: driver) + try await Task.sleep(nanoseconds: 300_000_000) + + // "users" should still be the original cached version + let cachedColumns = await provider.getColumns(for: "users") + #expect(cachedColumns.count == 2) + } + + @Test("Eager load respects maxCachedTables limit") + func eagerLoadRespectsMaxCachedTables() async throws { + let driver = MockFallbackDriver() + var tables: [TableInfo] = [] + for i in 0..<60 { + let name = "table_\(i)" + tables.append(TestFixtures.makeTableInfo(name: name)) + driver.columnsPerTable[name] = [ + TestFixtures.makeColumnInfo(name: "col_\(i)", isPrimaryKey: false) + ] + } + driver.tablesToReturn = tables + + let provider = SQLSchemaProvider() + await provider.resetForDatabase("testdb", tables: tables, driver: driver) + + // Wait for the eager load task + try await Task.sleep(nanoseconds: 300_000_000) + + // allColumnsFromCachedTables should return at most 50 tables worth of columns + let items = await provider.allColumnsFromCachedTables() + #expect(items.count <= 50) + } + + @Test("allColumnsFromCachedTables uses canonical table names from tables list") + func canonicalTableNames() async { + let driver = MockFallbackDriver() + // Table list has mixed-case name + driver.tablesToReturn = [TestFixtures.makeTableInfo(name: "Users")] + // Column cache stores under lowercased key via getColumns + driver.columnsPerTable = [ + "users": [TestFixtures.makeColumnInfo(name: "name", dataType: "VARCHAR", isPrimaryKey: false)] + ] + + let provider = SQLSchemaProvider() + await provider.loadSchema(using: driver, connection: TestFixtures.makeConnection()) + // getColumns lowercases the key, but the canonical name should be "Users" + _ = await provider.getColumns(for: "Users") + + let items = await provider.allColumnsFromCachedTables() + #expect(items.count == 1) + // Since only one table, label should be plain + #expect(items[0].label == "name") + // Documentation should reference the canonical table name "Users" + #expect(items[0].documentation?.contains("Users") == true) + } +} From 5c7df313f5646f539767693e20e886ad6fcfabff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ng=C3=B4=20Qu=E1=BB=91c=20=C4=90=E1=BA=A1t?= Date: Thu, 23 Apr 2026 13:24:09 +0700 Subject: [PATCH 3/4] docs: update autocomplete docs for column fallback and eager loading --- docs/features/autocomplete.mdx | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/docs/features/autocomplete.mdx b/docs/features/autocomplete.mdx index 68a8d431b..d880b222a 100644 --- a/docs/features/autocomplete.mdx +++ b/docs/features/autocomplete.mdx @@ -75,14 +75,17 @@ SELECT * FROM users JOIN | -- Suggests all tables ### Column Names -Columns are suggested based on referenced tables: +Columns are suggested based on referenced tables. When a FROM clause exists (even after the cursor), columns from those tables appear. When no FROM clause exists yet, columns from all cached schema tables are suggested as a fallback. ```sql +SELECT na| -- Suggests columns matching "na" from all cached tables SELECT | FROM users -- Suggests columns from users table SELECT u.| FROM users u -- Suggests columns with "u." prefix SELECT * FROM users WHERE | -- Suggests columns from users ``` +When multiple tables have a column with the same name, the fallback shows qualified names (e.g. `users.id`, `orders.id`) to disambiguate. + #### After Table Alias Typing an alias followed by `.` shows that table's columns: @@ -147,8 +150,10 @@ Suggestions adapt to SQL context: after SELECT, shows columns and aggregate func ## Schema Caching -On connection, TablePro fetches and caches all table and column information. If you make schema changes externally, right-click the connection and select **Refresh**, or disconnect and reconnect. +On connection, TablePro fetches all table names and eagerly loads column information for up to 50 tables in the background. This means column suggestions are available immediately, even before you write a FROM clause. If you make schema changes externally, right-click the connection and select **Refresh**, or disconnect and reconnect. + +Schema-qualified table names (`public.users`, `schema.table`) are resolved correctly in all contexts: FROM, JOIN, UPDATE, INSERT INTO, and CREATE INDEX. ## Performance -Autocomplete works on files of any size, including multi-megabyte dumps. For files >500 KB, it analyzes only a ~10 KB window around the cursor. For large schemas (100+ tables), initial loading may take a moment, but remains responsive after caching. Use aliases to narrow suggestions faster and let the cache work. +Autocomplete works on files of any size, including multi-megabyte dumps. For files >500 KB, it analyzes only a ~10 KB window around the cursor. For large schemas (100+ tables), initial loading may take a moment, but remains responsive after caching. The column cache holds up to 50 tables and evicts least-recently-used entries. Use aliases to narrow suggestions faster and let the cache work. From 57076faa0f2e03a83e32f10d539203b2b6001d6f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ng=C3=B4=20Qu=E1=BB=91c=20=C4=90=E1=BA=A1t?= Date: Thu, 23 Apr 2026 13:25:33 +0700 Subject: [PATCH 4/4] docs: rewrite autocomplete docs for clarity --- docs/features/autocomplete.mdx | 72 ++++++++++++++++++++-------------- 1 file changed, 42 insertions(+), 30 deletions(-) diff --git a/docs/features/autocomplete.mdx b/docs/features/autocomplete.mdx index d880b222a..394065522 100644 --- a/docs/features/autocomplete.mdx +++ b/docs/features/autocomplete.mdx @@ -5,7 +5,7 @@ description: Schema-aware SQL autocomplete with alias resolution, 50ms debounce, # SQL Autocomplete -Autocomplete analyzes your cursor position, the SQL syntax context, referenced tables and aliases, and your database schema to suggest only relevant completions at each point in the query. +Autocomplete suggests keywords, tables, columns, and functions based on where your cursor is and what tables are in scope. {/* Screenshot: Autocomplete popup showing mixed suggestions */} @@ -21,18 +21,18 @@ Autocomplete analyzes your cursor position, the SQL syntax context, referenced t /> -Suggestions appear after 2 characters, after `.` for column access, or after keywords like SELECT/FROM/JOIN. 50ms debounce keeps typing smooth. Press `Escape` to dismiss. +Suggestions appear after 2 characters, after `.`, or after keywords like SELECT/FROM/JOIN. 50ms debounce. Press `Escape` to dismiss. ## Completion Types ### SQL Keywords -Keywords are suggested based on SQL syntax context: +Context-aware keyword suggestions: ```sql -SEL| -- Suggests: SELECT -FROM users WH| -- Suggests: WHERE -SELECT * FROM users WHERE name LIKE '%test%' ORD| -- Suggests: ORDER +SEL| -- SELECT +FROM users WH| -- WHERE +SELECT * FROM users WHERE name LIKE '%test%' ORD| -- ORDER BY ``` {/* Screenshot: Keyword suggestions */} @@ -51,12 +51,12 @@ SELECT * FROM users WHERE name LIKE '%test%' ORD| -- Suggests: ORDER ### Table Names -Tables are suggested after keywords that expect table references: +Tables appear after FROM, JOIN, INSERT INTO, and similar keywords: ```sql -SELECT * FROM | -- Suggests all tables -SELECT * FROM us| -- Suggests tables starting with "us": users, user_roles -SELECT * FROM users JOIN | -- Suggests all tables +SELECT * FROM | -- All tables +SELECT * FROM us| -- Tables starting with "us": users, user_roles +SELECT * FROM users JOIN | -- All tables ``` {/* Screenshot: Table name suggestions */} @@ -75,26 +75,28 @@ SELECT * FROM users JOIN | -- Suggests all tables ### Column Names -Columns are suggested based on referenced tables. When a FROM clause exists (even after the cursor), columns from those tables appear. When no FROM clause exists yet, columns from all cached schema tables are suggested as a fallback. +Columns are suggested in SELECT, WHERE, ORDER BY, GROUP BY, and other column contexts. + +If a FROM clause exists anywhere in the statement (even after the cursor), columns come from those tables. If no FROM clause exists yet, columns from all cached tables appear as fallback. ```sql -SELECT na| -- Suggests columns matching "na" from all cached tables -SELECT | FROM users -- Suggests columns from users table -SELECT u.| FROM users u -- Suggests columns with "u." prefix -SELECT * FROM users WHERE | -- Suggests columns from users +SELECT na| -- Columns matching "na" from all cached tables +SELECT | FROM users -- Columns from users +SELECT u.| FROM users u -- Columns from users via alias +SELECT * FROM users WHERE | -- Columns from users ``` -When multiple tables have a column with the same name, the fallback shows qualified names (e.g. `users.id`, `orders.id`) to disambiguate. +If the same column name exists in multiple tables, the fallback qualifies them: `users.id`, `orders.id`. -#### After Table Alias +#### Alias Resolution -Typing an alias followed by `.` shows that table's columns: +Type an alias followed by `.` to see that table's columns: ```sql SELECT - u.| -- Shows: id, name, email, created_at (from users) + u.| -- id, name, email, created_at (from users) FROM users u -JOIN orders o ON u.id = o.| -- Shows: id, user_id, total (from orders) +JOIN orders o ON u.id = o.| -- id, user_id, total (from orders) ``` {/* Screenshot: Column suggestions after alias */} @@ -113,12 +115,12 @@ JOIN orders o ON u.id = o.| -- Shows: id, user_id, total (from orders) ### Functions -SQL functions are suggested in appropriate contexts: +SQL functions appear in SELECT, WHERE, and expression contexts: ```sql -SELECT | -- Suggests: COUNT, SUM, AVG, MAX, MIN, etc. -SELECT COUNT(| -- Suggests columns and * -WHERE date_column > | -- Suggests: NOW(), CURRENT_DATE, etc. +SELECT | -- COUNT, SUM, AVG, MAX, MIN, etc. +SELECT COUNT(| -- Columns and * +WHERE date_column > | -- NOW(), CURRENT_DATE, etc. ``` {/* Screenshot: SQL function suggestions */} @@ -140,20 +142,30 @@ WHERE date_column > | -- Suggests: NOW(), CURRENT_DATE, etc. For databases with multiple schemas (PostgreSQL): ```sql -SELECT * FROM | -- Suggests: public, schema1, schema2 -SELECT * FROM public.| -- Suggests tables in public schema +SELECT * FROM | -- public, schema1, schema2 +SELECT * FROM public.| -- Tables in public schema ``` +Schema-qualified names like `public.users` resolve correctly in FROM, JOIN, UPDATE, INSERT INTO, and CREATE INDEX. + ## Context-Aware Suggestions -Suggestions adapt to SQL context: after SELECT, shows columns and aggregate functions; after FROM/JOIN, shows table names; after WHERE, shows columns and operators; after ON (JOIN), shows columns from both tables; after GROUP BY/ORDER BY, shows relevant columns and modifiers. +What appears depends on where your cursor is: + +| Context | Suggestions | +|---------|-------------| +| After SELECT | Columns, `*`, aggregate functions | +| After FROM / JOIN | Table names | +| After WHERE | Columns, operators, AND/OR | +| After ON (JOIN) | Columns from both tables | +| After GROUP BY / ORDER BY | Columns, ASC/DESC | ## Schema Caching -On connection, TablePro fetches all table names and eagerly loads column information for up to 50 tables in the background. This means column suggestions are available immediately, even before you write a FROM clause. If you make schema changes externally, right-click the connection and select **Refresh**, or disconnect and reconnect. +On connection, TablePro fetches table names and loads columns for up to 50 tables in the background. Column suggestions work right away, even before you write a FROM clause. -Schema-qualified table names (`public.users`, `schema.table`) are resolved correctly in all contexts: FROM, JOIN, UPDATE, INSERT INTO, and CREATE INDEX. +After external schema changes, right-click the connection and select **Refresh**. ## Performance -Autocomplete works on files of any size, including multi-megabyte dumps. For files >500 KB, it analyzes only a ~10 KB window around the cursor. For large schemas (100+ tables), initial loading may take a moment, but remains responsive after caching. The column cache holds up to 50 tables and evicts least-recently-used entries. Use aliases to narrow suggestions faster and let the cache work. +Works on files of any size, including multi-megabyte dumps. For files over 500 KB, only a ~10 KB window around the cursor is analyzed. The column cache holds up to 50 tables with LRU eviction.