From 8dfe2ab05cc140ea6e342a43295e2d4ef82b5328 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Wed, 21 Oct 2026 01:28:03 +0700 Subject: [PATCH 1/2] feat(editor): complete columns of every CTE shape and ORDER BY aliases --- .../DerivedTableParser.swift | 1225 ++++++++++++----- .../SQLCompletionProvider.swift | 74 +- .../SQLContextAnalyzer.swift | 155 ++- .../DerivedTableParserTests.swift | 244 +++- .../SQLCompletionProviderCatalogTests.swift | 201 ++- ...QLContextAnalyzerStatementShapeTests.swift | 102 ++ .../Core/Autocomplete/CompletionEngine.swift | 18 +- 7 files changed, 1583 insertions(+), 436 deletions(-) create mode 100644 apple/Packages/TableProCore/Tests/TableProAutocompleteTests/SQLContextAnalyzerStatementShapeTests.swift diff --git a/apple/Packages/TableProCore/Sources/TableProAutocomplete/DerivedTableParser.swift b/apple/Packages/TableProCore/Sources/TableProAutocomplete/DerivedTableParser.swift index fec55b86b2..435a71e181 100644 --- a/apple/Packages/TableProCore/Sources/TableProAutocomplete/DerivedTableParser.swift +++ b/apple/Packages/TableProCore/Sources/TableProAutocomplete/DerivedTableParser.swift @@ -1,35 +1,148 @@ -// -// 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 { +struct DerivedTable: Equatable, Sendable { let alias: String - let columns: [String] + let projection: DerivedProjection + let isCommonTableExpression: Bool + + // What the statement text names, before the catalog expands a `*`. + var columns: [String] { + projection.columns { _ in nil } + } +} + +// A `*` stays a table until the provider can ask the catalog for its columns. +struct DerivedProjection: Hashable, Sendable { + struct Table: Hashable, Sendable { + let name: String + let schema: String? + } + + enum Entry: Hashable, Sendable { + case column(String) + case unnamed + case allColumns(Table) + case nested(DerivedProjection) + // A USING join outputs its join columns once, ahead of what is left of each side. + case excluding([String], DerivedProjection) + // A NATURAL join does the same for the columns both sides share, which only the catalog can name. + case natural(DerivedProjection, DerivedProjection) + } + + var entries: [Entry] + // `name (a, b) AS (...)` and `AS x(a, b)` rename the output by position and keep the rest. + var declaredNames: [String] = [] + + static let empty = DerivedProjection(entries: []) + + var tables: [Table] { + entries.flatMap { entry -> [Table] in + switch entry { + case .allColumns(let table): return [table] + case .nested(let projection), .excluding(_, let projection): return projection.tables + case .natural(let left, let right): return left.tables + right.tables + case .column, .unnamed: return [] + } + } + } + + func declaring(_ names: [String]) -> DerivedProjection { + guard !names.isEmpty else { return self } + guard declaredNames.isEmpty else { return DerivedProjection(entries: [.nested(self)], declaredNames: names) } + return DerivedProjection(entries: entries, declaredNames: names) + } + + // `expand` answers nil for a table it cannot read, whose width also hides where the declared names land. Names + // stay case-sensitive, since `"Value"` and `"value"` are two columns. + func columns(expanding expand: (Table) -> [String]?) -> [String] { + var seen = Set() + return resolve(expand).names.compactMap { $0 }.filter { seen.insert($0).inserted } + } + + private func resolve(_ expand: (Table) -> [String]?) -> (names: [String?], knownWidth: Int?) { + var names: [String?] = [] + var knownWidth: Int? + for entry in entries { + switch entry { + case .column(let name): + names.append(name) + case .unnamed: + names.append(nil) + case .allColumns(let table): + if let columns = expand(table) { + names += columns.map { Optional($0) } + } else if knownWidth == nil { + knownWidth = names.count + } + case .nested(let projection): + let inner = projection.resolve(expand) + if knownWidth == nil, let innerWidth = inner.knownWidth { + knownWidth = names.count + innerWidth + } + names += inner.names + case .excluding(let excluded, let projection): + let inner = projection.resolve(expand) + let dropped = Set(excluded.map { $0.lowercased() }) + let keeps: (String?) -> Bool = { name in name.map { !dropped.contains($0.lowercased()) } ?? true } + if knownWidth == nil, let innerWidth = inner.knownWidth { + knownWidth = names.count + inner.names[.. Bool = { name in name.map { shared.contains($0.lowercased()) } ?? false } + names += lhs.names.filter(isShared) + lhs.names.filter { !isShared($0) } + rhs.names.filter { !isShared($0) } + } + } + let known = knownWidth ?? names.count + for (index, name) in declaredNames.enumerated() where index < known { + names[index] = name + } + guard declaredNames.count > known else { return (names, knownWidth) } + // A declared name past an unread `*` still names a column, only not which one it replaces. + names.insert(contentsOf: declaredNames[known...].map { Optional($0) }, at: known) + return (names, knownWidth.map { _ in declaredNames.count }) + } } -/// 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 { +// Read from the analyzer's code view, where comments and literal bodies are blank and quoted names are masked, so a +// parenthesis or quote inside one never counts, whatever the engine's quoting rules. +struct SQLTokenStream { + enum Kind { + case word, quoted, literal, number, openParen, closeParen, comma, dot, star, other + } + + struct Token { + let kind: Kind + let range: NSRange + let upper: String + var partner: Int + } + + let tokens: [Token] + let original: NSString + private static let space: unichar = 0x20 private static let tab: unichar = 0x09 private static let newline: unichar = 0x0A + private static let verticalTab: unichar = 0x0B + private static let formFeed: unichar = 0x0C private static let cr: unichar = 0x0D + private static let noBreakSpace: unichar = 0xA0 + private static let dollar: unichar = 0x24 private static let openParen: unichar = 0x28 private static let closeParen: unichar = 0x29 + private static let star: unichar = 0x2A 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 @@ -37,422 +150,846 @@ internal struct DerivedTableParser { 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" - ] - - 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 - var results: [DerivedTable] = [] - var seen = Set() - var i = 0 - while i < length { - if let skipped = skipNonCode(in: ns, at: i, limit: length) { - i = skipped + init(code: NSString, original: NSString, bracketQuotes: Bool) { + self.original = original + let length = min(code.length, original.length) + var tokens: [Token] = [] + var openParens: [Int] = [] + var index = 0 + while index < length { + let unit = code.character(at: index) + if Self.isWhitespace(unit) { + index += 1 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) + let start = index + let kind: Kind + if Self.isWordStart(unit) { + index += 1 + while index < length, Self.isWordPart(code.character(at: index)) { index += 1 } + kind = .word + } else if unit >= 0x30 && unit <= 0x39 { + index += 1 + while index < length, Self.isWordPart(code.character(at: index)) || code.character(at: index) == Self.dot { + index += 1 } - i = close + 1 - continue + kind = .number + } else if unit == Self.doubleQuote || unit == Self.backtick || (bracketQuotes && unit == Self.openBracket) { + index = Self.end(closing: unit == Self.openBracket ? Self.closeBracket : unit, in: code, from: index, length) + kind = .quoted + } else if unit == Self.singleQuote { + index = Self.end(closing: Self.singleQuote, in: code, from: index, length) + kind = .literal + } else { + index += 1 + switch unit { + case Self.openParen: kind = .openParen + case Self.closeParen: kind = .closeParen + case Self.comma: kind = .comma + case Self.dot: kind = .dot + case Self.star: kind = .star + default: kind = .other + } + } + let range = NSRange(location: start, length: index - start) + let upper = kind == .word ? original.substring(with: range).uppercased() : "" + tokens.append(Token(kind: kind, range: range, upper: upper, partner: -1)) + if kind == .openParen { + openParens.append(tokens.count - 1) + } else if kind == .closeParen, let open = openParens.popLast() { + tokens[open].partner = tokens.count - 1 + tokens[tokens.count - 1].partner = open + } + } + for open in openParens { + tokens[open].partner = tokens.count + } + self.tokens = tokens + } + + var count: Int { tokens.count } + + func text(_ index: Int) -> String { + original.substring(with: tokens[index].range) + } + + func isWord(_ index: Int, _ upper: String) -> Bool { + index >= 0 && index < tokens.count && tokens[index].kind == .word && tokens[index].upper == upper + } + + func isWord(_ index: Int, in words: Set) -> Bool { + index >= 0 && index < tokens.count && tokens[index].kind == .word && words.contains(tokens[index].upper) + } + + func kind(_ index: Int) -> Kind? { + index >= 0 && index < tokens.count ? tokens[index].kind : nil + } + + func isName(_ index: Int) -> Bool { + kind(index) == .word || kind(index) == .quoted + } + + func name(_ index: Int) -> String { + Self.unquoted(text(index)) + } + + // The index after an open parenthesis's partner, or the end when it never closes. + func after(_ open: Int) -> Int { + min(tokens[open].partner, tokens.count - 1) + 1 + } + + func closeIndex(of open: Int) -> Int { + min(tokens[open].partner, tokens.count) + } + + // The open parentheses around `cursor`, outermost first. + func enclosingParens(before cursor: Int) -> [Int] { + var stack: [Int] = [] + for index in tokens.indices where tokens[index].range.location < cursor { + switch tokens[index].kind { + case .openParen: stack.append(index) + case .closeParen: if tokens[index].partner >= 0 { stack.removeLast() } + default: break } - i += 1 } - return results + return stack } - // MARK: - Classification + func contains(_ cursor: Int, inside open: Int?) -> Bool { + guard let open else { return true } + guard cursor > tokens[open].range.location else { return false } + let close = tokens[open].partner + return close >= tokens.count || cursor <= tokens[close].range.location + } - 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) + static func unquoted(_ spelling: String) -> String { + guard let first = spelling.first else { return spelling } + let closing: Character + switch first { + case "\"", "`", "'": closing = first + case "[": closing = "]" + default: return spelling } - if kind == "FROM" || kind == "JOIN" || preceding.text == "," { - return parseDerived(in: ns, subOpen: open, subClose: close, limit: limit) + var body = spelling.dropFirst() + if body.last == closing { + body = body.dropLast() } - return nil + return String(body).replacingOccurrences(of: String(repeating: closing, count: 2), with: String(closing)) } - 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) + private static func end(closing: unichar, in code: NSString, from start: Int, _ length: Int) -> Int { + var index = start + 1 + while index < length { + if code.character(at: index) == closing { + if closing != closeBracket, index + 1 < length, code.character(at: index + 1) == closing { + index += 2 + continue + } + return index + 1 + } + index += 1 } - 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) + return length } - 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) + private static func isWhitespace(_ unit: unichar) -> Bool { + unit == space || unit == tab || unit == newline || unit == cr || unit == verticalTab || unit == formFeed + || unit == noBreakSpace } - // MARK: - SELECT list + private static func isWordStart(_ unit: unichar) -> Bool { + (unit >= 0x41 && unit <= 0x5A) || (unit >= 0x61 && unit <= 0x7A) || unit == underscore || unit >= 0x80 + } - 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 [] - } - 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 [] } + private static func isWordPart(_ unit: unichar) -> Bool { + isWordStart(unit) || (unit >= 0x30 && unit <= 0x39) || unit == dollar + } +} - 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 +// Reads the derived tables and CTEs a statement defines, what each outputs, and the query level around the cursor. +struct DerivedTableParser { + private struct Source { + let exposedName: String + let tableName: String? + let projection: DerivedProjection + } + + private enum ItemOutput { + case named(String, spelling: String, isAlias: Bool) + case allColumns(qualifier: String?) + case unnamed } - /// 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 + private struct Statement { + let keyword: Int + let ctes: [String: DerivedProjection] + } + + private struct FromItems { + var sources: [Source] = [] + var star: [DerivedProjection.Entry] = [] + + init(sources: [Source] = [], star: [DerivedProjection.Entry] = []) { + self.sources = sources + self.star = star } - 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 + + init(source: Source) { + self.init(sources: [source], star: DerivedTableParser.spread(source.projection)) } - let parenStart = firstCode(in: ns, from: on.end, limit: limit) - guard parenStart < limit, ns.character(at: parenStart) == Self.openParen else { - return start + + func joining(_ next: FromItems, using columns: [String]?, natural: Bool) -> FromItems { + if natural, !star.isEmpty { + let merged = DerivedProjection.Entry.natural( + DerivedProjection(entries: star), DerivedProjection(entries: next.star) + ) + return FromItems(sources: sources + next.sources, star: [merged]) + } + guard let columns, !columns.isEmpty, !star.isEmpty else { + return FromItems(sources: sources + next.sources, star: star + next.star) + } + let merged = columns.map { DerivedProjection.Entry.column($0) } + [ + .excluding(columns, DerivedProjection(entries: star)), + .excluding(columns, DerivedProjection(entries: next.star)), + ] + return FromItems(sources: sources + next.sources, star: merged) } - 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 } - 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 struct CommonTableExpression { + let name: String + let open: Int + let projection: DerivedProjection } - private func plainColumnName(_ item: String) -> String? { - 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 + private typealias Found = [(table: DerivedTable, depth: Int)] + + private typealias Scope = [String: DerivedProjection] + + private static let queryBodyWords: Set = ["SELECT", "WITH", "VALUES", "TABLE"] + private static let modifyingBodyWords: Set = ["INSERT", "UPDATE", "DELETE", "MERGE"] + private static let fromListOpeners: Set = ["FROM", "JOIN", "USING", "APPLY", "STRAIGHT_JOIN"] + private static let fromListBreakers: Set = [ + "SELECT", "WHERE", "SET", "GROUP", "ORDER", "HAVING", "VALUES", "RETURNING", "INTO", "WINDOW", "LIMIT", + "OFFSET", "BY", "UPDATE", "DELETE", "INSERT", "MERGE", "WITH", "WHEN", "THEN", "ELSE", "CASE", "QUALIFY" + ] + private static let joinWords: Set = ["JOIN", "APPLY", "STRAIGHT_JOIN"] + private static let selectListEnders: Set = [ + "FROM", "INTO", "WHERE", "GROUP", "HAVING", "WINDOW", "ORDER", "LIMIT", "OFFSET", "FETCH", "FOR", + "UNION", "INTERSECT", "EXCEPT", "MINUS", "QUALIFY" + ] + private static let fromListEnders: Set = [ + "WHERE", "GROUP", "HAVING", "WINDOW", "ORDER", "LIMIT", "OFFSET", "FETCH", "FOR", "UNION", "INTERSECT", + "EXCEPT", "MINUS", "RETURNING", "QUALIFY", "CONNECT", "START" + ] + private static let selectModifiers: Set = [ + "DISTINCT", "DISTINCTROW", "ALL", "HIGH_PRIORITY", "STRAIGHT_JOIN", "SQL_SMALL_RESULT", "SQL_BIG_RESULT", + "SQL_BUFFER_RESULT", "SQL_NO_CACHE", "SQL_CACHE", "SQL_CALC_FOUND_ROWS" + ] + private static let nonAliasWords: Set = [ + "ON", "USING", "WHERE", "GROUP", "ORDER", "HAVING", "LIMIT", "OFFSET", "FETCH", "FOR", "UNION", + "INTERSECT", "EXCEPT", "MINUS", "JOIN", "INNER", "LEFT", "RIGHT", "FULL", "OUTER", "CROSS", "NATURAL", + "APPLY", "STRAIGHT_JOIN", "AS", "SET", "RETURNING", "WINDOW", "LATERAL", "TABLESAMPLE", "WITH", "USE", + "FORCE", "IGNORE", "PARTITION", "INTO", "VALUES", "SELECT", "DEFAULT", "WHEN", "QUALIFY", "PIVOT", + "UNPIVOT", "CONNECT", "START", "OVERRIDING" + ] + // A bare word that ends an expression even though it is a keyword, as `CASE ... END total` does. + private static let valueKeywords: Set = ["END", "TRUE", "FALSE", "NULL"] + private static let notImplicitAliases: Set = SQLKeywords.keywordSet.union([ + "day", "hour", "minute", "second", "month", "week", "zone", "precision", "varying", "escape", "interval", + "array", "row", "only", "ties", "percent" + ]) + + // Innermost first, so a nearer definition shadows an outer one of the same name. + func visibleTables(in stream: SQLTokenStream, cursor: Int) -> [DerivedTable] { + var found: Found = [] + collect(from: 0, to: stream.count, depth: 0, scope: [:], cursor: cursor, in: stream, into: &found) + var seen = Set() + return found.enumerated() + .sorted { lhs, rhs in + lhs.element.depth == rhs.element.depth ? lhs.offset < rhs.offset : lhs.element.depth > rhs.element.depth + } + .map(\.element.table) + .filter { seen.insert($0.alias.lowercased()).inserted } } - private func isIdentifier(_ scalar: Unicode.Scalar) -> Bool { - CharacterSet.alphanumerics.contains(scalar) || scalar == "_" + // ORDER BY may name these; inside a call's or a window's parentheses it orders that call's input instead. + func outputAliases(in stream: SQLTokenStream, cursor: Int) -> [String] { + var start = 0 + var end = stream.count + if let innermost = stream.enclosingParens(before: cursor).last { + guard isQueryBody(innermost, in: stream) else { return [] } + start = innermost + 1 + end = stream.closeIndex(of: innermost) + } + guard let select = firstSelect(from: start, to: end, in: stream) else { return [] } + let listStart = selectListStart(after: select, to: end, in: stream) + let listEnd = firstTopLevel(Self.selectListEnders, from: listStart, to: end, in: stream) ?? end + return topLevelItems(from: listStart, to: listEnd, in: stream).compactMap { item in + guard case .named(_, let spelling, true) = output(of: item, in: stream) else { return nil } + return spelling + } } - // MARK: - Token scanning + // The table an INSERT, UPDATE, DELETE or MERGE writes at the cursor's query level. + func targetTable(in stream: SQLTokenStream, cursor: Int) -> TableReference? { + var start = 0 + var end = stream.count + if let open = stream.enclosingParens(before: cursor).last(where: { isStatementBody($0, in: stream) }) { + start = open + 1 + end = stream.closeIndex(of: open) + } + guard let statement = mainStatement(from: start, to: end, in: stream, scope: [:]), + let target = modifiedTable(of: statement.keyword, to: end, in: stream) else { return nil } + return target.reference + } - 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 + // MARK: - Definitions + + // One query level. A CTE of its WITH list is in scope from the next definition on, and only a level that holds the + // cursor is read further, since what it defines is out of the cursor's reach otherwise. + private func collect( + from start: Int, to end: Int, depth: Int, scope: Scope, cursor: Int, + in stream: SQLTokenStream, into found: inout Found + ) { + let ctes = withList(from: start, to: end, in: stream, scope: scope)?.ctes ?? [] + var scope = scope + var nextCTE = 0 + var index = start + while index < end { + guard stream.tokens[index].kind == .openParen else { + index += 1 continue } - let c = ns.character(at: i) - if c == Self.openParen { - depth += 1 - i += 1 - continue + if nextCTE < ctes.count, ctes[nextCTE].open == index { + let cte = ctes[nextCTE] + found.append((DerivedTable(alias: cte.name, projection: cte.projection, isCommonTableExpression: true), depth)) + } else if let table = definition(at: index, in: stream, scope: scope) { + found.append((table, depth)) } - if c == Self.closeParen { - depth -= 1 - i += 1 - continue + if stream.contains(cursor, inside: index) { + collect( + from: index + 1, to: stream.closeIndex(of: index), depth: depth + 1, scope: scope, cursor: cursor, + in: stream, into: &found + ) } - 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 + index = stream.after(index) + while nextCTE < ctes.count, ctes[nextCTE].open < index { + scope[ctes[nextCTE].name.lowercased()] = ctes[nextCTE].projection + nextCTE += 1 } - 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 + private func definition(at open: Int, in stream: SQLTokenStream, scope: Scope) -> DerivedTable? { + guard isStatementBody(open, in: stream) else { return nil } + if let asIndex = cteKeyword(before: open, in: stream) { + var nameEnd = asIndex - 1 + var declared: [String] = [] + if stream.kind(nameEnd) == .closeParen, stream.tokens[nameEnd].partner >= 0 { + let listOpen = stream.tokens[nameEnd].partner + declared = nameList(at: listOpen, in: stream) + nameEnd = listOpen - 1 } - i += 1 + guard stream.isName(nameEnd) else { return nil } + return DerivedTable( + alias: stream.name(nameEnd), + projection: projection(ofBodyAt: open, in: stream, scope: scope).declaring(declared), + isCommonTableExpression: true + ) } - items.append(ns.substring(with: NSRange(location: itemStart, length: end - itemStart))) - return items + guard isQueryBody(open, in: stream), opensFromItem(open, in: stream), + let alias = alias(after: stream.after(open) - 1, in: stream) else { return nil } + return DerivedTable( + alias: alias.name, + projection: projection(ofBodyAt: open, in: stream, scope: scope).declaring(alias.declared), + isCommonTableExpression: false + ) } - 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 + 2 else { return nil } - return ns.substring(with: NSRange(location: index + 1, length: end - index - 2)) - } - 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: Self.quoteCharacters) - if !cleaned.isEmpty { - names.append(cleaned) + // `AS (`, `AS MATERIALIZED (` or `AS NOT MATERIALIZED (`. + private func cteKeyword(before open: Int, in stream: SQLTokenStream) -> Int? { + var index = open - 1 + if stream.isWord(index, "MATERIALIZED") { + index -= 1 + if stream.isWord(index, "NOT") { index -= 1 } + } + return stream.isWord(index, "AS") ? index : nil + } + + private func opensFromItem(_ open: Int, in stream: SQLTokenStream) -> Bool { + var index = open - 1 + if stream.isWord(index, "LATERAL") { index -= 1 } + if stream.isWord(index, in: Self.fromListOpeners) { return true } + guard stream.kind(index) == .comma else { return false } + // A comma continues a FROM list only when FROM or a join is the nearest clause word behind it. + index -= 1 + while index >= 0 { + let token = stream.tokens[index] + switch token.kind { + case .closeParen where token.partner >= 0: + index = token.partner - 1 + continue + case .openParen: + return false + case .word: + if Self.fromListOpeners.contains(token.upper) { return true } + if Self.fromListBreakers.contains(token.upper) { return false } + default: + break } + index -= 1 } - return names + return false } - // MARK: - Structural helpers + private func isQueryBody(_ open: Int, in stream: SQLTokenStream) -> Bool { + let first = open + 1 + if stream.kind(first) == .openParen { return isQueryBody(first, in: stream) } + return stream.isWord(first, in: Self.queryBodyWords) + } - 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 isStatementBody(_ open: Int, in stream: SQLTokenStream) -> Bool { + isQueryBody(open, in: stream) || stream.isWord(open + 1, in: Self.modifyingBodyWords) } - 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" + // MARK: - Projections + + private func projection(ofBodyAt open: Int, in stream: SQLTokenStream, scope: Scope) -> DerivedProjection { + projection(from: open + 1, to: stream.closeIndex(of: open), in: stream, scope: scope) } - 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 projection(from start: Int, to end: Int, in stream: SQLTokenStream, scope: Scope) -> DerivedProjection { + guard let statement = mainStatement(from: start, to: end, in: stream, scope: scope) else { return .empty } + let keyword = statement.keyword + let scope = scope.merging(statement.ctes) { _, inner in inner } + if stream.kind(keyword) == .openParen { + return projection(ofBodyAt: keyword, in: stream, scope: scope) + } + switch stream.tokens[keyword].upper { + case "SELECT": + return selectProjection(at: keyword, to: end, in: stream, scope: scope) + case "VALUES": + var row = keyword + 1 + if stream.isWord(row, "ROW") { row += 1 } + guard stream.kind(row) == .openParen else { return .empty } + let width = topLevelItems(from: row + 1, to: stream.closeIndex(of: row), in: stream).count + return DerivedProjection(entries: Array(repeating: .unnamed, count: width)) + case "TABLE": + guard let path = path(at: keyword + 1, to: end, in: stream) else { return .empty } + return tableProjection(path.table, schema: path.schema, scope: scope) + default: + return modifyingProjection(at: keyword, to: end, in: stream, scope: scope) + } } - 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 } + // Each CTE reads the ones before it in the list. + private func withList( + from start: Int, to end: Int, in stream: SQLTokenStream, scope: Scope + ) -> (ctes: [CommonTableExpression], next: Int)? { + guard stream.isWord(start, "WITH") else { return nil } + var index = start + 1 + if stream.isWord(index, "RECURSIVE") { index += 1 } + var ctes: [CommonTableExpression] = [] + var visible = scope + while index < end, stream.isName(index) { + let name = stream.name(index) + index += 1 + var declared: [String] = [] + if stream.kind(index) == .openParen { + declared = nameList(at: index, in: stream) + index = stream.after(index) } - i += 1 + guard stream.isWord(index, "AS") else { break } + index += 1 + if stream.isWord(index, "NOT") { index += 1 } + if stream.isWord(index, "MATERIALIZED") { index += 1 } + guard stream.kind(index) == .openParen else { break } + let projection = projection(ofBodyAt: index, in: stream, scope: visible).declaring(declared) + ctes.append(CommonTableExpression(name: name, open: index, projection: projection)) + visible[name.lowercased()] = projection + index = stream.after(index) + guard stream.kind(index) == .comma else { break } + index += 1 } - return limit + return (ctes, index) } - 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 } + private func mainStatement(from start: Int, to end: Int, in stream: SQLTokenStream, scope: Scope) -> Statement? { + var index = start + var ctes: Scope = [:] + if let list = withList(from: start, to: end, in: stream, scope: scope) { + index = list.next + for cte in list.ctes { + ctes[cte.name.lowercased()] = cte.projection } - i -= 1 } - return -1 + guard index < end else { return nil } + if stream.kind(index) == .openParen || stream.isWord(index, in: Self.queryBodyWords) + || stream.isWord(index, in: Self.modifyingBodyWords) { + return Statement(keyword: index, ctes: ctes) + } + return nil } - 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 + private func firstSelect(from start: Int, to end: Int, in stream: SQLTokenStream) -> Int? { + guard let statement = mainStatement(from: start, to: end, in: stream, scope: [:]) else { return nil } + let keyword = statement.keyword + if stream.kind(keyword) == .openParen { + return firstSelect(from: keyword + 1, to: stream.closeIndex(of: keyword), in: stream) + } + return stream.isWord(keyword, "SELECT") ? keyword : nil + } + + private func selectProjection(at select: Int, to end: Int, in stream: SQLTokenStream, scope: Scope) -> DerivedProjection { + let listStart = selectListStart(after: select, to: end, in: stream) + let listEnd = firstTopLevel(Self.selectListEnders, from: listStart, to: end, in: stream) ?? end + let outputs = topLevelItems(from: listStart, to: listEnd, in: stream).map { output(of: $0, in: stream) } + var items = FromItems() + // A set operation ends the branch, so the next branch's FROM never feeds this one's `*`. + if outputs.contains(where: { if case .allColumns = $0 { return true } else { return false } }), + let from = firstTopLevel(Self.selectListEnders.subtracting(["INTO"]), from: listEnd, to: end, in: stream), + stream.isWord(from, "FROM") { + let fromEnd = firstTopLevel(Self.fromListEnders, from: from + 1, to: end, in: stream) ?? end + items = fromItems(from: from + 1, to: fromEnd, in: stream, scope: scope) + } + return DerivedProjection(entries: entries(for: outputs, from: items, writtenTable: nil)) + } + + private func selectListStart(after select: Int, to end: Int, in stream: SQLTokenStream) -> Int { + var index = select + 1 + while index < end { + if stream.isWord(index, "DISTINCT"), stream.isWord(index + 1, "ON"), stream.kind(index + 2) == .openParen { + index = stream.after(index + 2) + } else if stream.isWord(index, in: Self.selectModifiers) { + index += 1 + } else if stream.isWord(index, "TOP"), stream.kind(index + 1) == .number || stream.kind(index + 1) == .openParen { + index += 1 + index = stream.kind(index) == .openParen ? stream.after(index) : index + 1 + if stream.isWord(index, "PERCENT") { index += 1 } + if stream.isWord(index, "WITH"), stream.isWord(index + 1, "TIES") { index += 2 } + } else { + break } - if c == Self.hyphen, i + 1 < limit, ns.character(at: i + 1) == Self.hyphen { - i = skipLineComment(in: ns, from: i, limit: limit) - continue + } + return index + } + + // RETURNING `*` reads the written table and the tables the statement joins to it; MERGE lists its source first. + private func modifyingProjection(at keyword: Int, to end: Int, in stream: SQLTokenStream, scope: Scope) -> DerivedProjection { + guard let written = modifiedTable(of: keyword, to: end, in: stream), + let returning = firstTopLevel(["RETURNING"], from: written.end, to: end, in: stream) else { return .empty } + let target = Source( + exposedName: written.reference.identifier, + tableName: written.reference.tableName, + projection: tableProjection(written.reference.tableName, schema: written.reference.schema, scope: scope) + ) + var items = FromItems(sources: [target], star: Self.spread(target.projection)) + switch stream.tokens[keyword].upper { + case "UPDATE", "DELETE": + let opener = stream.tokens[keyword].upper == "UPDATE" ? "FROM" : "USING" + if let listStart = firstTopLevel([opener], from: written.end, to: returning, in: stream) { + let listEnd = firstTopLevel(["WHERE", "RETURNING"], from: listStart + 1, to: end, in: stream) ?? end + let joined = fromItems(from: listStart + 1, to: listEnd, in: stream, scope: scope) + items = FromItems(sources: items.sources + joined.sources, star: items.star + joined.star) } - if c == Self.slash, i + 1 < limit, ns.character(at: i + 1) == Self.star { - i = skipBlockComment(in: ns, from: i, limit: limit) - continue + case "MERGE": + if stream.isWord(written.end, "USING") { + let listEnd = firstTopLevel(["ON"], from: written.end + 1, to: end, in: stream) ?? end + let source = fromItems(from: written.end + 1, to: listEnd, in: stream, scope: scope) + items = FromItems(sources: source.sources + items.sources, star: source.star + items.star) } + default: break } - return i + let outputs = topLevelItems(from: returning + 1, to: end, in: stream).map { output(of: $0, in: stream) } + return DerivedProjection(entries: entries(for: outputs, from: items, writtenTable: target)) } - 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 modifiedTable( + of keyword: Int, to end: Int, in stream: SQLTokenStream + ) -> (reference: TableReference, end: Int)? { + var index = keyword + 1 + switch stream.tokens[keyword].upper { + case "INSERT", "MERGE": + guard stream.isWord(index, "INTO") else { return nil } + index += 1 + case "DELETE": + guard stream.isWord(index, "FROM") else { return nil } + index += 1 + case "UPDATE": + break + default: + return nil + } + if stream.isWord(index, "ONLY") { index += 1 } + guard let path = path(at: index, to: end, in: stream) else { return nil } + index = path.end + if stream.kind(index) == .star { index += 1 } + var alias: (name: String, isQuoted: Bool)? + let explicitAlias = stream.isWord(index, "AS") + let aliasIndex = explicitAlias ? index + 1 : index + // INSERT takes an alias only after AS, so `INSERT INTO t VALUES` never reads VALUES as one. + if stream.isName(aliasIndex), explicitAlias || stream.tokens[keyword].upper != "INSERT", + stream.kind(aliasIndex) == .quoted || !Self.nonAliasWords.contains(stream.tokens[aliasIndex].upper) { + alias = (stream.name(aliasIndex), stream.kind(aliasIndex) == .quoted) + index = aliasIndex + 1 + } + let reference = TableReference( + tableName: path.table, + alias: alias?.name, + schema: path.schema, + isIdentifierQuoted: alias?.isQuoted ?? path.isQuoted + ) + return (reference, index) } - 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) + // `*` reads the items in order, except that a USING join merges its join columns. + private func fromItems(from start: Int, to end: Int, in stream: SQLTokenStream, scope: Scope) -> FromItems { + var items = FromItems() + var index = start + var isNatural = false + while index < end { + while stream.isWord(index, "LATERAL") || stream.isWord(index, "ONLY") { index += 1 } + guard index < end else { break } + var factor = FromItems() + if stream.kind(index) == .openParen { + let close = stream.after(index) - 1 + if isQueryBody(index, in: stream) { + let body = projection(ofBodyAt: index, in: stream, scope: scope) + if let alias = alias(after: close, in: stream) { + factor = FromItems(source: Source( + exposedName: alias.name, tableName: nil, projection: body.declaring(alias.declared) + )) + index = alias.end + } else { + index = close + 1 + } + } else { + factor = fromItems(from: index + 1, to: min(close, end), in: stream, scope: scope) + index = close + 1 + } + } else if let path = path(at: index, to: end, in: stream) { + index = path.end + if stream.kind(index) == .openParen { + let close = stream.after(index) - 1 + index = close + 1 + if let alias = alias(after: close, in: stream) { + let output = DerivedProjection(entries: [], declaredNames: alias.declared) + factor = FromItems(source: Source(exposedName: alias.name, tableName: nil, projection: output)) + index = alias.end + } + } else { + if stream.kind(index) == .star { index += 1 } + let alias = alias(after: index - 1, in: stream) + let table = tableProjection(path.table, schema: path.schema, scope: scope) + factor = FromItems(source: Source( + exposedName: alias?.name ?? path.table, + tableName: path.table, + projection: table.declaring(alias?.declared ?? []) + )) + index = alias?.end ?? index + } + } + var using: [String]? + if stream.isWord(index, "USING"), stream.kind(index + 1) == .openParen { + using = nameList(at: index + 1, in: stream) + } + items = items.joining(factor, using: using, natural: isNatural) + (index, isNatural) = nextFromItem(after: index, to: end, in: stream) } - if c == Self.hyphen, i + 1 < limit, ns.character(at: i + 1) == Self.hyphen { - return skipLineComment(in: ns, from: i, limit: limit) + return items + } + + // The next item starts after a comma or a join word; ON and USING conditions in between are skipped. + private func nextFromItem(after start: Int, to end: Int, in stream: SQLTokenStream) -> (index: Int, isNatural: Bool) { + var index = start + var isNatural = false + while index < end { + switch stream.tokens[index].kind { + case .openParen: + index = stream.after(index) + continue + case .comma: + return (index + 1, false) + case .word where Self.joinWords.contains(stream.tokens[index].upper): + return (index + 1, isNatural) + case .word where stream.tokens[index].upper == "NATURAL": + isNatural = true + default: + break + } + index += 1 } - if c == Self.slash, i + 1 < limit, ns.character(at: i + 1) == Self.star { - return skipBlockComment(in: ns, from: i, limit: limit) + return (end, false) + } + + private func tableProjection(_ table: String, schema: String?, scope: Scope) -> DerivedProjection { + if schema == nil, let cte = scope[table.lowercased()] { + return DerivedProjection(entries: [.nested(cte)]) } - return nil + return DerivedProjection(entries: [.allColumns(DerivedProjection.Table(name: table, schema: schema))]) } - 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 + private func entries(for outputs: [ItemOutput], from items: FromItems, writtenTable: Source?) -> [DerivedProjection.Entry] { + var entries: [DerivedProjection.Entry] = [] + for output in outputs { + switch output { + case .named(let name, _, _): + entries.append(.column(name)) + case .unnamed: + entries.append(.unnamed) + case .allColumns(nil): + entries += items.star + case .allColumns(let qualifier?): + entries += Self.spread(source(named: qualifier, in: items.sources, writtenTable: writtenTable)) } - i += 1 } - return limit + return entries + } + + private static func spread(_ projection: DerivedProjection) -> [DerivedProjection.Entry] { + projection.declaredNames.isEmpty ? projection.entries : [.nested(projection)] } - 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 source(named qualifier: String, in sources: [Source], writtenTable: Source?) -> DerivedProjection { + if let match = sources.first(where: { $0.exposedName.caseInsensitiveCompare(qualifier) == .orderedSame }) + ?? sources.first(where: { $0.tableName?.caseInsensitiveCompare(qualifier) == .orderedSame }) { + return match.projection + } + // PostgreSQL 18 names the written row OLD and NEW in RETURNING. + if let writtenTable, ["OLD", "NEW"].contains(qualifier.uppercased()) { + return writtenTable.projection + } + return DerivedProjection(entries: [.allColumns(DerivedProjection.Table(name: qualifier, schema: nil))]) } - 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 + // MARK: - Items + + private func output(of item: Range, in stream: SQLTokenStream) -> ItemOutput { + let start = item.lowerBound + let end = item.upperBound + guard start < end else { return .unnamed } + let last = end - 1 + if stream.kind(last) == .star { + if start == last { return .allColumns(qualifier: nil) } + if stream.kind(last - 1) == .dot, isPath(start..<(last - 1), in: stream) { + return .allColumns(qualifier: stream.name(last - 2)) } - i += 1 + return .unnamed } - return limit + if end - start >= 3, stream.isWord(last - 1, "AS"), stream.isName(last) || stream.kind(last) == .literal { + let name = stream.name(last) + return name.isEmpty ? .unnamed : .named(name, spelling: stream.text(last), isAlias: true) + } + if isPath(item, in: stream) { + return .named(stream.name(last), spelling: stream.text(last), isAlias: false) + } + if end - start >= 2, isImplicitAlias(last, in: stream), endsExpression(last - 1, in: stream) { + return .named(stream.name(last), spelling: stream.text(last), isAlias: true) + } + return .unnamed } - private func isWhitespace(_ c: unichar) -> Bool { - c == Self.space || c == Self.tab || c == Self.newline || c == Self.cr + private func isPath(_ range: Range, in stream: SQLTokenStream) -> Bool { + guard !range.isEmpty, range.count % 2 == 1 else { return false } + return range.enumerated().allSatisfy { offset, index in + offset % 2 == 0 ? stream.isName(index) : stream.kind(index) == .dot + } } - private func isWordStart(_ c: unichar) -> Bool { - (c >= 0x41 && c <= 0x5A) || (c >= 0x61 && c <= 0x7A) || c == Self.underscore + private func isImplicitAlias(_ index: Int, in stream: SQLTokenStream) -> Bool { + switch stream.kind(index) { + case .quoted: return true + case .word: return !Self.notImplicitAliases.contains(stream.tokens[index].upper.lowercased()) + default: return false + } } - private func isWordChar(_ c: unichar) -> Bool { - isWordStart(c) || (c >= 0x30 && c <= 0x39) + private func endsExpression(_ index: Int, in stream: SQLTokenStream) -> Bool { + switch stream.kind(index) { + case .closeParen, .literal, .number, .quoted: + return true + case .word: + let upper = stream.tokens[index].upper + return Self.valueKeywords.contains(upper) || !Self.notImplicitAliases.contains(upper.lowercased()) + case .other: + return stream.text(index) == "]" + default: + return false + } + } + + // MARK: - Scanning + + private func path( + at start: Int, to end: Int, in stream: SQLTokenStream + ) -> (table: String, schema: String?, isQuoted: Bool, end: Int)? { + guard start < end, stream.isName(start) else { return nil } + guard stream.kind(start) == .quoted || !Self.nonAliasWords.contains(stream.tokens[start].upper) else { return nil } + var segments = [start] + var index = start + 1 + while index + 1 < end, stream.kind(index) == .dot, stream.isName(index + 1) { + segments.append(index + 1) + index += 2 + } + let table = segments[segments.count - 1] + let schema = segments.count >= 2 ? stream.name(segments[segments.count - 2]) : nil + return (stream.name(table), schema, stream.kind(table) == .quoted, index) + } + + private func alias(after close: Int, in stream: SQLTokenStream) -> (name: String, declared: [String], end: Int)? { + var index = close + 1 + if stream.isWord(index, "AS") { index += 1 } + guard stream.isName(index), + stream.kind(index) == .quoted || !Self.nonAliasWords.contains(stream.tokens[index].upper) else { return nil } + let name = stream.name(index) + guard !name.isEmpty else { return nil } + guard stream.kind(index + 1) == .openParen else { return (name, [], index + 1) } + return (name, nameList(at: index + 1, in: stream), stream.after(index + 1)) + } + + private func nameList(at open: Int, in stream: SQLTokenStream) -> [String] { + topLevelItems(from: open + 1, to: stream.closeIndex(of: open), in: stream).compactMap { item in + guard let first = item.first, stream.isName(first) else { return nil } + let name = stream.name(first) + return name.isEmpty ? nil : name + } + } + + private func firstTopLevel(_ words: Set, from start: Int, to end: Int, in stream: SQLTokenStream) -> Int? { + var index = start + while index < end { + let token = stream.tokens[index] + if token.kind == .openParen { + index = stream.after(index) + continue + } + if token.kind == .word, words.contains(token.upper) { return index } + index += 1 + } + return nil + } + + private func topLevelItems(from start: Int, to end: Int, in stream: SQLTokenStream) -> [Range] { + guard start < end else { return [] } + var items: [Range] = [] + var itemStart = start + var index = start + while index < end { + switch stream.tokens[index].kind { + case .openParen: + index = min(stream.after(index), end) + continue + case .comma: + items.append(itemStart.. [SQLCompletionItem] { + let columnNames = Set(columns.map(\.label)) + let foldedColumnNames = Set(columns.map { $0.label.lowercased() }) + var seen = Set() + return spellings.compactMap { spelling in + let name = SQLTokenStream.unquoted(spelling) + // A quoted alias keeps its case, so `"Status"` is another name than a `status` column. + let isQuoted = name != spelling + let repeatsColumn = isQuoted ? columnNames.contains(name) : foldedColumnNames.contains(name.lowercased()) + guard !name.isEmpty, !repeatsColumn, seen.insert(spelling).inserted else { return nil } + return SQLCompletionItem( + label: name, + kind: .column, + insertText: insideQuotes ? name : identifierSpelling(of: spelling, name: name), + sortPriority: 10 + ) + } + } + + // `AS 'Total'` names the column on MySQL and SQLite, but ORDER BY 'Total' sorts by a constant. + private func identifierSpelling(of spelling: String, name: String) -> String { + guard spelling.hasPrefix("'") else { return spelling } + let isBare = name.unicodeScalars.first.map { CharacterSet.letters.contains($0) || $0 == "_" } == true + && name.unicodeScalars.allSatisfy { CharacterSet.alphanumerics.contains($0) || $0 == "_" } + guard !isBare || SQLKeywords.isKeyword(name) else { return name } + // The analyzer reads a backslash-escaped `"` as a string, which is where a backtick quotes a name instead. + let quote = grammar.isSuperset(of: [.backslashEscapesInDoubleQuotes, .backtickQuotes]) ? "`" : "\"" + return quote + name.replacingOccurrences(of: quote, with: quote + quote) + quote + } + + // CTEs were bound to their own scope by the parser, so what is left to expand is a table the catalog knows. + private func derivedColumns(of reference: TableReference) async -> [String] { + guard let projection = reference.derivedProjection else { return reference.derivedColumns ?? [] } + var expanded: [DerivedProjection.Table: [String]] = [:] + for table in projection.tables where expanded[table] == nil { + expanded[table] = await catalog?.columnCompletionItems(for: table.name, schema: table.schema).map(\.label) ?? [] + } + // The catalog answers no columns for a table it does not know, which must not count as a zero-width `*`. + return projection.columns { table in expanded[table].flatMap { $0.isEmpty ? nil : $0 } } } private func filterKeywords(_ keywords: [String]) -> [SQLCompletionItem] { diff --git a/apple/Packages/TableProCore/Sources/TableProAutocomplete/SQLContextAnalyzer.swift b/apple/Packages/TableProCore/Sources/TableProAutocomplete/SQLContextAnalyzer.swift index ad21bdd111..2ebb8e6d55 100644 --- a/apple/Packages/TableProCore/Sources/TableProAutocomplete/SQLContextAnalyzer.swift +++ b/apple/Packages/TableProCore/Sources/TableProAutocomplete/SQLContextAnalyzer.swift @@ -53,6 +53,8 @@ public struct TableReference: Hashable, Sendable { public let derivedColumns: [String]? // Whether `identifier` was quoted in the statement, since a qualifier inserted bare would fold to another name. public let isIdentifierQuoted: Bool + // Set while a `*` in the derived table still needs the catalog; the provider resolves it into `derivedColumns`. + let derivedProjection: DerivedProjection? public init( tableName: String, @@ -66,6 +68,26 @@ public struct TableReference: Hashable, Sendable { self.schema = schema self.derivedColumns = derivedColumns self.isIdentifierQuoted = isIdentifierQuoted + self.derivedProjection = nil + } + + init(tableName: String, alias: String?, schema: String?, projection: DerivedProjection, isIdentifierQuoted: Bool) { + self.tableName = tableName + self.alias = alias + self.schema = schema + self.derivedColumns = projection.columns { _ in nil } + self.isIdentifierQuoted = isIdentifierQuoted + self.derivedProjection = projection.tables.isEmpty ? nil : projection + } + + func resolvingDerivedColumns(_ columns: [String]) -> TableReference { + TableReference( + tableName: tableName, + alias: alias, + schema: schema, + derivedColumns: columns, + isIdentifierQuoted: isIdentifierQuoted + ) } public var identifier: String { @@ -105,6 +127,8 @@ public struct SQLContext: Sendable { public let expectsObjectName: Bool // The table-operand slot of FROM, JOIN or INTO public let comparisonColumn: String? // The column on the other side of a comparison the cursor is a value in public let quotedName: SQLQuotedName? + public let outputAliases: [String] // SELECT-list aliases an ORDER BY at the cursor may name, as written + public let targetTable: TableReference? // The table the INSERT, UPDATE or MERGE at the cursor writes public init( clauseType: SQLClauseType, @@ -120,7 +144,9 @@ public struct SQLContext: Sendable { isAfterComma: Bool = false, expectsObjectName: Bool = false, comparisonColumn: String? = nil, - quotedName: SQLQuotedName? = nil + quotedName: SQLQuotedName? = nil, + outputAliases: [String] = [], + targetTable: TableReference? = nil ) { self.clauseType = clauseType self.prefix = prefix @@ -136,13 +162,24 @@ public struct SQLContext: Sendable { self.expectsObjectName = expectsObjectName self.comparisonColumn = comparisonColumn self.quotedName = quotedName + self.outputAliases = outputAliases + self.targetTable = targetTable } public func replacingTableReferences(_ references: [TableReference]) -> SQLContext { + copy(tableReferences: references, offset: 0) + } + + // The same context read against a larger text the analyzed one starts `offset` units into. + public func offsettingRanges(by offset: Int) -> SQLContext { + copy(tableReferences: tableReferences, offset: offset) + } + + private func copy(tableReferences references: [TableReference], offset: Int) -> SQLContext { SQLContext( clauseType: clauseType, prefix: prefix, - prefixRange: prefixRange, + prefixRange: (prefixRange.lowerBound + offset)..<(prefixRange.upperBound + offset), dotPrefix: dotPrefix, tableReferences: references, isInsideString: isInsideString, @@ -153,7 +190,9 @@ public struct SQLContext: Sendable { isAfterComma: isAfterComma, expectsObjectName: expectsObjectName, comparisonColumn: comparisonColumn, - quotedName: quotedName + quotedName: quotedName.map { SQLQuotedName(open: $0.open + offset, close: $0.close.map { $0 + offset }) }, + outputAliases: outputAliases, + targetTable: targetTable ) } @@ -189,6 +228,7 @@ public final class SQLContextAnalyzer { // The linear DML clauses are left to `scanLinearClause`: a fixed-priority sweep cannot tell the clause nearest the // cursor from an earlier one. Order matters here, the more specific pattern first. private static let structuralClauseRegexes: [(regex: NSRegularExpression, clause: SQLClauseType)] = { + let insertTarget = "\(quotedSegment)(?:\\.\(quotedSegment))*(?:\\s+AS\\s+\(quotedSegment))?" let patterns: [(String, SQLClauseType)] = [ ("\\bADD\\s+(?:COLUMN\\s+)?[`\"']?\\w+[`\"']?\\s+\\w+.*?\\b(?:AFTER|BEFORE)(?:\\s+\\w*)?$", .alterTableColumn), @@ -222,19 +262,13 @@ public final class SQLContextAnalyzer { ("\\b(LIMIT|OFFSET)\\s+\\d*$", .limit), ("\\bVALUES\\s*(?:\\([^)]*\\)\\s*,?\\s*)+\\w*$", .values), ("\\bVALUES\\s*\\([^)]*$", .values), - ("\\bINSERT\\s+INTO\\s+\\w+\\s*\\([^)]*$", .insertColumns), + ("\\bINSERT\\s+INTO\\s+\(insertTarget)\\s*\\([^)]*$", .insertColumns), ] return patterns.map { pattern, clause in (compileRegex(pattern, options: .caseInsensitive), clause) } }() - private static let cteFirstRegex = compileRegex( - "(?i)\\bWITH\\s+(?:RECURSIVE\\s+)?([\\w]+)\\s+AS\\s*\\(" - ) - - private static let cteCommaRegex = compileRegex("(?i),\\s*([\\w]+)\\s+AS\\s*\\(") - // Read on the code view, where a quoted name's content is masked to word characters, so a segment may be quoted // and hold any character. private static let quotedSegment = "[`\"'\\[]?\\w+[`\"'\\]]?" @@ -302,10 +336,10 @@ public final class SQLContextAnalyzer { var tableReferences = extractTableReferences(from: text.forReferences) - let cteNames = extractCTENames(from: text) - - let derivedTables = Self.derivedTableParser.parse(located.sql) - mergeDerivedTables(derivedTables, cteNames: cteNames, into: &tableReferences) + let tokens = Self.tokens(of: text, grammar: grammar) + let derivedTables = Self.derivedTableParser.visibleTables(in: tokens, cursor: cursor) + let cteNames = derivedTables.filter(\.isCommonTableExpression).map(\.alias) + mergeDerivedTables(derivedTables, into: &tableReferences) var seenReferences = Set(tableReferences) @@ -344,6 +378,14 @@ public final class SQLContextAnalyzer { let isCastTarget = endsWithCastOperator(text.code, before: clauseEnd) let comparisonColumn = comparisonTarget(in: text, before: clauseEnd) + let outputAliases = resolution.clause == .orderBy + ? Self.derivedTableParser.outputAliases(in: tokens, cursor: clauseEnd) + : [] + let targetTable = resolution.clause == .set || resolution.clause == .insertColumns + ? Self.derivedTableParser.targetTable(in: tokens, cursor: clauseEnd).map { + writtenTable($0, among: tableReferences) + } + : nil return SQLContext( clauseType: isCastTarget ? .castTarget : resolution.clause, @@ -359,13 +401,24 @@ public final class SQLContextAnalyzer { isAfterComma: isAfterComma, expectsObjectName: resolution.expectsObjectName, comparisonColumn: comparisonColumn, - quotedName: quotedName + quotedName: quotedName, + outputAliases: outputAliases, + targetTable: targetTable ) } // MARK: - Lexing - private enum CursorState: Equatable { + static func tokens(of text: LexedStatement, grammar: SQLLexicalGrammar) -> SQLTokenStream { + let references = text.forReferences + return SQLTokenStream( + code: references.code, + original: references.original, + bracketQuotes: grammar.contains(.bracketQuotedIdentifiers) + ) + } + + enum CursorState: Equatable { case code case comment case literal @@ -374,7 +427,7 @@ public final class SQLContextAnalyzer { // `code` blanks every comment and literal body and masks every quoted name's content to `_`, offsets kept, so a // scan or pattern reads structure from it and a name from `original` at the same range. - private struct LexedStatement { + struct LexedStatement { let original: NSString let code: NSString let cursor: CursorState @@ -396,7 +449,7 @@ public final class SQLContextAnalyzer { } } - private static func lex(_ statement: NSString, cursor: Int, grammar: SQLLexicalGrammar) -> LexedStatement { + static func lex(_ statement: NSString, cursor: Int, grammar: SQLLexicalGrammar) -> LexedStatement { let length = statement.length var units = [UInt16](repeating: 0, count: length) statement.getCharacters(&units, range: NSRange(location: 0, length: length)) @@ -529,31 +582,6 @@ public final class SQLContextAnalyzer { } } - // MARK: - CTE Support - - private func extractCTENames(from text: LexedStatement) -> [String] { - var cteNames: [String] = [] - let nsRange = NSRange(location: 0, length: text.code.length) - - if let match = Self.cteFirstRegex.firstMatch(in: text.codeString, range: nsRange) { - let nameNSRange = match.range(at: 1) - if nameNSRange.location != NSNotFound { - cteNames.append(text.original.substring(with: nameNSRange)) - } - } - - Self.cteCommaRegex.enumerateMatches(in: text.codeString, range: nsRange) { match, _, _ in - if let match = match { - let nameNSRange = match.range(at: 1) - if nameNSRange.location != NSNotFound { - cteNames.append(text.original.substring(with: nameNSRange)) - } - } - } - - return cteNames - } - // MARK: - Subquery Support private func calculateNestingLevel(in code: NSString, end: Int) -> Int { @@ -750,7 +778,7 @@ public final class SQLContextAnalyzer { private static let tableRefKeywords: Set = [ "LEFT", "RIGHT", "INNER", "OUTER", "FULL", "CROSS", "NATURAL", - "JOIN", "ON", "AND", "OR", "WHERE", "SELECT", "FROM", "AS", "SET" + "JOIN", "ON", "AND", "OR", "WHERE", "SELECT", "FROM", "AS", "SET", "LATERAL" ] private static let bareIdentifierRegex = compileRegex("^\\w+$") @@ -888,25 +916,31 @@ public final class SQLContextAnalyzer { // Columns attach to a matching reference, such as a CTE aliased in FROM, and the derived table or CTE is still // registered under its own name so it resolves by either identifier. - private func mergeDerivedTables( - _ derivedTables: [DerivedTable], - cteNames: [String], - into references: inout [TableReference] - ) { - let derivedColumnsByAlias = Dictionary( - derivedTables.map { ($0.alias.lowercased(), $0.columns) }, + // SQL Server writes `UPDATE o SET ... FROM orders o`, naming the table by its FROM alias. + private func writtenTable(_ target: TableReference, among references: [TableReference]) -> TableReference { + guard target.schema == nil, let aliased = references.first(where: { + !$0.isDerived && $0.alias?.caseInsensitiveCompare(target.tableName) == .orderedSame + }) else { return target } + return aliased + } + + private func mergeDerivedTables(_ derivedTables: [DerivedTable], into references: inout [TableReference]) { + let projectionsByAlias = Dictionary( + derivedTables.map { ($0.alias.lowercased(), $0.projection) }, uniquingKeysWith: { first, _ in first } ) for index in references.indices { let ref = references[index] + // A schema-qualified name is a real table even when a CTE shares its name. + guard ref.schema == nil else { continue } for key in [ref.tableName.lowercased(), ref.identifier.lowercased()] { - guard let columns = derivedColumnsByAlias[key] else { continue } + guard let projection = projectionsByAlias[key] else { continue } references[index] = TableReference( tableName: ref.tableName, alias: ref.alias, - schema: ref.schema, - derivedColumns: columns, + schema: nil, + projection: projection, isIdentifierQuoted: ref.isIdentifierQuoted ) break @@ -915,12 +949,13 @@ public final class SQLContextAnalyzer { var present = Set(references.map { $0.identifier.lowercased() }) 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)) + references.append(TableReference( + tableName: derived.alias, + alias: derived.alias, + schema: nil, + projection: derived.projection, + isIdentifierQuoted: false + )) } } diff --git a/apple/Packages/TableProCore/Tests/TableProAutocompleteTests/DerivedTableParserTests.swift b/apple/Packages/TableProCore/Tests/TableProAutocompleteTests/DerivedTableParserTests.swift index dcc4f1f19f..e5c55f0c01 100644 --- a/apple/Packages/TableProCore/Tests/TableProAutocompleteTests/DerivedTableParserTests.swift +++ b/apple/Packages/TableProCore/Tests/TableProAutocompleteTests/DerivedTableParserTests.swift @@ -1,7 +1,17 @@ import Foundation @testable import TableProAutocomplete +import TableProSQLGrammar import Testing +private extension DerivedTableParser { + func parse(_ statement: String, cursor: Int? = nil, grammar: SQLLexicalGrammar = .ansi) -> [DerivedTable] { + let text = statement as NSString + let position = cursor ?? text.length + let lexed = SQLContextAnalyzer.lex(text, cursor: position, grammar: grammar) + return visibleTables(in: SQLContextAnalyzer.tokens(of: lexed, grammar: grammar), cursor: position) + } +} + struct DerivedTableParserTests { let parser = DerivedTableParser() @@ -9,6 +19,10 @@ struct DerivedTableParserTests { tables.first { $0.alias.caseInsensitiveCompare(alias) == .orderedSame } } + private func starTables(_ table: DerivedTable?) -> [String] { + table?.projection.tables.map { [$0.schema, $0.name].compactMap { $0 }.joined(separator: ".") } ?? [] + } + @Test("Resolves the reported derived-table join") func resolvesReportedQuery() { let query = """ @@ -49,10 +63,11 @@ struct DerivedTableParserTests { #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("SELECT star names no columns itself and leaves its table to the catalog") + func selectStarLeavesItsTableToTheCatalog() { + let d = table(parser.parse("SELECT * FROM (SELECT * FROM users) d"), alias: "d") + #expect(d?.columns.isEmpty == true) + #expect(starTables(d) == ["users"]) } @Test("Qualified star is skipped") @@ -124,4 +139,225 @@ struct DerivedTableParserTests { 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"]) } + + // MARK: - CTE bodies + + @Test("A MATERIALIZED CTE reads its body", arguments: ["MATERIALIZED", "NOT MATERIALIZED"]) + func materializedCte(_ hint: String) { + let query = "WITH recent AS \(hint) (SELECT id, total FROM orders) SELECT r.id FROM recent r" + let recent = table(parser.parse(query), alias: "recent") + #expect(recent?.columns == ["id", "total"]) + #expect(recent?.isCommonTableExpression == true) + } + + @Test("A data-modifying CTE outputs its RETURNING list", arguments: [ + "DELETE FROM queue WHERE done RETURNING id, payload", + "UPDATE queue SET done = true RETURNING id, payload", + "INSERT INTO queue (id, payload) VALUES (1, 'a') RETURNING id, payload", + "INSERT INTO queue SELECT id, payload FROM staging RETURNING id, payload", + ]) + func dataModifyingCte(_ body: String) { + let moved = table(parser.parse("WITH moved AS (\(body)) SELECT m.id FROM moved m"), alias: "moved") + #expect(moved?.columns == ["id", "payload"]) + } + + @Test("A data-modifying CTE without RETURNING outputs nothing") + func dataModifyingCteWithoutReturning() { + let gone = table(parser.parse("WITH gone AS (DELETE FROM queue WHERE done) SELECT 1"), alias: "gone") + #expect(gone?.columns.isEmpty == true) + #expect(starTables(gone).isEmpty) + } + + @Test("RETURNING star reads every table the statement returns rows of", arguments: [ + ("INSERT INTO queue (id) VALUES (9) RETURNING *", ["queue"]), + ("INSERT INTO public.queue SELECT * FROM staging RETURNING *", ["public.queue"]), + ("UPDATE queue q SET done = true FROM owners o WHERE o.qid = q.id RETURNING *", ["queue", "owners"]), + ("DELETE FROM queue q USING owners o WHERE o.qid = q.id RETURNING *", ["queue", "owners"]), + ("MERGE INTO queue q USING owners o ON o.qid = q.id WHEN MATCHED THEN DELETE RETURNING *", ["owners", "queue"]), + ]) + func returningStar(_ body: String, _ tables: [String]) { + #expect(starTables(table(parser.parse("WITH m AS (\(body)) SELECT 1"), alias: "m")) == tables) + } + + @Test("A qualified RETURNING star reads only its own table") + func qualifiedReturningStar() { + let query = "WITH m AS (DELETE FROM queue q USING owners o WHERE o.qid = q.id RETURNING q.*, o.owner) SELECT 1" + let m = table(parser.parse(query), alias: "m") + #expect(starTables(m) == ["queue"]) + #expect(m?.columns == ["owner"]) + } + + @Test("A RETURNING OLD or NEW star reads the written table") + func returningOldNewStar() { + let query = "WITH m AS (UPDATE queue SET done = true RETURNING old.*, new.done AS now_done) SELECT 1" + let m = table(parser.parse(query), alias: "m") + #expect(starTables(m) == ["queue"]) + #expect(m?.columns == ["now_done"]) + } + + @Test("A VALUES body takes the CTE's column list") + func valuesBody() { + let query = "WITH v (a, b) AS (VALUES (1, 'x'), (2, 'y')) SELECT * FROM v" + #expect(table(parser.parse(query), alias: "v")?.columns == ["a", "b"]) + } + + @Test("A shorter column list renames the leading columns and keeps the rest") + func partialColumnList() { + let query = "WITH m (a) AS (UPDATE queue SET done = true RETURNING id, payload) SELECT * FROM m" + #expect(table(parser.parse(query), alias: "m")?.columns == ["a", "payload"]) + } + + @Test("An unnamed expression still holds its position for the column list") + func columnListOverUnnamedExpression() { + let query = "WITH c (n, label) AS (SELECT count(*), name FROM t GROUP BY name) SELECT * FROM c" + #expect(table(parser.parse(query), alias: "c")?.columns == ["n", "label"]) + } + + @Test("A nested WITH body outputs its main query") + func nestedWithBody() { + let query = "WITH a AS (WITH b AS (SELECT 1 AS x) SELECT x, x + 1 AS y FROM b) SELECT * FROM a" + let tables = parser.parse(query) + #expect(table(tables, alias: "a")?.columns == ["x", "y"]) + #expect(table(tables, alias: "b") == nil) + } + + @Test("A star over a CTE of the same body reads that CTE") + func starOverInnerCte() { + let query = "WITH a AS (WITH b AS (SELECT 1 AS x, 2 AS y) SELECT * FROM b) SELECT * FROM a" + #expect(table(parser.parse(query), alias: "a")?.columns == ["x", "y"]) + } + + @Test("A star over an earlier CTE of the list reads that CTE") + func starOverSiblingCte() { + let query = "WITH a AS (SELECT x FROM t1), b AS (SELECT * FROM a) SELECT * FROM b" + let b = table(parser.parse(query), alias: "b") + #expect(b?.columns == ["x"]) + #expect(starTables(b).isEmpty) + } + + @Test("A TABLE body reads its table") + func tableBody() { + #expect(starTables(table(parser.parse("WITH t AS (TABLE queue) SELECT * FROM t"), alias: "t")) == ["queue"]) + } + + @Test("A star over a join reads both tables, and a qualified star its own") + func starOverJoin() { + let query = """ + WITH c AS (SELECT * FROM queue q JOIN owners o ON o.qid = q.id), + d AS (SELECT o.*, q.id FROM queue q LEFT JOIN sales.owners AS o ON o.qid = q.id) + SELECT * FROM c, d + """ + let tables = parser.parse(query) + #expect(starTables(table(tables, alias: "c")) == ["queue", "owners"]) + #expect(starTables(table(tables, alias: "d")) == ["sales.owners"]) + #expect(table(tables, alias: "d")?.columns == ["id"]) + } + + @Test("A star over a derived table reads that derived table's columns") + func starOverDerivedTable() { + let query = "WITH c AS (SELECT * FROM (SELECT id AS a, name AS b FROM t) s) SELECT * FROM c" + #expect(table(parser.parse(query), alias: "c")?.columns == ["a", "b"]) + } + + // MARK: - Derived tables + + @Test("A derived table's column list renames its columns", arguments: [ + ("SELECT * FROM (SELECT price, qty FROM items) AS x(p, q)", ["p", "q"]), + ("SELECT * FROM (SELECT price, qty FROM items) x (p)", ["p", "qty"]), + ("SELECT * FROM (VALUES (1, 'a'), (2, 'b')) AS x(id, name)", ["id", "name"]), + ]) + func derivedColumnList(_ query: String, _ columns: [String]) { + #expect(table(parser.parse(query), alias: "x")?.columns == columns) + } + + @Test("A LATERAL derived table is read", arguments: [ + "SELECT * FROM users u CROSS JOIN LATERAL (SELECT count(*) AS n FROM orders o WHERE o.uid = u.id) l", + "SELECT * FROM users u, LATERAL (SELECT count(*) AS n FROM orders o WHERE o.uid = u.id) l", + "SELECT * FROM users u LEFT JOIN LATERAL (SELECT count(*) AS n FROM orders) l ON true", + ]) + func lateralDerivedTable(_ query: String) { + #expect(table(parser.parse(query), alias: "l")?.columns == ["n"]) + } + + @Test("A scalar subquery in the SELECT list is not a derived table") + func scalarSubqueryIsNotDerived() { + #expect(parser.parse("SELECT a, (SELECT b FROM t) AS x FROM u").isEmpty) + } + + @Test("A derived table inside another is visible from inside it") + func nestedDerivedTableVisibleInside() { + let query = "SELECT * FROM (SELECT s.a FROM (SELECT id AS a FROM t) s) outer_q" + let cursor = (query as NSString).range(of: "s.a").location + 2 + let tables = parser.parse(query, cursor: cursor) + #expect(table(tables, alias: "s")?.columns == ["a"]) + #expect(table(tables, alias: "outer_q") != nil) + } + + @Test("Implicit aliases name their columns") + func implicitAliases() { + let query = """ + SELECT * FROM ( + SELECT count(*) total, CASE WHEN a THEN 1 END status, x::int, 1 one, interval '1' day, price "Price" + FROM t + ) d + """ + #expect(table(parser.parse(query), alias: "d")?.columns == ["total", "status", "one", "Price"]) + } + + @Test("SELECT TOP leaves the list's names intact") + func selectTop() { + let query = "WITH c AS (SELECT TOP 10 id, name FROM t ORDER BY id) SELECT * FROM c" + #expect(table(parser.parse(query, grammar: .engine("SQL Server")), alias: "c")?.columns == ["id", "name"]) + } + + @Test("A quote or parenthesis inside a literal never ends the body", arguments: [ + ("SELECT * FROM (SELECT 'it\\'s' AS label, id FROM t) d", "MySQL"), + ("SELECT * FROM (SELECT $$ ) $$ AS label, id FROM t) d", "PostgreSQL"), + ("SELECT * FROM (SELECT ') -- (' AS label, id /* ) */ FROM t) d", "PostgreSQL"), + ]) + func literalsAreSkipped(_ query: String, _ engine: String) { + #expect(table(parser.parse(query, grammar: .engine(engine)), alias: "d")?.columns == ["label", "id"]) + } + + @Test("A column named top is not a TOP modifier") + func columnNamedTop() { + #expect(table(parser.parse("WITH c AS (SELECT top, id FROM orders) SELECT * FROM c"), alias: "c")?.columns == ["top", "id"]) + } + + @Test("A column list survives a star the catalog cannot read", arguments: [ + ("WITH c (a, b) AS (SELECT * FROM orders) SELECT * FROM c", ["a", "b"]), + ("WITH c (a) AS (SELECT *, total * 2 AS doubled FROM orders) SELECT * FROM c", ["a", "doubled"]), + ]) + func columnListOverUnreadStar(_ query: String, _ columns: [String]) { + #expect(table(parser.parse(query), alias: "c")?.columns == columns) + } + + @Test("A star reads a CTE only where that CTE is in scope") + func starBindsInItsOwnScope() { + let query = """ + WITH totals AS (SELECT 1 AS n) + SELECT d.n FROM (SELECT * FROM orders) d, (SELECT 1 AS fake) orders, (SELECT * FROM totals) t + """ + let tables = parser.parse(query) + #expect(starTables(table(tables, alias: "d")) == ["orders"]) + #expect(table(tables, alias: "t")?.columns == ["n"]) + } + + @Test("A CTE never reads itself, so a same-named table stays the table") + func cteDoesNotReadItself() { + let query = "WITH orders AS (SELECT * FROM orders WHERE total > 0) SELECT * FROM orders" + #expect(starTables(table(parser.parse(query), alias: "orders")) == ["orders"]) + } + + @Test("Quoted names differing only in case are two columns") + func caseDistinctQuotedNames() { + let query = "WITH c (\"value\", \"Value\") AS (SELECT 1, 2) SELECT * FROM c" + #expect(table(parser.parse(query), alias: "c")?.columns == ["value", "Value"]) + } + + @Test("A quoted CTE name and alias keep their spelling") + func quotedCteName() { + let query = "WITH \"My CTE\" AS (SELECT 1 AS \"Odd Name\") SELECT * FROM \"My CTE\"" + #expect(table(parser.parse(query), alias: "My CTE")?.columns == ["Odd Name"]) + } } diff --git a/apple/Packages/TableProCore/Tests/TableProAutocompleteTests/SQLCompletionProviderCatalogTests.swift b/apple/Packages/TableProCore/Tests/TableProAutocompleteTests/SQLCompletionProviderCatalogTests.swift index d42b020d39..8c1c3a645b 100644 --- a/apple/Packages/TableProCore/Tests/TableProAutocompleteTests/SQLCompletionProviderCatalogTests.swift +++ b/apple/Packages/TableProCore/Tests/TableProAutocompleteTests/SQLCompletionProviderCatalogTests.swift @@ -34,7 +34,12 @@ private struct StubCatalog: SQLCompletionCatalog { func allowedValues(forColumn column: String, in references: [TableReference]) -> [String] { [] } func allColumnsInScope(for references: [TableReference]) -> [SQLCompletionItem] { - references.flatMap { columnCompletionItems(for: $0.tableName, schema: $0.schema) } + references.flatMap { reference in + guard let derived = reference.derivedColumns else { + return columnCompletionItems(for: reference.tableName, schema: reference.schema) + } + return derived.map { SQLCompletionItem.column($0, dataType: nil, tableName: reference.identifier) } + } } func allColumnsFromCachedTables() -> [SQLCompletionItem] { [] } @@ -42,11 +47,21 @@ private struct StubCatalog: SQLCompletionCatalog { struct SQLCompletionProviderCatalogTests { private let provider = SQLCompletionProvider( - catalog: StubCatalog(tables: ["customers", "orders"], columns: ["orders": ["id", "total"]]), + catalog: StubCatalog( + tables: ["customers", "orders"], + columns: ["orders": ["id", "total", "status"], "customers": ["id", "name"]] + ), grammar: .engine("SQLite"), vocabulary: .generic ) + // `|` marks the cursor. + private func labels(_ marked: String) async -> [String] { + let marker = (marked as NSString).range(of: "|") + let text = (marked as NSString).replacingCharacters(in: marker, with: "") + return await provider.completionSession(text: text, cursorPosition: marker.location).items.map(\.label) + } + @Test("A table slot leads with the table the prefix names") func tableSlotLeadsWithTheTable() async { let session = await provider.completionSession(text: "SELECT * FROM or", cursorPosition: 16) @@ -56,7 +71,7 @@ struct SQLCompletionProviderCatalogTests { @Test("An alias with a dot offers its table's columns") func aliasDotOffersColumns() async { let session = await provider.completionSession(text: "SELECT o. FROM orders o", cursorPosition: 9) - #expect(Set(session.items.map(\.label)) == ["id", "total"]) + #expect(Set(session.items.map(\.label)) == ["id", "total", "status"]) } @Test("UPDATE offers tables") @@ -83,4 +98,184 @@ struct SQLCompletionProviderCatalogTests { #expect(custom.statementStartCompletionItems().map(\.label) == ["FIND"]) #expect(provider.statementStartCompletionItems().contains { $0.label == "SELECT" }) } + + // MARK: - CTEs and derived tables + + @Test("A MATERIALIZED CTE offers its columns", arguments: ["MATERIALIZED", "NOT MATERIALIZED"]) + func materializedCteColumns(_ hint: String) async { + let labels = await labels("WITH recent AS \(hint) (SELECT id, total FROM orders) SELECT r.| FROM recent r") + #expect(Set(labels) == ["id", "total"]) + } + + @Test("A data-modifying CTE offers its RETURNING columns") + func dataModifyingCteColumns() async { + let labels = await labels("WITH moved AS (DELETE FROM queue WHERE done RETURNING id, payload) SELECT m.| FROM moved m") + #expect(Set(labels) == ["id", "payload"]) + } + + @Test("RETURNING star offers the written table's columns from the catalog") + func returningStarColumns() async { + let dotted = await labels("WITH moved AS (DELETE FROM orders WHERE status = 'done' RETURNING *) SELECT m.| FROM moved m") + #expect(Set(dotted) == ["id", "total", "status"]) + let listed = await labels("WITH moved AS (DELETE FROM orders RETURNING *) SELECT | FROM moved") + #expect(Set(["id", "total", "status"]).isSubset(of: Set(listed))) + } + + @Test("A column list renames a star's columns by position") + func columnListOverStar() async { + let labels = await labels("WITH moved (order_id) AS (DELETE FROM orders RETURNING *) SELECT m.| FROM moved m") + #expect(Set(labels) == ["order_id", "total", "status"]) + } + + @Test("A star over another CTE reads that CTE") + func starOverSiblingCte() async { + let labels = await labels("WITH a AS (SELECT * FROM orders), b AS (SELECT * FROM a) SELECT b.| FROM b") + #expect(Set(labels) == ["id", "total", "status"]) + } + + @Test("A CTE that reads its own name reads the table, not itself") + func cteShadowingItsTable() async { + let labels = await labels("WITH orders AS (SELECT * FROM orders WHERE total > 0) SELECT o.| FROM orders o") + #expect(Set(labels) == ["id", "total", "status"]) + } + + @Test("A derived table over a star offers its table's columns") + func derivedStarColumns() async { + let labels = await labels("SELECT x.| FROM (SELECT * FROM orders) x") + #expect(Set(labels) == ["id", "total", "status"]) + } + + @Test("A USING join lists its join column once, so a column list renames the right columns", arguments: [ + ("WITH c AS (SELECT * FROM a JOIN b USING (id)) SELECT c.| FROM c", ["id", "x", "y"]), + ("WITH c (key, ax, by) AS (SELECT * FROM a JOIN b USING (id)) SELECT c.| FROM c", ["key", "ax", "by"]), + ("WITH c (key, ax) AS (SELECT * FROM a, b) SELECT c.| FROM c", ["key", "ax", "y", "id"]), + ("WITH c AS (SELECT * FROM a NATURAL JOIN b) SELECT c.| FROM c", ["id", "x", "y"]), + ("WITH c (key, ax, by) AS (SELECT * FROM a NATURAL LEFT JOIN b) SELECT c.| FROM c", ["key", "ax", "by"]), + ]) + func usingJoinStar(_ marked: String, _ columns: [String]) async { + let provider = SQLCompletionProvider( + catalog: StubCatalog(tables: ["a", "b"], columns: ["a": ["x", "id"], "b": ["y", "id"]]), + grammar: .engine("PostgreSQL"), + vocabulary: .generic + ) + let marker = (marked as NSString).range(of: "|") + let text = (marked as NSString).replacingCharacters(in: marker, with: "") + let labels = await provider.completionSession(text: text, cursorPosition: marker.location).items.map(\.label) + #expect(labels.count == columns.count) + #expect(Set(labels) == Set(columns)) + } + + @Test("SET inside a CTE offers the UPDATE table's columns") + func setInsideCte() async { + let labels = await labels("WITH u AS (UPDATE orders SET st| WHERE id = 1 RETURNING *) SELECT * FROM u") + #expect(labels.first == "status") + } + + @Test("SET offers the UPDATE table's columns, not a FROM table's") + func setWithFromTable() async { + let labels = await labels("UPDATE orders SET | FROM customers c WHERE c.id = orders.id") + #expect(labels.contains("total")) + #expect(!labels.contains("name")) + } + + @Test("SET reads a table the UPDATE names by its FROM alias") + func setThroughFromAlias() async { + let labels = await labels("UPDATE o SET st| FROM orders o JOIN customers c ON c.id = o.id") + #expect(labels.first == "status") + } + + @Test("A star expands the real table, not a derived table of the same name") + func starIgnoresSiblingDerivedName() async { + let labels = await labels("SELECT d.| FROM (SELECT * FROM orders) d, (SELECT 1 AS fake) orders") + #expect(Set(labels) == ["id", "total", "status"]) + } + + @Test("An INSERT column list offers the INSERT table's columns, not the SELECT's") + func insertColumnsWithSelect() async { + let labels = await labels("INSERT INTO orders (|) SELECT id, name FROM customers") + #expect(labels.contains("total")) + #expect(!labels.contains("name")) + } + + // MARK: - ORDER BY + + @Test("ORDER BY offers a SELECT alias ahead of the table columns") + func orderByAliasLeads() async { + let empty = await labels("SELECT total * 2 AS line_total FROM orders ORDER BY |") + #expect(empty.first == "line_total") + #expect(empty.contains("total")) + let typed = await labels("SELECT total * 2 AS line_total FROM orders ORDER BY li|") + #expect(typed.first == "line_total") + } + + @Test("ORDER BY inserts a quoted alias as written") + func orderByQuotedAlias() async { + let marked = "SELECT total AS \"Grand Total\" FROM orders ORDER BY Gr|" + let marker = (marked as NSString).range(of: "|") + let text = (marked as NSString).replacingCharacters(in: marker, with: "") + let items = await provider.completionSession(text: text, cursorPosition: marker.location).items + #expect(items.first?.label == "Grand Total") + #expect(items.first?.insertText == "\"Grand Total\"") + } + + @Test("Inside an opened quote ORDER BY inserts the alias's bare name") + func orderByAliasInsideQuotes() async { + let marked = "SELECT total AS \"Grand Total\" FROM orders ORDER BY \"Gr|\"" + let marker = (marked as NSString).range(of: "|") + let text = (marked as NSString).replacingCharacters(in: marker, with: "") + let items = await provider.completionSession(text: text, cursorPosition: marker.location).items + #expect(items.first?.label == "Grand Total") + #expect(items.first?.insertText == "Grand Total") + } + + @Test("A string-literal alias is inserted as a quoted name", arguments: [ + ("SQLite", "\"Grand Total\""), + ("MySQL", "`Grand Total`"), + ]) + func orderByLiteralAlias(_ engine: String, _ insertText: String) async { + let provider = SQLCompletionProvider(catalog: nil, grammar: .engine(engine), vocabulary: .generic) + let text = "SELECT total AS 'Grand Total', 1 AS 'plain' FROM orders ORDER BY " + let items = await provider.completionSession(text: text, cursorPosition: (text as NSString).length).items + #expect(items.first { $0.label == "Grand Total" }?.insertText == insertText) + #expect(items.first { $0.label == "plain" }?.insertText == "plain") + } + + @Test("An opened quote with no close is replaced along with the name, so the alias keeps its quotes") + func orderByAliasAfterOpenQuote() async { + let text = "SELECT total AS \"Grand Total\" FROM orders ORDER BY \"Gr" + let items = await provider.completionSession(text: text, cursorPosition: (text as NSString).length).items + #expect(items.first?.label == "Grand Total") + #expect(items.first?.insertText == "\"Grand Total\"") + } + + @Test("A quoted alias differing only in case from a column is still offered") + func orderByQuotedAliasCase() async { + let marked = "SELECT total AS \"Status\" FROM orders ORDER BY St|" + let marker = (marked as NSString).range(of: "|") + let text = (marked as NSString).replacingCharacters(in: marker, with: "") + let items = await provider.completionSession(text: text, cursorPosition: marker.location).items + #expect(items.first?.insertText == "\"Status\"") + #expect(items.contains { $0.label == "status" }) + } + + @Test("A string-literal alias that is a keyword is inserted quoted") + func orderByKeywordLiteralAlias() async { + let provider = SQLCompletionProvider(catalog: nil, grammar: .engine("SQLite"), vocabulary: .generic) + let text = "SELECT 1 AS 'group' FROM orders ORDER BY gr" + let items = await provider.completionSession(text: text, cursorPosition: (text as NSString).length).items + #expect(items.first { $0.label == "group" }?.insertText == "\"group\"") + } + + @Test("ORDER BY offers an alias that repeats a column name once") + func orderByAliasMatchingColumn() async { + let labels = await labels("SELECT status AS status, total FROM orders ORDER BY |") + #expect(labels.filter { $0 == "status" }.count == 1) + } + + @Test("ORDER BY inside an aggregate offers no output alias") + func orderByInsideAggregate() async { + let labels = await labels("SELECT string_agg(status, ',' ORDER BY |) AS statuses FROM orders") + #expect(!labels.contains("statuses")) + #expect(labels.contains("status")) + } } diff --git a/apple/Packages/TableProCore/Tests/TableProAutocompleteTests/SQLContextAnalyzerStatementShapeTests.swift b/apple/Packages/TableProCore/Tests/TableProAutocompleteTests/SQLContextAnalyzerStatementShapeTests.swift new file mode 100644 index 0000000000..2add4c25a1 --- /dev/null +++ b/apple/Packages/TableProCore/Tests/TableProAutocompleteTests/SQLContextAnalyzerStatementShapeTests.swift @@ -0,0 +1,102 @@ +import Foundation +@testable import TableProAutocomplete +import Testing + +struct SQLContextAnalyzerStatementShapeTests { + let analyzer = SQLContextAnalyzer() + + // `|` marks the cursor. + private func context(_ marked: String) -> SQLContext { + let marker = (marked as NSString).range(of: "|") + let text = (marked as NSString).replacingCharacters(in: marker, with: "") + return analyzer.analyze(query: text, cursorPosition: marker.location) + } + + @Test("SET reads the UPDATE's own table, not a FROM table", arguments: [ + "UPDATE orders SET st| FROM customers c WHERE c.id = orders.customer_id", + "WITH u AS (UPDATE orders SET st| WHERE id = 1 RETURNING *) SELECT * FROM u", + "WITH c AS (SELECT * FROM customers) UPDATE orders o SET st| FROM c WHERE c.id = o.customer_id", + ]) + func setTarget(_ marked: String) { + let context = context(marked) + #expect(context.clauseType == .set) + #expect(context.targetTable?.tableName == "orders") + } + + @Test("ON CONFLICT DO UPDATE SET reads the INSERT's table") + func onConflictTarget() { + let context = context("INSERT INTO orders (id) VALUES (1) ON CONFLICT (id) DO UPDATE SET st|") + #expect(context.clauseType == .set) + #expect(context.targetTable?.tableName == "orders") + } + + @Test("An INSERT column list reads the INSERT's table, not the SELECT's") + func insertColumnsTarget() { + let context = context("INSERT INTO orders (|) SELECT id, name FROM customers") + #expect(context.clauseType == .insertColumns) + #expect(context.targetTable?.tableName == "orders") + } + + @Test("An INSERT column list follows a qualified or quoted table") + func qualifiedInsertColumns() { + let context = context("INSERT INTO sales.\"Orders\" AS o (id, |") + #expect(context.clauseType == .insertColumns) + #expect(context.targetTable?.tableName == "Orders") + #expect(context.targetTable?.schema == "sales") + } + + @Test("ORDER BY carries the SELECT list's aliases as written") + func orderByAliases() { + let context = context("SELECT price * qty AS line_total, name, count(*) \"Total\" FROM items ORDER BY li|") + #expect(context.clauseType == .orderBy) + #expect(context.outputAliases == ["line_total", "\"Total\""]) + } + + @Test("ORDER BY reads the aliases of its own query level", arguments: [ + ("WITH c AS (SELECT 1 AS inner_alias) SELECT a AS outer_alias FROM c ORDER BY |", ["outer_alias"]), + ("SELECT * FROM (SELECT a AS inner_alias FROM t ORDER BY |) d", ["inner_alias"]), + ("SELECT a AS first_alias FROM t UNION SELECT b AS second_alias FROM u ORDER BY |", ["first_alias"]), + ]) + func orderByLevel(_ marked: String, _ aliases: [String]) { + #expect(context(marked).outputAliases == aliases) + } + + @Test("ORDER BY inside a call or WITHIN GROUP orders the input, so it carries no alias", arguments: [ + "SELECT string_agg(name, ',' ORDER BY |) AS names FROM items", + "SELECT percentile_cont(0.5) WITHIN GROUP (ORDER BY |) AS median FROM items", + ]) + func orderByInsideCall(_ marked: String) { + let context = context(marked) + #expect(context.clauseType == .orderBy) + #expect(context.outputAliases.isEmpty) + } + + @Test("LATERAL is never read as a table") + func lateralIsNotATable() { + let context = context("SELECT l.| FROM users u CROSS JOIN LATERAL (SELECT 1 AS n) l, LATERAL (SELECT 2 AS m) k") + #expect(!context.tableReferences.contains { $0.tableName.uppercased() == "LATERAL" }) + #expect(context.tableReferences.first { $0.identifier == "l" }?.derivedColumns == ["n"]) + } + + @Test("CTE names include MATERIALIZED and column-list CTEs") + func cteNamesForEveryShape() { + let context = context(""" + WITH a AS MATERIALIZED (SELECT 1), b (x) AS (VALUES (1)), c AS (DELETE FROM t RETURNING id) SELECT | + """) + #expect(context.cteNames == ["a", "b", "c"]) + } + + @Test("A RETURNING star keeps its table for the catalog") + func returningStarProjection() { + let context = context("WITH moved AS (DELETE FROM queue RETURNING *) SELECT m.| FROM moved m") + let moved = context.tableReferences.first { $0.identifier == "m" } + #expect(moved?.isDerived == true) + #expect(moved?.derivedProjection?.tables.map { $0.name } == ["queue"]) + } + + @Test("A schema-qualified table is never a CTE of the same name") + func qualifiedTableIsNotCte() { + let context = context("WITH orders AS (SELECT 1 AS n) SELECT o.| FROM public.orders o") + #expect(context.tableReferences.first { $0.identifier == "o" }?.isDerived == false) + } +} diff --git a/apple/TablePro/Core/Autocomplete/CompletionEngine.swift b/apple/TablePro/Core/Autocomplete/CompletionEngine.swift index 7f27cbcae0..2e710c2d80 100644 --- a/apple/TablePro/Core/Autocomplete/CompletionEngine.swift +++ b/apple/TablePro/Core/Autocomplete/CompletionEngine.swift @@ -148,29 +148,13 @@ final class CompletionEngine { return nil } - // Calculate replacement range — translate back to original document - // positions by adding windowOffset let replaceStart = context.prefixRange.lowerBound + windowOffset let replaceEnd = context.prefixRange.upperBound + windowOffset let replacementRange = NSRange( location: replaceStart, length: replaceEnd - replaceStart ) - // Build a context with prefixRange adjusted back to original positions - let adjustedContext = SQLContext( - clauseType: context.clauseType, - prefix: context.prefix, - prefixRange: replaceStart.. Date: Wed, 21 Oct 2026 01:28:26 +0700 Subject: [PATCH 2/2] docs(changelog): note CTE column completion and the SET target fix --- apple/changelog.d/3713.added.md | 1 + apple/changelog.d/3713.fixed.md | 1 + 2 files changed, 2 insertions(+) create mode 100644 apple/changelog.d/3713.added.md create mode 100644 apple/changelog.d/3713.fixed.md diff --git a/apple/changelog.d/3713.added.md b/apple/changelog.d/3713.added.md new file mode 100644 index 0000000000..c25dfb9b61 --- /dev/null +++ b/apple/changelog.d/3713.added.md @@ -0,0 +1 @@ +Autocomplete offers columns of MATERIALIZED, VALUES and data-modifying CTEs, and SELECT aliases after ORDER BY. diff --git a/apple/changelog.d/3713.fixed.md b/apple/changelog.d/3713.fixed.md new file mode 100644 index 0000000000..7ceaa0c2b1 --- /dev/null +++ b/apple/changelog.d/3713.fixed.md @@ -0,0 +1 @@ +SET and an INSERT column list offered columns of a joined or selected table instead of the table being written.