diff --git a/apple/Packages/TableProCore/Sources/TableProAutocomplete/SQLCompletionProvider.swift b/apple/Packages/TableProCore/Sources/TableProAutocomplete/SQLCompletionProvider.swift index a190774b8c..4c7af0ac3c 100644 --- a/apple/Packages/TableProCore/Sources/TableProAutocomplete/SQLCompletionProvider.swift +++ b/apple/Packages/TableProCore/Sources/TableProAutocomplete/SQLCompletionProvider.swift @@ -150,22 +150,25 @@ public final class SQLCompletionProvider { // A dot prefix is a table or alias first, then a schema (its tables) or a database (its schemas, or its // tables on an engine with none). The namespace fallback also covers an alias that resolves to a schema - // name parsed out of the FROM clause. + // name parsed out of the FROM clause. In a table slot it can only qualify the table being named, so a table + // that shares the name is never read for columns. if let dotPrefix = context.dotPrefix { guard let catalog else { return [] } - if let derived = context.tableReferences.first(where: { - $0.isDerived && $0.identifier.caseInsensitiveCompare(dotPrefix) == .orderedSame - }) { - let columns = await derivedColumns(of: derived) - if !columns.isEmpty { - return [columns.map { SQLCompletionItem.column($0, dataType: nil, tableName: derived.identifier) }] + if !context.expectsObjectName { + if let derived = context.tableReferences.first(where: { + $0.isDerived && $0.identifier.caseInsensitiveCompare(dotPrefix) == .orderedSame + }) { + let columns = await derivedColumns(of: derived) + if !columns.isEmpty { + return [columns.map { SQLCompletionItem.column($0, dataType: nil, tableName: derived.identifier) }] + } + } + if let tableName = await catalog.resolveAlias(dotPrefix, in: context.tableReferences) { + let schema = context.tableReferences.first { + $0.tableName.caseInsensitiveCompare(tableName) == .orderedSame + }?.schema + items = await catalog.columnCompletionItems(for: tableName, schema: schema) } - } - if let tableName = await catalog.resolveAlias(dotPrefix, in: context.tableReferences) { - let schema = context.tableReferences.first { - $0.tableName.caseInsensitiveCompare(tableName) == .orderedSame - }?.schema - items = await catalog.columnCompletionItems(for: tableName, schema: schema) } if items.isEmpty { if await catalog.isKnownSchema(dotPrefix) { diff --git a/apple/Packages/TableProCore/Sources/TableProAutocomplete/SQLContextAnalyzer.swift b/apple/Packages/TableProCore/Sources/TableProAutocomplete/SQLContextAnalyzer.swift index aadeb59b74..841ac697c2 100644 --- a/apple/Packages/TableProCore/Sources/TableProAutocomplete/SQLContextAnalyzer.swift +++ b/apple/Packages/TableProCore/Sources/TableProAutocomplete/SQLContextAnalyzer.swift @@ -209,6 +209,7 @@ public final class SQLContextAnalyzer { private static let doubleQuote = UInt16(UnicodeScalar("\"").value) private static let backtick = UInt16(UnicodeScalar("`").value) private static let openBracket = UInt16(UnicodeScalar("[").value) + private static let closeBracket = UInt16(UnicodeScalar("]").value) private static let dollar = UInt16(UnicodeScalar("$").value) private static let hash = UInt16(UnicodeScalar("#").value) @@ -260,8 +261,9 @@ public final class SQLContextAnalyzer { // and hold any character. private static let quotedSegment = "[`\"'\\[]?\\w+[`\"'\\]]?" + // A trailing dot stays in the path, so `JOIN sales.` reads as unfinished instead of naming a table `sales`. private static let tableRefRegexes: [NSRegularExpression] = { - let path = "(\(quotedSegment)(?:\\.\(quotedSegment))*)" + let path = "(\(quotedSegment)(?:\\.\(quotedSegment))*\\.?)" let alias = "(?:\\s+(?:AS\\s+)?(\(quotedSegment)))?" let patterns = [ // MySQL's STRAIGHT_JOIN has no word boundary before JOIN. @@ -282,7 +284,7 @@ public final class SQLContextAnalyzer { ) private static let alterTableRegex = compileRegex( - "(?i)\\bALTER\\s+TABLE\\s+(\(quotedSegment)(?:\\.\(quotedSegment))*)" + "(?i)\\bALTER\\s+TABLE\\s+(\(quotedSegment)(?:\\.\(quotedSegment))*\\.?)" ) private static let derivedTableParser = DerivedTableParser() @@ -855,7 +857,13 @@ public final class SQLContextAnalyzer { append(upTo: index) start = index + 1 } - append(upTo: end) + // The name after a trailing dot is still being typed: an empty last segment, so no caller reads `sales.` as + // a table named `sales`. + if start == end, end > range.location { + segments.append(PathSegment(masked: "", name: "", isQuoted: false)) + } else { + append(upTo: end) + } return segments } @@ -1048,10 +1056,6 @@ public final class SQLContextAnalyzer { dotPrefix: String?, currentFunction: String? = nil ) -> ClauseResolution { - if dotPrefix != nil { - return ClauseResolution(clause: .select, expectsObjectName: false) - } - // Bounds the regex work on a long statement. SQLContextAnalyzerWindowingTests pins the size. let windowSize = 5_000 let nsText = textBeforeCursor as NSString @@ -1059,6 +1063,10 @@ public final class SQLContextAnalyzer { ? nsText.substring(from: nsText.length - windowSize) : textBeforeCursor + if dotPrefix != nil { + return ClauseResolution(clause: .select, expectsObjectName: qualifiesTableName(after: windowedText)) + } + // Structural contexts first: `CREATE TABLE test (id ` looks like a call to `test(` but is a column definition. let range = NSRange(location: 0, length: (windowedText as NSString).length) for (regex, clause) in Self.structuralClauseRegexes { @@ -1079,6 +1087,42 @@ public final class SQLContextAnalyzer { return ClauseResolution(clause: .unknown, expectsObjectName: false) } + // `FROM sales.` qualifies the table being named. A FROM after `IS DISTINCT` or inside a call such as + // `EXTRACT(YEAR FROM o.`, and any qualifier this cannot place, stays a table or alias. + private func qualifiesTableName(after textBeforePrefix: String) -> Bool { + let tokens = tokenize(Self.removingTrailingPath(textBeforePrefix)) + guard let keyword = tokens.last, keyword.kind == .word, + ["FROM", "JOIN", "INTO"].contains(keyword.upper) else { return false } + guard keyword.upper == "FROM" else { return true } + if tokens.count >= 2, tokens[tokens.count - 2].upper == "DISTINCT" { return false } + var open: [Int] = [] + for (index, token) in tokens.enumerated() { + if token.kind == .openParen { + open.append(index) + } else if token.kind == .closeParen, !open.isEmpty { + open.removeLast() + } + } + guard let paren = open.last, paren > 0, tokens[paren - 1].kind == .word else { return true } + return Self.groupingKeywords.contains(tokens[paren - 1].upper) + } + + // A parenthesis after one of these opens a subquery or a join group, so a FROM inside it names a table. + private static let groupingKeywords: Set = [ + "FROM", "JOIN", "LATERAL", "AS", "IN", "EXISTS", "ANY", "ALL", "SOME", "UNION", "INTERSECT", "EXCEPT" + ] + + private static func removingTrailingPath(_ text: String) -> String { + let ns = text as NSString + var start = ns.length + while start > 0 { + let ch = ns.character(at: start - 1) + guard SQLTokenBoundary.isTokenChar(ch) || ch == dot || ch == openBracket || ch == closeBracket else { break } + start -= 1 + } + return ns.substring(to: start) + } + // MARK: - Linear Clause Scan private enum ScanTokenKind { diff --git a/apple/Packages/TableProCore/Tests/TableProAutocompleteTests/SQLClauseDetectionTests.swift b/apple/Packages/TableProCore/Tests/TableProAutocompleteTests/SQLClauseDetectionTests.swift index bf73b9a139..9fb3812a87 100644 --- a/apple/Packages/TableProCore/Tests/TableProAutocompleteTests/SQLClauseDetectionTests.swift +++ b/apple/Packages/TableProCore/Tests/TableProAutocompleteTests/SQLClauseDetectionTests.swift @@ -132,6 +132,28 @@ struct SQLClauseDetectionTests { #expect(ctx.expectsObjectName == testCase.expects) } + @Test("A qualifier's dot sits in the table slot only right after a table-naming keyword", arguments: [ + (query: "SELECT * FROM sales.", expects: true), + (query: "SELECT * FROM a JOIN sales.", expects: true), + (query: "INSERT INTO sales.", expects: true), + (query: "SELECT * FROM t WHERE id IN (SELECT id FROM sales.", expects: true), + (query: "SELECT * FROM (WITH c AS (SELECT 1 AS id) SELECT * FROM sales.", expects: true), + (query: "SELECT * FROM (a JOIN sales.", expects: true), + (query: "SELECT o. FROM orders o", expects: false), + (query: "SELECT * FROM orders o WHERE o.", expects: false), + (query: "SELECT * FROM a, sales.", expects: false), + (query: "SELECT EXTRACT(YEAR FROM o.", expects: false), + (query: "SELECT SUBSTR(o.status FROM o.", expects: false), + (query: "SELECT * FROM t WHERE a IS DISTINCT FROM o.", expects: false), + (query: "SELECT * FROM orders o, generate_series(1, o.", expects: false), + ]) + func dotInOperandSlot(_ testCase: (query: String, expects: Bool)) { + let cursor = testCase.query.hasPrefix("SELECT o.") ? 9 : (testCase.query as NSString).length + let ctx = analyzer.analyze(query: testCase.query, cursorPosition: cursor) + #expect(ctx.dotPrefix != nil) + #expect(ctx.expectsObjectName == testCase.expects) + } + // MARK: - String and Comment Guards @Test("Clause keywords inside a string literal are ignored") diff --git a/apple/Packages/TableProCore/Tests/TableProAutocompleteTests/SQLCompletionProviderCatalogTests.swift b/apple/Packages/TableProCore/Tests/TableProAutocompleteTests/SQLCompletionProviderCatalogTests.swift index cb4c923171..b2d62173dd 100644 --- a/apple/Packages/TableProCore/Tests/TableProAutocompleteTests/SQLCompletionProviderCatalogTests.swift +++ b/apple/Packages/TableProCore/Tests/TableProAutocompleteTests/SQLCompletionProviderCatalogTests.swift @@ -102,6 +102,35 @@ struct SQLCompletionProviderCatalogTests { #expect(Set(session.items.map(\.label)) == ["orders", "customers"]) } + // On Oracle the column read for a table that was never there ran first and queued the schema's table list + // behind it on one connection. + @Test("A qualifier in a table slot offers its schema's tables, even beside a table of that name") + func tableSlotQualifierIsASchema() async { + let catalog = StubCatalog( + tables: ["audit"], columns: ["audit": ["id", "action"]], schemas: ["audit"], + schemaTables: ["audit": ["events"]] + ) + let provider = SQLCompletionProvider(catalog: catalog, grammar: .engine("PostgreSQL"), vocabulary: .generic) + for text in ["SELECT * FROM audit.", "SELECT * FROM orders o JOIN audit.", "INSERT INTO audit."] { + let session = await provider.completionSession(text: text, cursorPosition: (text as NSString).length) + #expect(session.items.map(\.label) == ["events"], "\(text)") + } + let columns = await provider.completionSession(text: "SELECT audit. FROM audit", cursorPosition: 13) + #expect(Set(columns.items.map(\.label)) == ["id", "action"]) + } + + @Test("A FROM inside an expression keeps an alias's columns", arguments: [ + "SELECT EXTRACT(YEAR FROM o.|) FROM orders o", + "SELECT SUBSTRING(o.status FROM o.|) FROM orders o", + "SELECT SUBSTR(o.status FROM o.|) FROM orders o", + "SELECT * FROM orders o WHERE o.total IS DISTINCT FROM o.|", + "SELECT * FROM orders o WHERE o.total IS NOT DISTINCT FROM o.|", + "SELECT * FROM orders o, generate_series(1, o.|)" + ]) + func expressionFromKeepsAliasColumns(_ marked: String) async { + #expect(Set(await labels(marked)) == ["id", "total", "status"]) + } + @Test("UPDATE offers tables") func updateOffersTables() async { let session = await provider.completionSession(text: "UPDATE or", cursorPosition: 9) diff --git a/apple/Packages/TableProCore/Tests/TableProAutocompleteTests/SQLContextAnalyzerTableReferenceTests.swift b/apple/Packages/TableProCore/Tests/TableProAutocompleteTests/SQLContextAnalyzerTableReferenceTests.swift index f45cfea954..3db7400b72 100644 --- a/apple/Packages/TableProCore/Tests/TableProAutocompleteTests/SQLContextAnalyzerTableReferenceTests.swift +++ b/apple/Packages/TableProCore/Tests/TableProAutocompleteTests/SQLContextAnalyzerTableReferenceTests.swift @@ -38,6 +38,17 @@ struct SQLContextAnalyzerTableReferenceTests { #expect(tables("ALTER TABLE invoices ADD COLUMN due DATE") == ["invoices"]) } + @Test("A path that ends in a dot names no table yet") + func unfinishedPath() { + #expect(tables("SELECT * FROM sales.").isEmpty) + #expect(tables("SELECT * FROM orders o JOIN sales.") == ["orders"]) + #expect(tables("UPDATE sales.").isEmpty) + #expect(tables("INSERT INTO sales.").isEmpty) + #expect(tables("ALTER TABLE sales.").isEmpty) + #expect(analyzer.analyze(query: "SELECT * FROM sales.", cursorPosition: 20).tableReferences.isEmpty) + #expect(tables("SELECT * FROM sales.orders") == ["orders"]) + } + @Test("A schema-qualified ALTER target keeps its schema") func qualifiedAlterTarget() { let references = analyzer.tableReferences(in: "ALTER TABLE archive.orders ADD COLUMN due DATE", grammar: .ansi) diff --git a/apple/changelog.d/3874.fixed.md b/apple/changelog.d/3874.fixed.md new file mode 100644 index 0000000000..a022de62b5 --- /dev/null +++ b/apple/changelog.d/3874.fixed.md @@ -0,0 +1 @@ +Autocomplete after a schema name and a dot could list a same-named table's columns, or time out on Oracle.