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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down Expand Up @@ -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.
Expand All @@ -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()
Expand Down Expand Up @@ -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
}

Expand Down Expand Up @@ -1048,17 +1056,17 @@ 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
let windowedText = nsText.length > windowSize
? 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 {
Expand All @@ -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<String> = [
"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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
1 change: 1 addition & 0 deletions apple/changelog.d/3874.fixed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Autocomplete after a schema name and a dot could list a same-named table's columns, or time out on Oracle.
Loading