From 2cad9d004c6503d6313d22e4c2745a5e8ce31549 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Wed, 17 Jun 2026 23:54:16 +0700 Subject: [PATCH 1/3] fix(editor): suggest columns for derived-table and CTE aliases in autocomplete --- CHANGELOG.md | 1 + .../Autocomplete/DerivedTableParser.swift | 448 ++++++++++++++++++ .../Autocomplete/SQLCompletionProvider.swift | 5 + .../Autocomplete/SQLContextAnalyzer.swift | 64 ++- .../Core/Autocomplete/SQLSchemaProvider.swift | 14 +- .../Autocomplete/CompletionEngineTests.swift | 34 ++ .../DerivedTableParserTests.swift | 139 ++++++ .../SQLContextAnalyzerTests.swift | 21 + docs/features/autocomplete.mdx | 21 + 9 files changed, 737 insertions(+), 10 deletions(-) create mode 100644 TablePro/Core/Autocomplete/DerivedTableParser.swift create mode 100644 TableProTests/Core/Autocomplete/DerivedTableParserTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 99c066f2d..6023b3c5f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- SQL autocomplete now suggests columns for a derived-table or CTE alias, reading the subquery's output columns. Before, typing `alias.` for a `JOIN (SELECT ...) alias` or a `WITH` table returned nothing. (#1697) - Redis entries no longer disappear after the connection sits idle. The health check was running `SELECT 1`, which on Redis switches the active database, so a later refresh scanned the wrong database. (#1701) - Redis key browsing now lists every key in a database or namespace and pages through them correctly. It was reading only the first SCAN batch, so large keyspaces showed a partial, fixed set of keys. (#1701) - A dropped Redis connection now reconnects on the next command and replays auth and the selected database, instead of failing until the next health check. (#1701) diff --git a/TablePro/Core/Autocomplete/DerivedTableParser.swift b/TablePro/Core/Autocomplete/DerivedTableParser.swift new file mode 100644 index 000000000..d2605b91f --- /dev/null +++ b/TablePro/Core/Autocomplete/DerivedTableParser.swift @@ -0,0 +1,448 @@ +// +// DerivedTableParser.swift +// TablePro +// +// Parses derived-table subqueries and CTEs into their output column names so +// autocomplete can resolve `alias.` against a subquery's SELECT list. +// + +import Foundation + +/// A derived table (FROM/JOIN subquery or CTE) and the column names its SELECT +/// list produces. Columns come from the query text, not the live schema. +internal struct DerivedTable: Equatable, Sendable { + let alias: String + let columns: [String] +} + +/// Scans a SQL statement once and extracts every referenceable derived table: +/// FROM/JOIN subqueries with an alias, and CTEs defined in a WITH clause. The +/// scan is paren-, string-, and comment-aware and uses O(1) NSString access. +internal struct DerivedTableParser { + private static let space: unichar = 0x20 + private static let tab: unichar = 0x09 + private static let newline: unichar = 0x0A + private static let cr: unichar = 0x0D + private static let openParen: unichar = 0x28 + private static let closeParen: unichar = 0x29 + private static let comma: unichar = 0x2C + private static let dot: unichar = 0x2E + private static let hyphen: unichar = 0x2D + private static let slash: unichar = 0x2F + private static let star: unichar = 0x2A + private static let underscore: unichar = 0x5F + private static let singleQuote: unichar = 0x27 + private static let doubleQuote: unichar = 0x22 + private static let backtick: unichar = 0x60 + private static let openBracket: unichar = 0x5B + private static let closeBracket: unichar = 0x5D + + private static let clauseKeywords: Set = [ + "FROM", "WHERE", "GROUP", "HAVING", "ORDER", "LIMIT", "OFFSET", + "UNION", "INTERSECT", "EXCEPT", "WINDOW", "FETCH", "INTO", "FOR" + ] + + private static let nonAliasKeywords: Set = [ + "ON", "USING", "WHERE", "GROUP", "ORDER", "HAVING", "LIMIT", "OFFSET", + "UNION", "INTERSECT", "EXCEPT", "JOIN", "INNER", "LEFT", "RIGHT", + "FULL", "CROSS", "NATURAL", "AS", "SET", "RETURNING", "WINDOW" + ] + + func parse(_ statement: String) -> [DerivedTable] { + let ns = statement as NSString + let length = ns.length + var results: [DerivedTable] = [] + var seen = Set() + var i = 0 + while i < length { + if let skipped = skipNonCode(in: ns, at: i, limit: length) { + i = skipped + continue + } + if ns.character(at: i) == Self.openParen, innerStartsWithSelect(in: ns, openIdx: i, limit: length) { + let close = matchingParen(in: ns, openAt: i, limit: length) + let preceding = precedingToken(in: ns, before: i) + if let table = classify(in: ns, preceding: preceding, open: i, close: close, limit: length), + seen.insert(table.alias.lowercased()).inserted { + results.append(table) + } + i = close + 1 + continue + } + i += 1 + } + return results + } + + // MARK: - Classification + + private func classify( + in ns: NSString, preceding: (text: String, start: Int), + open: Int, close: Int, limit: Int + ) -> DerivedTable? { + let kind = preceding.text.uppercased() + if kind == "AS" { + return parseCTE(in: ns, asStart: preceding.start, subOpen: open, subClose: close) + } + if kind == "FROM" || kind == "JOIN" || preceding.text == "," { + return parseDerived(in: ns, subOpen: open, subClose: close, limit: limit) + } + return nil + } + + private func parseDerived(in ns: NSString, subOpen: Int, subClose: Int, limit: Int) -> DerivedTable? { + let columns = selectListColumns(in: innerText(in: ns, open: subOpen, close: subClose)) + var cursor = firstCode(in: ns, from: subClose + 1, limit: limit) + guard cursor < limit else { return nil } + if let word = readWord(in: ns, at: cursor, limit: limit), word.text.uppercased() == "AS" { + cursor = firstCode(in: ns, from: word.end, limit: limit) + } + guard cursor < limit, let alias = readAliasForward(in: ns, at: cursor, limit: limit) else { return nil } + guard !Self.nonAliasKeywords.contains(alias.uppercased()) else { return nil } + return DerivedTable(alias: alias, columns: columns) + } + + private func parseCTE(in ns: NSString, asStart: Int, subOpen: Int, subClose: Int) -> DerivedTable? { + var explicitColumns: [String]? + var nameEnd = lastCodeIndex(in: ns, before: asStart) + guard nameEnd >= 0 else { return nil } + if ns.character(at: nameEnd) == Self.closeParen { + let openColumn = matchingParenBackward(in: ns, closeAt: nameEnd) + guard openColumn >= 0 else { return nil } + explicitColumns = columnListNames(in: ns, open: openColumn, close: nameEnd) + nameEnd = lastCodeIndex(in: ns, before: openColumn) + guard nameEnd >= 0 else { return nil } + } + guard let name = readAliasBackward(in: ns, endingAt: nameEnd) else { return nil } + let columns = explicitColumns ?? selectListColumns(in: innerText(in: ns, open: subOpen, close: subClose)) + return DerivedTable(alias: name, columns: columns) + } + + // MARK: - SELECT list + + private func selectListColumns(in inner: String) -> [String] { + let ns = inner as NSString + let length = ns.length + guard let select = topLevelKeyword(in: ns, start: 0, end: length, keywords: ["SELECT"]), + select.word.uppercased() == "SELECT" else { + return [] + } + var listStart = firstCode(in: ns, from: select.end, limit: length) + if let modifier = readWord(in: ns, at: listStart, limit: length), + modifier.text.uppercased() == "DISTINCT" || modifier.text.uppercased() == "ALL" { + listStart = firstCode(in: ns, from: modifier.end, limit: length) + if let on = readWord(in: ns, at: listStart, limit: length), on.text.uppercased() == "ON" { + let parenStart = firstCode(in: ns, from: on.end, limit: length) + if parenStart < length, ns.character(at: parenStart) == Self.openParen { + listStart = matchingParen(in: ns, openAt: parenStart, limit: length) + 1 + } + } + } + let listEnd = topLevelKeyword(in: ns, start: listStart, end: length, keywords: Self.clauseKeywords)?.start ?? length + guard listStart < listEnd else { return [] } + + var names: [String] = [] + var seen = Set() + for item in topLevelItems(in: ns, start: listStart, end: listEnd) { + guard let name = deriveColumnName(item), seen.insert(name.lowercased()).inserted else { continue } + names.append(name) + } + return names + } + + private func deriveColumnName(_ rawItem: String) -> String? { + let trimmed = rawItem.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return nil } + let ns = trimmed as NSString + let length = ns.length + if let asKeyword = topLevelKeyword(in: ns, start: 0, end: length, keywords: ["AS"]) { + let aliasStart = firstCode(in: ns, from: asKeyword.end, limit: length) + guard aliasStart < length else { return nil } + return readAliasForward(in: ns, at: aliasStart, limit: length) + } + return plainColumnName(trimmed) + } + + private func plainColumnName(_ item: String) -> String? { + guard !item.contains("*") else { return nil } + let allowed = CharacterSet(charactersIn: + "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_.`\"[]") + guard item.unicodeScalars.allSatisfy(allowed.contains) else { return nil } + let segments = item.split(separator: ".") + guard let last = segments.last else { return nil } + let cleaned = String(last).trimmingCharacters(in: CharacterSet(charactersIn: "`\"[]")) + guard !cleaned.isEmpty, cleaned.unicodeScalars.allSatisfy({ isIdentifier($0) }) else { return nil } + return cleaned + } + + private func isIdentifier(_ scalar: Unicode.Scalar) -> Bool { + CharacterSet.alphanumerics.contains(scalar) || scalar == "_" + } + + // MARK: - Token scanning + + private func topLevelKeyword( + in ns: NSString, start: Int, end: Int, keywords: Set + ) -> (word: String, start: Int, end: Int)? { + var i = start + var depth = 0 + while i < end { + if let skipped = skipNonCode(in: ns, at: i, limit: end) { + i = skipped + continue + } + let c = ns.character(at: i) + if c == Self.openParen { + depth += 1 + i += 1 + continue + } + if c == Self.closeParen { + depth -= 1 + i += 1 + continue + } + if depth == 0, isWordStart(c) { + var j = i + 1 + while j < end, isWordChar(ns.character(at: j)) { j += 1 } + let word = ns.substring(with: NSRange(location: i, length: j - i)) + if keywords.contains(word.uppercased()) { + return (word, i, j) + } + i = j + continue + } + i += 1 + } + return nil + } + + private func topLevelItems(in ns: NSString, start: Int, end: Int) -> [String] { + var items: [String] = [] + var depth = 0 + var itemStart = start + var i = start + while i < end { + if let skipped = skipNonCode(in: ns, at: i, limit: end) { + i = skipped + continue + } + let c = ns.character(at: i) + if c == Self.openParen { + depth += 1 + } else if c == Self.closeParen { + depth -= 1 + } else if c == Self.comma, depth == 0 { + items.append(ns.substring(with: NSRange(location: itemStart, length: i - itemStart))) + itemStart = i + 1 + } + i += 1 + } + items.append(ns.substring(with: NSRange(location: itemStart, length: end - itemStart))) + return items + } + + private func readWord(in ns: NSString, at index: Int, limit: Int) -> (text: String, end: Int)? { + guard index < limit, isWordStart(ns.character(at: index)) else { return nil } + var j = index + 1 + while j < limit, isWordChar(ns.character(at: j)) { j += 1 } + return (ns.substring(with: NSRange(location: index, length: j - index)), j) + } + + private func readAliasForward(in ns: NSString, at index: Int, limit: Int) -> String? { + let c = ns.character(at: index) + if c == Self.backtick || c == Self.doubleQuote { + let end = matchingQuote(in: ns, from: index, limit: limit, quote: c) + guard end > index + 1 else { return nil } + return ns.substring(with: NSRange(location: index + 1, length: end - index - 1)) + } + if c == Self.openBracket { + var j = index + 1 + while j < limit, ns.character(at: j) != Self.closeBracket { j += 1 } + guard j > index + 1 else { return nil } + return ns.substring(with: NSRange(location: index + 1, length: j - index - 1)) + } + guard let word = readWord(in: ns, at: index, limit: limit) else { return nil } + return word.text + } + + private func readAliasBackward(in ns: NSString, endingAt end: Int) -> String? { + let c = ns.character(at: end) + if c == Self.backtick || c == Self.doubleQuote { + var i = end - 1 + while i >= 0, ns.character(at: i) != c { i -= 1 } + guard i >= 0, end - i - 1 > 0 else { return nil } + return ns.substring(with: NSRange(location: i + 1, length: end - i - 1)) + } + if c == Self.closeBracket { + var i = end - 1 + while i >= 0, ns.character(at: i) != Self.openBracket { i -= 1 } + guard i >= 0, end - i - 1 > 0 else { return nil } + return ns.substring(with: NSRange(location: i + 1, length: end - i - 1)) + } + guard isWordChar(c) else { return nil } + var i = end + while i >= 0, isWordChar(ns.character(at: i)) { i -= 1 } + return ns.substring(with: NSRange(location: i + 1, length: end - i)) + } + + private func columnListNames(in ns: NSString, open: Int, close: Int) -> [String] { + var names: [String] = [] + for entry in topLevelItems(in: ns, start: open + 1, end: close) { + let cleaned = entry.trimmingCharacters(in: .whitespacesAndNewlines) + .trimmingCharacters(in: CharacterSet(charactersIn: "`\"[]")) + if !cleaned.isEmpty { + names.append(cleaned) + } + } + return names + } + + // MARK: - Structural helpers + + private func innerText(in ns: NSString, open: Int, close: Int) -> String { + guard close > open + 1 else { return "" } + return ns.substring(with: NSRange(location: open + 1, length: close - open - 1)) + } + + private func innerStartsWithSelect(in ns: NSString, openIdx: Int, limit: Int) -> Bool { + let start = firstCode(in: ns, from: openIdx + 1, limit: limit) + guard let word = readWord(in: ns, at: start, limit: limit) else { return false } + return word.text.uppercased() == "SELECT" + } + + private func precedingToken(in ns: NSString, before index: Int) -> (text: String, start: Int) { + var i = index - 1 + while i >= 0, isWhitespace(ns.character(at: i)) { i -= 1 } + guard i >= 0 else { return ("", 0) } + let c = ns.character(at: i) + if c == Self.comma { return (",", i) } + if c == Self.closeParen { return (")", i) } + guard isWordChar(c) else { return (String(utf16CodeUnits: [c], count: 1), i) } + var start = i + while start >= 0, isWordChar(ns.character(at: start)) { start -= 1 } + start += 1 + return (ns.substring(with: NSRange(location: start, length: i - start + 1)), start) + } + + private func matchingParen(in ns: NSString, openAt: Int, limit: Int) -> Int { + var depth = 0 + var i = openAt + while i < limit { + if let skipped = skipNonCode(in: ns, at: i, limit: limit) { + i = skipped + continue + } + let c = ns.character(at: i) + if c == Self.openParen { + depth += 1 + } else if c == Self.closeParen { + depth -= 1 + if depth == 0 { return i } + } + i += 1 + } + return limit + } + + private func matchingParenBackward(in ns: NSString, closeAt: Int) -> Int { + var depth = 0 + var i = closeAt + while i >= 0 { + let c = ns.character(at: i) + if c == Self.closeParen { + depth += 1 + } else if c == Self.openParen { + depth -= 1 + if depth == 0 { return i } + } + i -= 1 + } + return -1 + } + + private func firstCode(in ns: NSString, from index: Int, limit: Int) -> Int { + var i = index + while i < limit { + let c = ns.character(at: i) + if isWhitespace(c) { + i += 1 + continue + } + if c == Self.hyphen, i + 1 < limit, ns.character(at: i + 1) == Self.hyphen { + i = skipLineComment(in: ns, from: i, limit: limit) + continue + } + if c == Self.slash, i + 1 < limit, ns.character(at: i + 1) == Self.star { + i = skipBlockComment(in: ns, from: i, limit: limit) + continue + } + break + } + return i + } + + private func lastCodeIndex(in ns: NSString, before index: Int) -> Int { + var i = index - 1 + while i >= 0, isWhitespace(ns.character(at: i)) { i -= 1 } + return i + } + + private func skipNonCode(in ns: NSString, at i: Int, limit: Int) -> Int? { + let c = ns.character(at: i) + if c == Self.singleQuote || c == Self.doubleQuote || c == Self.backtick { + return matchingQuote(in: ns, from: i, limit: limit, quote: c) + } + if c == Self.hyphen, i + 1 < limit, ns.character(at: i + 1) == Self.hyphen { + return skipLineComment(in: ns, from: i, limit: limit) + } + if c == Self.slash, i + 1 < limit, ns.character(at: i + 1) == Self.star { + return skipBlockComment(in: ns, from: i, limit: limit) + } + return nil + } + + private func matchingQuote(in ns: NSString, from index: Int, limit: Int, quote: unichar) -> Int { + var i = index + 1 + while i < limit { + if ns.character(at: i) == quote { + if i + 1 < limit, ns.character(at: i + 1) == quote { + i += 2 + continue + } + return i + 1 + } + i += 1 + } + return limit + } + + private func skipLineComment(in ns: NSString, from index: Int, limit: Int) -> Int { + var i = index + 2 + while i < limit, ns.character(at: i) != Self.newline { i += 1 } + return i + } + + private func skipBlockComment(in ns: NSString, from index: Int, limit: Int) -> Int { + var i = index + 2 + while i + 1 < limit { + if ns.character(at: i) == Self.star, ns.character(at: i + 1) == Self.slash { + return i + 2 + } + i += 1 + } + return limit + } + + private func isWhitespace(_ c: unichar) -> Bool { + c == Self.space || c == Self.tab || c == Self.newline || c == Self.cr + } + + private func isWordStart(_ c: unichar) -> Bool { + (c >= 0x41 && c <= 0x5A) || (c >= 0x61 && c <= 0x7A) || c == Self.underscore + } + + private func isWordChar(_ c: unichar) -> Bool { + isWordStart(c) || (c >= 0x30 && c <= 0x39) + } +} diff --git a/TablePro/Core/Autocomplete/SQLCompletionProvider.swift b/TablePro/Core/Autocomplete/SQLCompletionProvider.swift index 79c425c6c..40dfe0060 100644 --- a/TablePro/Core/Autocomplete/SQLCompletionProvider.swift +++ b/TablePro/Core/Autocomplete/SQLCompletionProvider.swift @@ -134,6 +134,11 @@ final class SQLCompletionProvider { // schema name parsed out of the FROM clause itself. if let dotPrefix = context.dotPrefix { guard let schemaProvider else { return [] } + if let derived = context.tableReferences.first(where: { + $0.isDerived && $0.identifier.caseInsensitiveCompare(dotPrefix) == .orderedSame + }), let columns = derived.derivedColumns, !columns.isEmpty { + return columns.map { SQLCompletionItem.column($0, dataType: nil, tableName: derived.identifier) } + } if let tableName = await schemaProvider.resolveAlias(dotPrefix, in: context.tableReferences) { let schema = context.tableReferences.first { $0.tableName.caseInsensitiveCompare(tableName) == .orderedSame diff --git a/TablePro/Core/Autocomplete/SQLContextAnalyzer.swift b/TablePro/Core/Autocomplete/SQLContextAnalyzer.swift index b6734dee5..938590e2a 100644 --- a/TablePro/Core/Autocomplete/SQLContextAnalyzer.swift +++ b/TablePro/Core/Autocomplete/SQLContextAnalyzer.swift @@ -56,17 +56,25 @@ internal struct TableReference: Hashable, Sendable { let tableName: String let alias: String? let schema: String? + let derivedColumns: [String]? - init(tableName: String, alias: String?, schema: String? = nil) { + init(tableName: String, alias: String?, schema: String? = nil, derivedColumns: [String]? = nil) { self.tableName = tableName self.alias = alias self.schema = schema + self.derivedColumns = derivedColumns } /// Returns the identifier that should be used to reference this table var identifier: String { alias ?? tableName } + + /// A derived table (subquery or CTE) carries its own column list parsed from + /// the subquery's SELECT list, rather than resolving columns from the schema. + var isDerived: Bool { + derivedColumns != nil + } } /// Result of context analysis @@ -251,6 +259,8 @@ final class SQLContextAnalyzer { "|WINDOW|FETCH|FOR)\\b|[;()]|$)" ) + private static let derivedTableParser = DerivedTableParser() + // MARK: - UTF-16 Helpers /// Check if a UTF-16 code unit is whitespace (space, tab, newline, CR) @@ -307,18 +317,16 @@ final class SQLContextAnalyzer { // Find all table references in the current statement var tableReferences = extractTableReferences(from: currentStatement) - var seenReferences = Set(tableReferences) // Extract CTEs from the current statement let cteNames = extractCTENames(from: currentStatement) - // Add CTE names as table references - for cteName in cteNames { - let cteRef = TableReference(tableName: cteName, alias: nil) - if seenReferences.insert(cteRef).inserted { - tableReferences.append(cteRef) - } - } + // Resolve derived tables (FROM/JOIN subqueries and CTEs) to their + // SELECT-list columns so `alias.` completes the subquery's output. + let derivedTables = Self.derivedTableParser.parse(currentStatement) + mergeDerivedTables(derivedTables, cteNames: cteNames, into: &tableReferences) + + var seenReferences = Set(tableReferences) // Extract ALTER TABLE table name and add to references if let alterTableName = extractAlterTableName(from: currentStatement) { @@ -781,6 +789,44 @@ final class SQLContextAnalyzer { return references } + /// Attach derived-table columns to references and add any derived table or + /// CTE not already in scope. A derived alias wins over a plain reference of + /// the same identifier (e.g. a CTE used directly in a FROM clause). + private func mergeDerivedTables( + _ derivedTables: [DerivedTable], + cteNames: [String], + into references: inout [TableReference] + ) { + let derivedColumnsByAlias = Dictionary( + derivedTables.map { ($0.alias.lowercased(), $0.columns) }, + uniquingKeysWith: { first, _ in first } + ) + + var consumed = Set() + for index in references.indices { + let ref = references[index] + let tableKey = ref.tableName.lowercased() + let identifierKey = ref.identifier.lowercased() + let matched = derivedColumnsByAlias[tableKey].map { ($0, tableKey) } + ?? derivedColumnsByAlias[identifierKey].map { ($0, identifierKey) } + guard let (columns, key) = matched else { continue } + consumed.insert(key) + references[index] = TableReference( + tableName: ref.tableName, alias: ref.alias, schema: ref.schema, derivedColumns: columns + ) + } + + var present = Set(references.map { $0.identifier.lowercased() }).union(consumed) + for derived in derivedTables where present.insert(derived.alias.lowercased()).inserted { + references.append( + TableReference(tableName: derived.alias, alias: derived.alias, derivedColumns: derived.columns) + ) + } + for cteName in cteNames where present.insert(cteName.lowercased()).inserted { + references.append(TableReference(tableName: cteName, alias: nil)) + } + } + /// Parse each comma-separated entry in a FROM list into a table reference. private func appendFromListReferences( from query: String, diff --git a/TablePro/Core/Autocomplete/SQLSchemaProvider.swift b/TablePro/Core/Autocomplete/SQLSchemaProvider.swift index ec609ddbc..8cce9eb73 100644 --- a/TablePro/Core/Autocomplete/SQLSchemaProvider.swift +++ b/TablePro/Core/Autocomplete/SQLSchemaProvider.swift @@ -396,8 +396,20 @@ actor SQLSchemaProvider { let hasMultipleRefs = references.count > 1 for ref in references { - let columns = await getColumns(for: ref.tableName, schema: ref.schema) let refId = ref.identifier + if let derivedColumns = ref.derivedColumns { + for name in derivedColumns { + let label = hasMultipleRefs ? "\(refId).\(name)" : name + itemDataBuilder.append( + ( + label: label, insertText: label, type: "", + table: refId, isPK: false, isNullable: true, + defaultValue: nil, comment: nil + )) + } + continue + } + let columns = await getColumns(for: ref.tableName, schema: ref.schema) for column in columns { let label = hasMultipleRefs ? "\(refId).\(column.name)" : column.name let insertText = hasMultipleRefs ? "\(refId).\(column.name)" : column.name diff --git a/TableProTests/Core/Autocomplete/CompletionEngineTests.swift b/TableProTests/Core/Autocomplete/CompletionEngineTests.swift index a5583b63c..a6a1dd05e 100644 --- a/TableProTests/Core/Autocomplete/CompletionEngineTests.swift +++ b/TableProTests/Core/Autocomplete/CompletionEngineTests.swift @@ -166,4 +166,38 @@ struct CompletionEngineTests { } } } + + @Test("Derived-table alias suggests the subquery's output columns") + func testDerivedTableAliasCompletion() async { + let driver = MockDatabaseDriver() + driver.tablesToReturn = [TestFixtures.makeTableInfo(name: "happiness_scores")] + await schemaProvider.loadSchema(using: driver, connection: TestFixtures.makeConnection()) + + let prefix = "SELECT ahs." + let text = prefix + " FROM happiness_scores hs " + + "LEFT JOIN (SELECT country, AVG(score) AS avg_score FROM happiness_scores GROUP BY country) ahs " + + "ON hs.country = ahs.country" + let result = await engine.getCompletions(text: text, cursorPosition: (prefix as NSString).length) + + #expect(result != nil) + let labels = result?.items.map(\.label) ?? [] + #expect(labels.contains("country")) + #expect(labels.contains("avg_score")) + } + + @Test("CTE alias suggests the CTE's output columns") + func testCteAliasCompletion() async { + let driver = MockDatabaseDriver() + driver.tablesToReturn = [TestFixtures.makeTableInfo(name: "sales")] + await schemaProvider.loadSchema(using: driver, connection: TestFixtures.makeConnection()) + + let prefix = "WITH totals AS (SELECT region, SUM(amount) AS total FROM sales GROUP BY region) SELECT t." + let text = prefix + " FROM totals t" + let result = await engine.getCompletions(text: text, cursorPosition: (prefix as NSString).length) + + #expect(result != nil) + let labels = result?.items.map(\.label) ?? [] + #expect(labels.contains("region")) + #expect(labels.contains("total")) + } } diff --git a/TableProTests/Core/Autocomplete/DerivedTableParserTests.swift b/TableProTests/Core/Autocomplete/DerivedTableParserTests.swift new file mode 100644 index 000000000..772eb73dd --- /dev/null +++ b/TableProTests/Core/Autocomplete/DerivedTableParserTests.swift @@ -0,0 +1,139 @@ +// +// DerivedTableParserTests.swift +// TableProTests +// +// Tests for derived-table and CTE column extraction. +// + +import Foundation +@testable import TablePro +import Testing + +@Suite("Derived Table Parser") +struct DerivedTableParserTests { + let parser = DerivedTableParser() + + private func table(_ tables: [DerivedTable], alias: String) -> DerivedTable? { + tables.first { $0.alias.caseInsensitiveCompare(alias) == .orderedSame } + } + + @Test("Resolves the reported derived-table join") + func resolvesReportedQuery() { + let query = """ + SELECT YEAR, hs.country, happiness_score, ahs.avg_happiness_score + FROM happiness_scores hs + LEFT JOIN ( + SELECT country, AVG(happiness_score) AS avg_happiness_score + FROM happiness_scores + GROUP BY country + ) ahs + ON hs.country = ahs.country; + """ + let ahs = table(parser.parse(query), alias: "ahs") + #expect(ahs?.columns == ["country", "avg_happiness_score"]) + } + + @Test("Derived table in FROM clause") + func derivedTableInFrom() { + let query = "SELECT * FROM (SELECT id, name FROM users) u" + #expect(table(parser.parse(query), alias: "u")?.columns == ["id", "name"]) + } + + @Test("Derived table with AS keyword before alias") + func derivedTableWithAsAlias() { + let query = "SELECT * FROM (SELECT id FROM users) AS u" + #expect(table(parser.parse(query), alias: "u")?.columns == ["id"]) + } + + @Test("Derived table in a comma-separated FROM list") + func derivedTableInFromList() { + let query = "SELECT * FROM orders o, (SELECT total FROM payments) p" + #expect(table(parser.parse(query), alias: "p")?.columns == ["total"]) + } + + @Test("Qualified column keeps only the trailing identifier") + func qualifiedColumn() { + let query = "SELECT * FROM (SELECT u.id, u.email FROM users u) d" + #expect(table(parser.parse(query), alias: "d")?.columns == ["id", "email"]) + } + + @Test("SELECT star yields no resolvable columns") + func selectStarYieldsNoColumns() { + let query = "SELECT * FROM (SELECT * FROM users) d" + #expect(table(parser.parse(query), alias: "d")?.columns.isEmpty == true) + } + + @Test("Qualified star is skipped") + func qualifiedStarSkipped() { + let query = "SELECT * FROM (SELECT u.*, u.id FROM users u) d" + #expect(table(parser.parse(query), alias: "d")?.columns == ["id"]) + } + + @Test("Unaliased expressions are skipped") + func unaliasedExpressionSkipped() { + let query = "SELECT * FROM (SELECT name, COUNT(*) FROM users GROUP BY name) d" + #expect(table(parser.parse(query), alias: "d")?.columns == ["name"]) + } + + @Test("Nested subquery exposes only the outer alias") + func nestedSubquery() { + let query = "SELECT * FROM (SELECT a FROM (SELECT id AS a FROM t) inner_q) outer_q" + let tables = parser.parse(query) + #expect(table(tables, alias: "outer_q")?.columns == ["a"]) + #expect(table(tables, alias: "inner_q") == nil) + } + + @Test("CTE columns come from its SELECT list") + func cteColumns() { + let query = "WITH totals AS (SELECT country, SUM(score) AS total FROM s GROUP BY country) SELECT * FROM totals" + #expect(table(parser.parse(query), alias: "totals")?.columns == ["country", "total"]) + } + + @Test("CTE explicit column list overrides the SELECT list") + func cteExplicitColumnList() { + let query = "WITH totals (region, amount) AS (SELECT country, SUM(score) FROM s GROUP BY country) SELECT * FROM totals" + #expect(table(parser.parse(query), alias: "totals")?.columns == ["region", "amount"]) + } + + @Test("Multiple CTEs are each resolved") + func multipleCtes() { + let query = """ + WITH a AS (SELECT x FROM t1), + b AS (SELECT y, z FROM t2) + SELECT * FROM a JOIN b ON a.x = b.y + """ + let tables = parser.parse(query) + #expect(table(tables, alias: "a")?.columns == ["x"]) + #expect(table(tables, alias: "b")?.columns == ["y", "z"]) + } + + @Test("Quoted alias is unquoted") + func quotedAlias() { + let query = "SELECT * FROM (SELECT AVG(score) AS `avg_score` FROM s) d" + #expect(table(parser.parse(query), alias: "d")?.columns == ["avg_score"]) + } + + @Test("Subquery in WHERE IN is not a derived table") + func subqueryInWhereIsNotDerived() { + let query = "SELECT * FROM users WHERE id IN (SELECT user_id FROM orders)" + #expect(parser.parse(query).isEmpty) + } + + @Test("Function call parentheses are not derived tables") + func functionCallNotDerived() { + let query = "SELECT COUNT(id), AVG(score) FROM users" + #expect(parser.parse(query).isEmpty) + } + + @Test("Distinct in a derived select list is ignored") + func distinctIgnored() { + let query = "SELECT * FROM (SELECT DISTINCT country FROM s) d" + #expect(table(parser.parse(query), alias: "d")?.columns == ["country"]) + } + + @Test("Commas inside function arguments do not split columns") + func commasInFunctionArgs() { + let query = "SELECT * FROM (SELECT COALESCE(a, b) AS first_set, c FROM t) d" + #expect(table(parser.parse(query), alias: "d")?.columns == ["first_set", "c"]) + } +} diff --git a/TableProTests/Core/Autocomplete/SQLContextAnalyzerTests.swift b/TableProTests/Core/Autocomplete/SQLContextAnalyzerTests.swift index ff739f4b1..88528b35f 100644 --- a/TableProTests/Core/Autocomplete/SQLContextAnalyzerTests.swift +++ b/TableProTests/Core/Autocomplete/SQLContextAnalyzerTests.swift @@ -267,6 +267,27 @@ struct SQLContextAnalyzerTests { #expect(context.tableReferences.contains { $0.tableName == "orders" && $0.alias == "o" }) } + @Test("Registers derived-table alias with its columns") + func testDerivedTableReference() { + let query = """ + SELECT * FROM happiness_scores hs + LEFT JOIN (SELECT country, AVG(score) AS avg_score FROM happiness_scores GROUP BY country) ahs + ON hs.country = ahs.country + """ + let context = analyzer.analyze(query: query, cursorPosition: (query as NSString).length) + let ahs = context.tableReferences.first { $0.identifier == "ahs" } + #expect(ahs?.isDerived == true) + #expect(ahs?.derivedColumns == ["country", "avg_score"]) + } + + @Test("Registers CTE alias with its columns") + func testCteReference() { + let query = "WITH totals AS (SELECT region, SUM(amount) AS total FROM sales GROUP BY region) SELECT * FROM totals t" + let context = analyzer.analyze(query: query, cursorPosition: (query as NSString).length) + let totals = context.tableReferences.first { $0.identifier == "totals" } + #expect(totals?.derivedColumns == ["region", "total"]) + } + @Test("Extracts table reference from UPDATE") func testTableReferenceFromUpdate() { let context = analyzer.analyze(query: "UPDATE users SET", cursorPosition: 16) diff --git a/docs/features/autocomplete.mdx b/docs/features/autocomplete.mdx index d249b6ee3..7d1de9eda 100644 --- a/docs/features/autocomplete.mdx +++ b/docs/features/autocomplete.mdx @@ -102,6 +102,27 @@ FROM users u JOIN orders o ON u.id = o.| -- id, user_id, total (from orders) ``` +#### Derived Tables and CTEs + +An alias for a subquery (derived table) or a `WITH` table completes the columns that subquery's SELECT list produces, including `AS` renames: + +```sql +SELECT ahs.| -- country, avg_score +FROM happiness_scores hs +LEFT JOIN ( + SELECT country, AVG(score) AS avg_score + FROM happiness_scores + GROUP BY country +) ahs ON hs.country = ahs.country + +WITH totals AS ( + SELECT region, SUM(amount) AS total FROM sales GROUP BY region +) +SELECT t.| FROM totals t -- region, total +``` + +Explicit (`AS name`), bare (`country`), and qualified (`t.col`) columns resolve. A `SELECT *` subquery and unaliased expressions like `AVG(score)` have no name to suggest, so they are skipped. + {/* Screenshot: Column suggestions after alias */} Date: Thu, 18 Jun 2026 00:00:23 +0700 Subject: [PATCH 2/3] refactor(editor): hoist parser charsets and drop empty derived column type --- .../Autocomplete/DerivedTableParser.swift | 19 ++++++++++--------- .../Core/Autocomplete/SQLSchemaProvider.swift | 4 ++-- 2 files changed, 12 insertions(+), 11 deletions(-) diff --git a/TablePro/Core/Autocomplete/DerivedTableParser.swift b/TablePro/Core/Autocomplete/DerivedTableParser.swift index d2605b91f..8df094d79 100644 --- a/TablePro/Core/Autocomplete/DerivedTableParser.swift +++ b/TablePro/Core/Autocomplete/DerivedTableParser.swift @@ -48,6 +48,10 @@ internal struct DerivedTableParser { "FULL", "CROSS", "NATURAL", "AS", "SET", "RETURNING", "WINDOW" ] + private static let identifierPathCharacters = CharacterSet(charactersIn: + "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_.`\"[]") + private static let quoteCharacters = CharacterSet(charactersIn: "`\"[]") + func parse(_ statement: String) -> [DerivedTable] { let ns = statement as NSString let length = ns.length @@ -164,14 +168,11 @@ internal struct DerivedTableParser { } private func plainColumnName(_ item: String) -> String? { - guard !item.contains("*") else { return nil } - let allowed = CharacterSet(charactersIn: - "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_.`\"[]") - guard item.unicodeScalars.allSatisfy(allowed.contains) else { return nil } - let segments = item.split(separator: ".") - guard let last = segments.last else { return nil } - let cleaned = String(last).trimmingCharacters(in: CharacterSet(charactersIn: "`\"[]")) - guard !cleaned.isEmpty, cleaned.unicodeScalars.allSatisfy({ isIdentifier($0) }) else { return nil } + guard !item.contains("*"), + item.unicodeScalars.allSatisfy(Self.identifierPathCharacters.contains) else { return nil } + guard let last = item.split(separator: ".").last else { return nil } + let cleaned = String(last).trimmingCharacters(in: Self.quoteCharacters) + guard !cleaned.isEmpty, cleaned.unicodeScalars.allSatisfy(isIdentifier) else { return nil } return cleaned } @@ -290,7 +291,7 @@ internal struct DerivedTableParser { var names: [String] = [] for entry in topLevelItems(in: ns, start: open + 1, end: close) { let cleaned = entry.trimmingCharacters(in: .whitespacesAndNewlines) - .trimmingCharacters(in: CharacterSet(charactersIn: "`\"[]")) + .trimmingCharacters(in: Self.quoteCharacters) if !cleaned.isEmpty { names.append(cleaned) } diff --git a/TablePro/Core/Autocomplete/SQLSchemaProvider.swift b/TablePro/Core/Autocomplete/SQLSchemaProvider.swift index 8cce9eb73..d6907b29d 100644 --- a/TablePro/Core/Autocomplete/SQLSchemaProvider.swift +++ b/TablePro/Core/Autocomplete/SQLSchemaProvider.swift @@ -390,7 +390,7 @@ actor SQLSchemaProvider { func allColumnsInScope(for references: [TableReference]) async -> [SQLCompletionItem] { // swiftlint:disable:next large_tuple var itemDataBuilder: [( - label: String, insertText: String, type: String, table: String, + label: String, insertText: String, type: String?, table: String, isPK: Bool, isNullable: Bool, defaultValue: String?, comment: String? )] = [] @@ -402,7 +402,7 @@ actor SQLSchemaProvider { let label = hasMultipleRefs ? "\(refId).\(name)" : name itemDataBuilder.append( ( - label: label, insertText: label, type: "", + label: label, insertText: label, type: nil, table: refId, isPK: false, isNullable: true, defaultValue: nil, comment: nil )) From e99664f2513d6100ea400f9cce0a124c5b866c04 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Thu, 18 Jun 2026 09:31:19 +0700 Subject: [PATCH 3/3] refactor(editor): flatten select-list-start and derived-table merge logic --- .../Autocomplete/DerivedTableParser.swift | 31 ++++++++++++------- .../Autocomplete/SQLContextAnalyzer.swift | 17 +++++----- 2 files changed, 28 insertions(+), 20 deletions(-) diff --git a/TablePro/Core/Autocomplete/DerivedTableParser.swift b/TablePro/Core/Autocomplete/DerivedTableParser.swift index 8df094d79..9eba0cad4 100644 --- a/TablePro/Core/Autocomplete/DerivedTableParser.swift +++ b/TablePro/Core/Autocomplete/DerivedTableParser.swift @@ -131,17 +131,7 @@ internal struct DerivedTableParser { select.word.uppercased() == "SELECT" else { return [] } - var listStart = firstCode(in: ns, from: select.end, limit: length) - if let modifier = readWord(in: ns, at: listStart, limit: length), - modifier.text.uppercased() == "DISTINCT" || modifier.text.uppercased() == "ALL" { - listStart = firstCode(in: ns, from: modifier.end, limit: length) - if let on = readWord(in: ns, at: listStart, limit: length), on.text.uppercased() == "ON" { - let parenStart = firstCode(in: ns, from: on.end, limit: length) - if parenStart < length, ns.character(at: parenStart) == Self.openParen { - listStart = matchingParen(in: ns, openAt: parenStart, limit: length) + 1 - } - } - } + let listStart = selectListStart(in: ns, after: select.end, limit: length) let listEnd = topLevelKeyword(in: ns, start: listStart, end: length, keywords: Self.clauseKeywords)?.start ?? length guard listStart < listEnd else { return [] } @@ -154,6 +144,25 @@ internal struct DerivedTableParser { return names } + /// Skip a leading `DISTINCT`/`ALL` (and a `DISTINCT ON (...)` group) so the + /// list scan starts at the first projected expression. + private func selectListStart(in ns: NSString, after selectEnd: Int, limit: Int) -> Int { + var start = firstCode(in: ns, from: selectEnd, limit: limit) + guard let modifier = readWord(in: ns, at: start, limit: limit), + modifier.text.uppercased() == "DISTINCT" || modifier.text.uppercased() == "ALL" else { + return start + } + start = firstCode(in: ns, from: modifier.end, limit: limit) + guard let on = readWord(in: ns, at: start, limit: limit), on.text.uppercased() == "ON" else { + return start + } + let parenStart = firstCode(in: ns, from: on.end, limit: limit) + guard parenStart < limit, ns.character(at: parenStart) == Self.openParen else { + return start + } + return matchingParen(in: ns, openAt: parenStart, limit: limit) + 1 + } + private func deriveColumnName(_ rawItem: String) -> String? { let trimmed = rawItem.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmed.isEmpty else { return nil } diff --git a/TablePro/Core/Autocomplete/SQLContextAnalyzer.swift b/TablePro/Core/Autocomplete/SQLContextAnalyzer.swift index 938590e2a..4043148cf 100644 --- a/TablePro/Core/Autocomplete/SQLContextAnalyzer.swift +++ b/TablePro/Core/Autocomplete/SQLContextAnalyzer.swift @@ -805,15 +805,14 @@ final class SQLContextAnalyzer { var consumed = Set() for index in references.indices { let ref = references[index] - let tableKey = ref.tableName.lowercased() - let identifierKey = ref.identifier.lowercased() - let matched = derivedColumnsByAlias[tableKey].map { ($0, tableKey) } - ?? derivedColumnsByAlias[identifierKey].map { ($0, identifierKey) } - guard let (columns, key) = matched else { continue } - consumed.insert(key) - references[index] = TableReference( - tableName: ref.tableName, alias: ref.alias, schema: ref.schema, derivedColumns: columns - ) + for key in [ref.tableName.lowercased(), ref.identifier.lowercased()] { + guard let columns = derivedColumnsByAlias[key] else { continue } + consumed.insert(key) + references[index] = TableReference( + tableName: ref.tableName, alias: ref.alias, schema: ref.schema, derivedColumns: columns + ) + break + } } var present = Set(references.map { $0.identifier.lowercased() }).union(consumed)