diff --git a/CHANGELOG.md b/CHANGELOG.md index ef86fec3c..612401059 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - iCloud Sync between the iPhone and Mac apps: the iOS app now uses the Production CloudKit environment, so a development build no longer syncs into a separate database the Mac never reads. - Exports no longer fail mid-table on servers that enforce a statement time limit; the export session disables the limit and restores it afterwards, the same way mysqldump does. (#1633) - Refreshing a table now reloads its data even when the previous load is still running; before, the refresh was silently dropped and the grid kept stale rows. (#1637) +- SQL autocomplete now suggests tables after JOIN. It detects the clause at the cursor across multi-join and multi-clause queries, so columns no longer appear where a table is expected, and tables lead the list. (#1646) ### Security diff --git a/TablePro/Core/Autocomplete/CompletionEngine.swift b/TablePro/Core/Autocomplete/CompletionEngine.swift index 16b74c48e..adb8f104a 100644 --- a/TablePro/Core/Autocomplete/CompletionEngine.swift +++ b/TablePro/Core/Autocomplete/CompletionEngine.swift @@ -154,7 +154,8 @@ final class CompletionEngine { cteNames: context.cteNames, nestingLevel: context.nestingLevel, currentFunction: context.currentFunction, - isAfterComma: context.isAfterComma + isAfterComma: context.isAfterComma, + expectsObjectName: context.expectsObjectName ) return CompletionContext( diff --git a/TablePro/Core/Autocomplete/SQLCompletionProvider.swift b/TablePro/Core/Autocomplete/SQLCompletionProvider.swift index a397eba1e..43aab2b20 100644 --- a/TablePro/Core/Autocomplete/SQLCompletionProvider.swift +++ b/TablePro/Core/Autocomplete/SQLCompletionProvider.swift @@ -251,6 +251,16 @@ final class SQLCompletionProvider { items += filterKeywords([ "AND", "OR", "NOT", "IS", "NULL", "TRUE", "FALSE" ]) + // Continuations once the join condition is written: another join or + // the next clause. Without these, typing the next keyword (e.g. a + // second INNER JOIN) only fuzzy-matches columns. + items += filterKeywords([ + "INNER JOIN", "LEFT JOIN", "RIGHT JOIN", "FULL JOIN", + "LEFT OUTER JOIN", "RIGHT OUTER JOIN", "FULL OUTER JOIN", + "CROSS JOIN", "NATURAL JOIN", "JOIN", + "WHERE", "ORDER BY", "GROUP BY", "HAVING", "LIMIT", + "UNION", "INTERSECT", "EXCEPT" + ]) case .where_, .and, .having: // HP-8: Columns, operators, logical keywords + clause transitions @@ -739,11 +749,16 @@ final class SQLCompletionProvider { score -= 1_000 } - // When prefix is empty and tables are in scope, user is at a clause - // transition point (e.g., "FROM users |" or "WHERE id > 1 |"). - // Boost keywords so they appear alongside context-specific items. + // When prefix is empty and tables are in scope, the user is either in a + // table-operand slot (e.g. "... JOIN |") or at a clause transition point + // (e.g. "FROM users |" or "WHERE id > 1 |"). In the operand slot, tables + // lead; otherwise keywords lead so clause transitions surface. if prefix.isEmpty && !context.tableReferences.isEmpty && !context.isAfterComma { - if item.kind == .keyword { + if context.expectsObjectName { + if item.kind == .table || item.kind == .view || item.kind == .schema { + score -= 300 + } + } else if item.kind == .keyword { score -= 300 } } else { diff --git a/TablePro/Core/Autocomplete/SQLContextAnalyzer.swift b/TablePro/Core/Autocomplete/SQLContextAnalyzer.swift index e238316fc..4efe9887f 100644 --- a/TablePro/Core/Autocomplete/SQLContextAnalyzer.swift +++ b/TablePro/Core/Autocomplete/SQLContextAnalyzer.swift @@ -84,6 +84,7 @@ struct SQLContext { let nestingLevel: Int // Subquery nesting level (0 = main query) let currentFunction: String? // If inside function args, the function name let isAfterComma: Bool // True if immediately after a comma + let expectsObjectName: Bool // Cursor is in the table-operand slot of FROM/JOIN/INTO init( clauseType: SQLClauseType, @@ -96,7 +97,8 @@ struct SQLContext { cteNames: [String] = [], nestingLevel: Int = 0, currentFunction: String? = nil, - isAfterComma: Bool = false + isAfterComma: Bool = false, + expectsObjectName: Bool = false ) { self.clauseType = clauseType self.prefix = prefix @@ -109,6 +111,7 @@ struct SQLContext { self.nestingLevel = nestingLevel self.currentFunction = currentFunction self.isAfterComma = isAfterComma + self.expectsObjectName = expectsObjectName } func replacingTableReferences(_ references: [TableReference]) -> SQLContext { @@ -123,7 +126,8 @@ struct SQLContext { cteNames: cteNames, nestingLevel: nestingLevel, currentFunction: currentFunction, - isAfterComma: isAfterComma + isAfterComma: isAfterComma, + expectsObjectName: expectsObjectName ) } } @@ -150,9 +154,14 @@ final class SQLContextAnalyzer { // MARK: - Cached Regex Patterns (Compiled Once at Class Load) - /// Pre-compiled clause detection patterns for performance - /// ORDER MATTERS: More specific patterns must come before general ones - private static let clauseRegexes: [(regex: NSRegularExpression, clause: SQLClauseType)] = { + /// Structural clause patterns that are bounded by parentheses or anchored to a + /// leading DDL keyword, so they identify the cursor's clause unambiguously + /// regardless of what other clauses precede it. The linear DML clauses + /// (SELECT/FROM/JOIN/ON/WHERE/...) are resolved separately by a nearest-keyword + /// scan (`scanLinearClause`), because a fixed-priority regex sweep cannot tell + /// the clause nearest the cursor from one earlier in the statement. + /// ORDER MATTERS: more specific patterns must come before general ones. + private static let structuralClauseRegexes: [(regex: NSRegularExpression, clause: SQLClauseType)] = { let patterns: [(String, SQLClauseType)] = [ // DDL patterns (most specific first) ("\\bADD\\s+(?:COLUMN\\s+)?[`\"']?\\w+[`\"']?\\s+\\w+.*?\\b(?:AFTER|BEFORE)(?:\\s+\\w*)?$", @@ -183,42 +192,17 @@ final class SQLContextAnalyzer { ("\\bCREATE\\s+(?:OR\\s+REPLACE\\s+)?(?:MATERIALIZED\\s+)?VIEW\\s+\\w+\\s+AS\\s+[^;]*$", .createView), ("\\bCREATE\\s+(?:OR\\s+REPLACE\\s+)?(?:MATERIALIZED\\s+)?VIEW\\s+\\w*$", .createView), - // RETURNING clause (PostgreSQL) - ("\\bRETURNING\\s+[^;]*$", .returning), - // UNION/INTERSECT/EXCEPT - ("\\b(?:UNION|INTERSECT|EXCEPT)\\s+(?:ALL\\s+)?\\w*$", .union), // USING clause in JOIN ("\\bUSING\\s*\\([^)]*$", .using), // Window function OVER clause ("\\bOVER\\s*\\([^)]*$", .window), ("\\bPARTITION\\s+BY\\s+[^)]*$", .window), - // Enhanced context patterns + // Parenthesis-bounded value/list contexts ("\\bIN\\s*\\([^)]*$", .inList), - ("\\bCASE\\s+(?:WHEN\\s+[^;]*)?$", .caseExpression), ("\\b(LIMIT|OFFSET)\\s+\\d*$", .limit), - // Standard clause patterns ("\\bVALUES\\s*(?:\\([^)]*\\)\\s*,?\\s*)+\\w*$", .values), ("\\bVALUES\\s*\\([^)]*$", .values), ("\\bINSERT\\s+INTO\\s+\\w+\\s*\\([^)]*$", .insertColumns), - ("\\bINSERT\\s+INTO\\s+[`\"']?\\w+[`\"']?\\s*$", .into), - ("\\bINTO\\s+\\w*$", .into), - ("\\bSET\\s+[^;]*$", .set), - ("\\bHAVING\\s+[^;]*$", .having), - ("\\bORDER\\s+BY\\s+[^;]*$", .orderBy), - ("\\bGROUP\\s+BY\\s+[^;]*$", .groupBy), - ("\\b(AND|OR)\\s+\\w*$", .and), - ("\\bWHERE\\s+[^;]*$", .where_), - ("\\bON\\s+[^;]*$", .on), - // JOIN patterns - ("(?:LEFT|RIGHT|INNER|OUTER|FULL|CROSS)?\\s*(?:OUTER)?\\s*JOIN\\s+[`\"']?\\w+[`\"']?" + - "(?:\\s+(?:AS\\s+)?\\w+)?\\s*$", .join), - ("\\bJOIN\\s+[`\"']?\\w*[`\"']?\\s*$", .join), - // FROM patterns - ("\\bFROM\\s+[`\"']?\\w+[`\"']?(?:\\s+(?:AS\\s+)?\\w+)?\\s*$", .from), - ("\\bFROM\\s+\\w*$", .from), - ("\\bFROM\\s*$", .from), - // SELECT is most general - ("\\bSELECT\\s+[^;]*$", .select), ] return patterns.map { pattern, clause in (compileRegex(pattern, options: .caseInsensitive), clause) @@ -248,7 +232,6 @@ final class SQLContextAnalyzer { let path = "(\(segment)(?:\\.\(segment))*)" let alias = "(?:\\s+(?:AS\\s+)?[`\"']?(\\w+)[`\"']?)?" let patterns = [ - "(?i)\\bFROM\\s+\(path)\(alias)", "(?i)(?:LEFT|RIGHT|INNER|OUTER|CROSS|FULL)?\\s*(?:OUTER)?\\s*JOIN\\s+\(path)\(alias)", "(?i)\\bUPDATE\\s+\(path)\(alias)", "(?i)\\bINSERT\\s+INTO\\s+\(path)", @@ -257,6 +240,17 @@ final class SQLContextAnalyzer { return patterns.map { compileRegex($0) } }() + /// Captures the comma-separated table list after a FROM keyword, up to the + /// next clause keyword, opening parenthesis, or statement end. Handles + /// old-style implicit joins (`FROM a, b, c`) that the single-table pattern + /// above could not, so every listed table is in scope for column completion. + private static let fromListRegex = compileRegex( + "(?i)\\bFROM\\s+([\\s\\S]+?)" + + "(?=\\b(?:WHERE|GROUP|ORDER|HAVING|LIMIT|OFFSET|JOIN|INNER|LEFT|RIGHT" + + "|FULL|CROSS|NATURAL|ON|USING|UNION|INTERSECT|EXCEPT|RETURNING|SET" + + "|WINDOW|FETCH|FOR)\\b|[;()]|$)" + ) + // MARK: - UTF-16 Helpers /// Check if a UTF-16 code unit is whitespace (space, tab, newline, CR) @@ -353,14 +347,14 @@ final class SQLContextAnalyzer { } // Determine clause type - let clauseType = determineClauseType( + let resolution = determineClauseType( textBeforeCursor: clauseText, dotPrefix: dotPrefix, currentFunction: currentFunction ) return SQLContext( - clauseType: clauseType, + clauseType: resolution.clause, prefix: prefix, prefixRange: (statementOffset + prefixStart).. Bool { + let range = NSRange(location: 0, length: (value as NSString).length) + return bareIdentifierRegex.firstMatch(in: value, range: range) != nil + } + private static let identifierQuoteChars = CharacterSet(charactersIn: "`\"'") /// Extract all table references (table names and aliases) from the query @@ -729,6 +731,8 @@ final class SQLContextAnalyzer { var references: [TableReference] = [] var seen = Set() + appendFromListReferences(from: query, into: &references, seen: &seen) + let nsRange = NSRange(location: 0, length: (query as NSString).length) for regex in Self.tableRefRegexes { @@ -770,6 +774,62 @@ final class SQLContextAnalyzer { return references } + /// Parse each comma-separated entry in a FROM list into a table reference. + private func appendFromListReferences( + from query: String, + into references: inout [TableReference], + seen: inout Set + ) { + let nsQuery = query as NSString + let nsRange = NSRange(location: 0, length: nsQuery.length) + Self.fromListRegex.enumerateMatches(in: query, range: nsRange) { match, _, _ in + guard let match = match else { return } + let listRange = match.range(at: 1) + guard listRange.location != NSNotFound else { return } + + let listText = nsQuery.substring(with: listRange) + for entry in listText.split(separator: ",") { + guard let ref = self.tableReference(fromListEntry: String(entry)) else { continue } + if seen.insert(ref).inserted { + references.append(ref) + } + } + } + } + + /// Parse a single `table [AS] alias` entry from a FROM list. + private func tableReference(fromListEntry entry: String) -> TableReference? { + let tokens = entry + .split(whereSeparator: { $0 == " " || $0 == "\t" || $0 == "\n" || $0 == "\r" }) + .map(String.init) + guard let pathToken = tokens.first else { return nil } + + let segments = pathToken.split(separator: ".").map { + String($0).trimmingCharacters(in: Self.identifierQuoteChars) + } + guard let tableName = segments.last, !tableName.isEmpty else { return nil } + // Reject anything that is not a plain identifier path, so a derived-table + // subquery (`FROM (SELECT ...) x`) never yields a phantom table name. + guard segments.allSatisfy(Self.isBareIdentifier) else { return nil } + guard !Self.tableRefKeywords.contains(tableName.uppercased()) else { return nil } + + let schema = segments.count >= 2 ? segments[segments.count - 2] : nil + + var alias: String? + if tokens.count >= 2 { + var candidate = tokens[1] + if candidate.uppercased() == "AS", tokens.count >= 3 { + candidate = tokens[2] + } + let cleaned = candidate.trimmingCharacters(in: Self.identifierQuoteChars) + if !cleaned.isEmpty, !Self.tableRefKeywords.contains(cleaned.uppercased()) { + alias = cleaned + } + } + + return TableReference(tableName: tableName, alias: alias, schema: schema) + } + /// Pre-compiled regex for extracting table name from ALTER TABLE statements private static let alterTableRegex: NSRegularExpression? = { let pattern = "(?i)\\bALTER\\s+TABLE\\s+[`\"']?(\\w+)[`\"']?" @@ -791,18 +851,24 @@ final class SQLContextAnalyzer { return nil } + /// A resolved clause plus whether the cursor sits in a table-operand slot. + private struct ClauseResolution { + let clause: SQLClauseType + let expectsObjectName: Bool + } + /// Determine the clause type based on text before cursor private func determineClauseType( textBeforeCursor: String, dotPrefix: String?, currentFunction: String? = nil - ) -> SQLClauseType { + ) -> ClauseResolution { // If we have a dot prefix, we're looking for columns if dotPrefix != nil { - return .select // Column context + return ClauseResolution(clause: .select, expectsObjectName: false) } - // Window to last N chars to avoid O(n) regex on large queries + // Window to last N chars to avoid O(n) work on large queries let windowSize = 5_000 // Also referenced by SQLContextAnalyzerWindowingTests let nsText = textBeforeCursor as NSString let windowedText: String @@ -815,24 +881,189 @@ final class SQLContextAnalyzer { // Remove string literals and comments for analysis let cleaned = removeStringsAndComments(from: windowedText) - // Run regex-based clause detection FIRST — DDL contexts (CREATE TABLE, - // ALTER TABLE, etc.) must take priority over function-arg detection, - // because `CREATE TABLE test (id ` looks like a function call `test(` - // to detectFunctionContext but is actually a column definition. + // Structural / DDL contexts FIRST. These are anchored to a leading keyword + // or bounded by parentheses, so they take priority over function-arg + // detection (`CREATE TABLE test (id ` looks like a function call `test(` + // but is a column definition) and over the linear-clause scan. let range = NSRange(location: 0, length: (cleaned as NSString).length) - for (regex, clause) in Self.clauseRegexes { + for (regex, clause) in Self.structuralClauseRegexes { if regex.firstMatch(in: cleaned, range: range) != nil { - return clause + return ClauseResolution(clause: clause, expectsObjectName: false) } } + // Linear DML clauses: resolve by the clause keyword nearest the cursor, + // not by a fixed priority order. This is what lets `... ON a = b JOIN |` + // detect JOIN (suggest tables) instead of the earlier ON. + if let resolution = scanLinearClause(in: cleaned) { + return resolution + } + // If inside a function call and no stronger clause matched, return // function arg context if currentFunction != nil { - return .functionArg + return ClauseResolution(clause: .functionArg, expectsObjectName: false) + } + + return ClauseResolution(clause: .unknown, expectsObjectName: false) + } + + // MARK: - Linear Clause Scan + + private enum ScanTokenKind { + case word + case openParen + case closeParen + case comma + case other + } + + private struct ScanToken { + let kind: ScanTokenKind + let upper: String + } + + /// Find the clause introduced by the keyword nearest the cursor. + /// Scans the cleaned text-before-cursor backwards, skipping content inside + /// closed parentheses and balanced `CASE … END`, and passing transparently + /// through an enclosing open parenthesis (a function call or grouping) so the + /// governing clause is still found. Returns nil when no clause keyword governs + /// the cursor (caller falls back to function-arg or unknown). + private func scanLinearClause(in cleaned: String) -> ClauseResolution? { + let tokens = tokenize(cleaned) + var depth = 0 + var pendingEnd = 0 + var sawObject = false + var slotClosed = false + + for index in stride(from: tokens.count - 1, through: 0, by: -1) { + let token = tokens[index] + switch token.kind { + case .closeParen: + depth += 1 + case .openParen: + if depth > 0 { depth -= 1 } + case .other: + continue + case .comma: + // A comma at the cursor's depth closes the current operand slot, + // so a later table in a `FROM a, b` list still expects a name. + if depth == 0 { slotClosed = true } + case .word: + guard depth == 0 else { continue } + let word = token.upper + if word == "END" { + pendingEnd += 1 + slotClosed = true + continue + } + if word == "CASE" { + if pendingEnd > 0 { + pendingEnd -= 1 + slotClosed = true + continue + } + return ClauseResolution(clause: .caseExpression, expectsObjectName: false) + } + guard pendingEnd == 0 else { continue } + let previous = index > 0 ? tokens[index - 1].upper : nil + if let clause = clause(forKeyword: word, previous: previous, sawObject: sawObject) { + return clause + } + if !slotClosed { sawObject = true } + } + } + + return nil + } + + /// Map a clause-introducing keyword to its clause. `sawObject` is true when a + /// non-keyword identifier already appeared between this keyword and the cursor, + /// which means a table/operand was typed and the cursor is past the operand slot. + /// Returns nil for any word that does not introduce a linear clause. + private func clause(forKeyword word: String, previous: String?, sawObject: Bool) -> ClauseResolution? { + switch word { + case "WHEN", "THEN", "ELSE": + return ClauseResolution(clause: .caseExpression, expectsObjectName: false) + case "SELECT": + return ClauseResolution(clause: .select, expectsObjectName: false) + case "FROM": + return ClauseResolution(clause: .from, expectsObjectName: !sawObject) + case "JOIN": + return ClauseResolution(clause: .join, expectsObjectName: !sawObject) + case "INTO": + return ClauseResolution(clause: .into, expectsObjectName: !sawObject) + case "ON": + return ClauseResolution(clause: .on, expectsObjectName: false) + case "WHERE": + return ClauseResolution(clause: .where_, expectsObjectName: false) + case "AND", "OR": + return ClauseResolution(clause: .and, expectsObjectName: false) + case "HAVING": + return ClauseResolution(clause: .having, expectsObjectName: false) + case "SET": + return ClauseResolution(clause: .set, expectsObjectName: false) + case "RETURNING": + return ClauseResolution(clause: .returning, expectsObjectName: false) + case "UNION", "INTERSECT", "EXCEPT": + return ClauseResolution(clause: .union, expectsObjectName: false) + case "BY": + switch previous { + case "GROUP": + return ClauseResolution(clause: .groupBy, expectsObjectName: false) + case "ORDER": + return ClauseResolution(clause: .orderBy, expectsObjectName: false) + case "PARTITION": + return ClauseResolution(clause: .window, expectsObjectName: false) + default: + return nil + } + default: + return nil + } + } + + /// Tokenize text into words, parentheses, commas, and other separators. + /// Words are uppercased for case-insensitive keyword matching. + /// Uses NSString character-at-index for O(1) access per character. + private func tokenize(_ text: String) -> [ScanToken] { + let ns = text as NSString + let length = ns.length + var tokens: [ScanToken] = [] + var wordStart = -1 + + func flushWord(end: Int) { + guard wordStart >= 0 else { return } + let word = ns.substring(with: NSRange(location: wordStart, length: end - wordStart)) + tokens.append(ScanToken(kind: .word, upper: word.uppercased())) + wordStart = -1 + } + + for i in 0.. SQLClauseType { + analyzer.analyze(query: query, cursorPosition: (query as NSString).length).clauseType + } + + private func context(_ query: String) -> SQLContext { + analyzer.analyze(query: query, cursorPosition: (query as NSString).length) + } + + // MARK: - Linear Clause Progression + + @Test("Single-clause queries detect the trailing clause", arguments: [ + (query: "SELECT ", expected: SQLClauseType.select), + (query: "SELECT id FROM ", expected: .from), + (query: "SELECT id FROM users ", expected: .from), + (query: "SELECT id FROM users WHERE ", expected: .where_), + (query: "SELECT id FROM users WHERE id = 1 AND ", expected: .and), + (query: "SELECT id FROM users WHERE id = 1 OR ", expected: .and), + (query: "SELECT id FROM users GROUP BY ", expected: .groupBy), + (query: "SELECT id FROM users ORDER BY ", expected: .orderBy), + (query: "SELECT id FROM users GROUP BY id HAVING ", expected: .having), + (query: "SELECT id FROM users LIMIT ", expected: .limit), + (query: "UPDATE users SET ", expected: .set), + (query: "INSERT INTO ", expected: .into), + (query: "SELECT 1 UNION ", expected: .union), + (query: "SELECT * FROM users RETURNING ", expected: .returning), + ]) + func linearClause(_ testCase: (query: String, expected: SQLClauseType)) { + #expect(clause(testCase.query) == testCase.expected) + } + + // MARK: - Nearest Clause Wins Over an Earlier Clause + + @Test("The clause nearest the cursor wins, not the earlier higher-priority one", arguments: [ + (query: "SELECT * FROM a JOIN b ON a.id = b.id INNER JOIN ", expected: SQLClauseType.join), + (query: "SELECT * FROM a LEFT JOIN b ON a.x = b.x RIGHT JOIN ", expected: .join), + (query: "SELECT * FROM a JOIN b ON a.id = b.id JOIN c ON b.id = c.id INNER JOIN ", expected: .join), + (query: "SELECT * FROM a JOIN b ON a.id = b.id WHERE ", expected: .where_), + (query: "SELECT * FROM a JOIN b ON a.id = b.id AND ", expected: .and), + (query: "UPDATE users SET name = 'x' WHERE ", expected: .where_), + (query: "SELECT * FROM t WHERE a = 1 GROUP BY ", expected: .groupBy), + (query: "SELECT * FROM t GROUP BY x HAVING count(*) > 1 ORDER BY ", expected: .orderBy), + ]) + func nearestClauseWins(_ testCase: (query: String, expected: SQLClauseType)) { + #expect(clause(testCase.query) == testCase.expected) + } + + // MARK: - Case Insensitivity + + @Test("Clause detection is case insensitive", arguments: [ + (query: "select * from a join b on a.id = b.id inner join ", expected: SQLClauseType.join), + (query: "Select Id From Users Where ", expected: .where_), + (query: "select * from t group by x having 1 = 1 order by ", expected: .orderBy), + ]) + func caseInsensitiveClause(_ testCase: (query: String, expected: SQLClauseType)) { + #expect(clause(testCase.query) == testCase.expected) + } + + // MARK: - CASE ... END Boundary + + @Test("CASE expressions and their END boundary are tracked", arguments: [ + (query: "SELECT CASE WHEN ", expected: SQLClauseType.caseExpression), + (query: "SELECT CASE WHEN x = 1 THEN ", expected: .caseExpression), + (query: "SELECT CASE WHEN a THEN b ELSE ", expected: .caseExpression), + (query: "SELECT CASE WHEN a THEN b END, ", expected: .select), + (query: "SELECT CASE WHEN a THEN b END FROM ", expected: .from), + (query: "SELECT CASE WHEN a THEN CASE WHEN b THEN c END END, ", expected: .select), + ]) + func caseExpressionBoundary(_ testCase: (query: String, expected: SQLClauseType)) { + #expect(clause(testCase.query) == testCase.expected) + } + + // MARK: - Parentheses and Subqueries + + @Test("Function calls, closed parens, and subqueries scope clause detection", arguments: [ + (query: "SELECT COUNT(", expected: SQLClauseType.select), + (query: "SELECT * FROM t WHERE fn(x) AND ", expected: .and), + (query: "SELECT SUM(amount) FROM t WHERE ", expected: .where_), + (query: "SELECT (SELECT max(x) FROM b) FROM a ", expected: .from), + (query: "SELECT * FROM t WHERE id IN (SELECT id FROM u WHERE ", expected: .where_), + ]) + func parenthesisScoping(_ testCase: (query: String, expected: SQLClauseType)) { + #expect(clause(testCase.query) == testCase.expected) + } + + // MARK: - Quoted Identifiers + + @Test("A quoted reserved word is an identifier, not a clause keyword", arguments: [ + "SELECT * FROM orders WHERE `order` = ", + "SELECT * FROM orders WHERE \"order\" = ", + ]) + func quotedReservedWordIsNotClause(_ query: String) { + #expect(clause(query) == .where_) + } + + // MARK: - Unknown / Empty + + @Test("Empty or keyword-free text is unknown", arguments: [ + "", + " ", + "\t\n ", + "randomword ", + ]) + func unknownClause(_ query: String) { + #expect(clause(query) == .unknown) + } + + // MARK: - Operand Slot (expectsObjectName) + + @Test("expectsObjectName is true only in a table-operand slot", arguments: [ + (query: "SELECT * FROM ", clause: SQLClauseType.from, expects: true), + (query: "SELECT * FROM users ", clause: .from, expects: false), + (query: "SELECT * FROM a, ", clause: .from, expects: true), + (query: "SELECT * FROM a, b ", clause: .from, expects: false), + (query: "SELECT * FROM users JOIN ", clause: .join, expects: true), + (query: "SELECT * FROM users JOIN orders ", clause: .join, expects: false), + (query: "SELECT * FROM users INNER JOIN ", clause: .join, expects: true), + (query: "INSERT INTO ", clause: .into, expects: true), + (query: "INSERT INTO users ", clause: .into, expects: false), + (query: "SELECT * FROM users WHERE ", clause: .where_, expects: false), + (query: "SELECT ", clause: .select, expects: false), + ]) + func operandSlot(_ testCase: (query: String, clause: SQLClauseType, expects: Bool)) { + let ctx = context(testCase.query) + #expect(ctx.clauseType == testCase.clause) + #expect(ctx.expectsObjectName == testCase.expects) + } + + // MARK: - String and Comment Guards + + @Test("Clause keywords inside a string literal are ignored") + func keywordsInStringIgnored() { + #expect(clause("SELECT * FROM users WHERE name = 'FROM JOIN' AND ") == .and) + } + + @Test("A cursor inside an unterminated string reports no clause") + func cursorInsideStringIsUnknown() { + let ctx = context("SELECT * FROM users WHERE name = 'unclosed ") + #expect(ctx.isInsideString) + #expect(ctx.clauseType == .unknown) + } + + // MARK: - Multi-Statement + + @Test("Detection uses the statement under the cursor") + func multiStatementUsesCurrent() { + let ctx = context("SELECT * FROM a; SELECT * FROM ") + #expect(ctx.clauseType == .from) + #expect(ctx.expectsObjectName) + } + + // MARK: - Table Reference Extraction + + @Test("Comma-separated FROM lists put every table in scope") + func commaSeparatedTables() { + let ctx = context("SELECT * FROM a, b, c WHERE ") + #expect(ctx.clauseType == .where_) + #expect(ctx.tableReferences.contains { $0.tableName == "a" }) + #expect(ctx.tableReferences.contains { $0.tableName == "b" }) + #expect(ctx.tableReferences.contains { $0.tableName == "c" }) + } + + @Test("Aliases in a comma-separated FROM list are captured") + func commaSeparatedAliases() { + let ctx = context("SELECT * FROM users u, orders o WHERE ") + #expect(ctx.tableReferences.contains { $0.tableName == "users" && $0.alias == "u" }) + #expect(ctx.tableReferences.contains { $0.tableName == "orders" && $0.alias == "o" }) + } + + @Test("Schema-qualified tables in a comma list keep their schema") + func commaSeparatedSchemaQualified() { + let ctx = context("SELECT * FROM sales.orders o, hr.staff s WHERE ") + #expect(ctx.tableReferences.contains { $0.tableName == "orders" && $0.schema == "sales" && $0.alias == "o" }) + #expect(ctx.tableReferences.contains { $0.tableName == "staff" && $0.schema == "hr" && $0.alias == "s" }) + } + + @Test("AS alias in a FROM list is captured without keeping AS as the alias") + func fromListAsAlias() { + let ctx = context("SELECT * FROM accounts AS acc WHERE ") + #expect(ctx.tableReferences.contains { $0.tableName == "accounts" && $0.alias == "acc" }) + } + + @Test("FROM and JOIN tables are both in scope") + func fromAndJoinTables() { + let ctx = context("SELECT * FROM a JOIN b ON a.id = b.id WHERE ") + #expect(ctx.tableReferences.contains { $0.tableName == "a" }) + #expect(ctx.tableReferences.contains { $0.tableName == "b" }) + } + + @Test("DELETE FROM keeps its target table in scope") + func deleteFromTarget() { + let ctx = context("DELETE FROM users WHERE ") + #expect(ctx.clauseType == .where_) + #expect(ctx.tableReferences.contains { $0.tableName == "users" }) + } + + @Test("A derived-table subquery does not create a phantom table reference") + func derivedTableNoPhantom() { + let ctx = context("SELECT * FROM (SELECT id FROM inner_t) sub WHERE ") + #expect(ctx.clauseType == .where_) + #expect(ctx.tableReferences.allSatisfy { + !$0.tableName.contains("(") && $0.tableName.uppercased() != "SELECT" + }) + } + + // MARK: - RETURNING After VALUES + + @Test("RETURNING after a VALUES list completes as RETURNING, not VALUES") + func returningAfterValues() { + #expect(clause("INSERT INTO t VALUES (1) RETURNING ") == .returning) + } +} diff --git a/TableProTests/Core/Autocomplete/SQLCompletionProviderTests.swift b/TableProTests/Core/Autocomplete/SQLCompletionProviderTests.swift index 735980a61..40b6d5001 100644 --- a/TableProTests/Core/Autocomplete/SQLCompletionProviderTests.swift +++ b/TableProTests/Core/Autocomplete/SQLCompletionProviderTests.swift @@ -1089,4 +1089,74 @@ struct SQLCompletionProviderTests { let hasFavorite = items.contains { $0.kind == .favorite } #expect(!hasFavorite, "Favorites appear only when the typed token matches their keyword") } + + // MARK: - Tables after JOIN (#1646) + + @Test("JOIN after an ON condition suggests available tables") + func testJoinAfterOnSuggestsTables() async { + await schemaProvider.updateTables([ + TestFixtures.makeTableInfo(name: "happiness_scores"), + TestFixtures.makeTableInfo(name: "country_stats"), + TestFixtures.makeTableInfo(name: "inflation_rates") + ]) + let text = "SELECT * FROM happiness_scores hs " + + "INNER JOIN country_stats cs ON hs.country = cs.country INNER JOIN " + let (items, context) = await provider.getCompletions(text: text, cursorPosition: text.count) + + #expect(context.clauseType == .join) + #expect(items.contains { $0.kind == .table && $0.label == "inflation_rates" }) + } + + @Test("Tables rank first when the cursor is in the JOIN operand slot") + func testTablesRankFirstAfterJoin() async { + await schemaProvider.updateTables([ + TestFixtures.makeTableInfo(name: "country_stats"), + TestFixtures.makeTableInfo(name: "inflation_rates") + ]) + let text = "SELECT * FROM happiness_scores hs " + + "INNER JOIN country_stats cs ON hs.country = cs.country INNER JOIN " + let (items, _) = await provider.getCompletions(text: text, cursorPosition: text.count) + + #expect(items.first?.kind == .table) + } + + @Test("ON clause offers clause-transition keywords for the next join or filter") + func testOnClauseOffersTransitionKeywords() async { + let text = "SELECT * FROM a JOIN b ON a.id = b.id " + let (items, context) = await provider.getCompletions(text: text, cursorPosition: text.count) + + #expect(context.clauseType == .on) + #expect(items.contains { $0.label == "INNER JOIN" }) + #expect(items.contains { $0.label == "WHERE" }) + } + + @Test("Typing a second join after an ON condition surfaces INNER JOIN first") + func testSecondJoinKeywordSurfacesAfterOn() async { + let text = "SELECT * FROM a JOIN b ON a.id = b.id INNE" + let (items, context) = await provider.getCompletions(text: text, cursorPosition: text.count) + + #expect(context.clauseType == .on) + #expect(items.first?.label == "INNER JOIN") + } + + @Test("Comma-separated FROM scopes columns to every listed table") + func testCommaFromScopesColumnsToAllTables() async { + let driver = MockDatabaseDriver() + driver.tablesToReturn = [ + TestFixtures.makeTableInfo(name: "users"), + TestFixtures.makeTableInfo(name: "orders") + ] + driver.columnsToReturn = [ + "users": [TestFixtures.makeColumnInfo(name: "user_name")], + "orders": [TestFixtures.makeColumnInfo(name: "order_total")] + ] + await schemaProvider.loadSchema(using: driver, connection: TestFixtures.makeConnection()) + + let text = "SELECT * FROM users u, orders o WHERE " + let (items, context) = await provider.getCompletions(text: text, cursorPosition: text.count) + + #expect(context.clauseType == .where_) + #expect(items.contains { $0.kind == .column && $0.label == "user_name" }) + #expect(items.contains { $0.kind == .column && $0.label == "order_total" }) + } } diff --git a/TableProTests/Core/Autocomplete/SQLContextAnalyzerTests.swift b/TableProTests/Core/Autocomplete/SQLContextAnalyzerTests.swift index 368068203..5cdf9b08c 100644 --- a/TableProTests/Core/Autocomplete/SQLContextAnalyzerTests.swift +++ b/TableProTests/Core/Autocomplete/SQLContextAnalyzerTests.swift @@ -964,4 +964,95 @@ struct SQLContextAnalyzerTests { let context = analyzer.analyze(query: "SELECT * FROM \"sales\".orders", cursorPosition: 28) #expect(context.tableReferences.contains { $0.tableName == "orders" && $0.schema == "sales" }) } + + // MARK: - Nearest-Clause Detection (multi-clause statements) + + @Test("JOIN after an ON condition detects JOIN, not the earlier ON") + func testJoinAfterOnDetectsJoin() { + let query = "SELECT * FROM a JOIN b ON a.id = b.id INNER JOIN " + let context = analyzer.analyze(query: query, cursorPosition: query.count) + #expect(context.clauseType == .join) + #expect(context.expectsObjectName) + } + + @Test("LEFT JOIN after an ON condition detects JOIN") + func testLeftJoinAfterOnDetectsJoin() { + let query = "SELECT * FROM a LEFT JOIN b ON a.x = b.x RIGHT JOIN " + let context = analyzer.analyze(query: query, cursorPosition: query.count) + #expect(context.clauseType == .join) + } + + @Test("Third JOIN in a chain detects JOIN, not the earlier ON") + func testThirdJoinInChainDetectsJoin() { + let query = "SELECT * FROM a JOIN b ON a.id = b.id JOIN c ON b.id = c.id INNER JOIN " + let context = analyzer.analyze(query: query, cursorPosition: query.count) + #expect(context.clauseType == .join) + } + + @Test("WHERE after SET detects WHERE, not the earlier SET") + func testWhereAfterSetDetectsWhere() { + let query = "UPDATE users SET name = 'a' WHERE " + let context = analyzer.analyze(query: query, cursorPosition: query.count) + #expect(context.clauseType == .where_) + } + + @Test("ORDER BY after HAVING detects ORDER BY, not the earlier HAVING") + func testOrderByAfterHavingDetectsOrderBy() { + let query = "SELECT id FROM t GROUP BY id HAVING COUNT(*) > 1 ORDER BY " + let context = analyzer.analyze(query: query, cursorPosition: query.count) + #expect(context.clauseType == .orderBy) + } + + @Test("A closed CASE expression does not leak into the following clause") + func testClosedCaseDoesNotLeak() { + let query = "SELECT CASE WHEN a THEN b ELSE c END, " + let context = analyzer.analyze(query: query, cursorPosition: query.count) + #expect(context.clauseType == .select) + } + + // MARK: - Table-Operand Slot Detection + + @Test("Cursor right after JOIN keyword expects an object name") + func testJoinKeywordExpectsObjectName() { + let query = "SELECT * FROM users JOIN " + let context = analyzer.analyze(query: query, cursorPosition: query.count) + #expect(context.clauseType == .join) + #expect(context.expectsObjectName) + } + + @Test("Cursor after a complete FROM table does not expect an object name") + func testFromAfterTableDoesNotExpectObjectName() { + let query = "SELECT * FROM users " + let context = analyzer.analyze(query: query, cursorPosition: query.count) + #expect(context.clauseType == .from) + #expect(!context.expectsObjectName) + } + + @Test("Cursor right after FROM keyword expects an object name") + func testFromKeywordExpectsObjectName() { + let query = "SELECT * FROM " + let context = analyzer.analyze(query: query, cursorPosition: query.count) + #expect(context.clauseType == .from) + #expect(context.expectsObjectName) + } + + // MARK: - Comma-Separated FROM List + + @Test("Extracts every table from a comma-separated FROM list") + func testCommaSeparatedFromTables() { + let query = "SELECT * FROM users u, orders o, products WHERE " + let context = analyzer.analyze(query: query, cursorPosition: query.count) + #expect(context.clauseType == .where_) + #expect(context.tableReferences.contains { $0.tableName == "users" && $0.alias == "u" }) + #expect(context.tableReferences.contains { $0.tableName == "orders" && $0.alias == "o" }) + #expect(context.tableReferences.contains { $0.tableName == "products" }) + } + + @Test("DELETE FROM keeps the target table in scope for the WHERE clause") + func testDeleteFromTableInScope() { + let query = "DELETE FROM users WHERE " + let context = analyzer.analyze(query: query, cursorPosition: query.count) + #expect(context.clauseType == .where_) + #expect(context.tableReferences.contains { $0.tableName == "users" }) + } } diff --git a/docs/features/autocomplete.mdx b/docs/features/autocomplete.mdx index 77fedfc46..d249b6ee3 100644 --- a/docs/features/autocomplete.mdx +++ b/docs/features/autocomplete.mdx @@ -57,8 +57,11 @@ Tables appear after FROM, JOIN, INSERT INTO, and similar keywords: SELECT * FROM | -- All tables SELECT * FROM us| -- Tables starting with "us": users, user_roles SELECT * FROM users JOIN | -- All tables +SELECT * FROM a JOIN b ON a.id = b.id JOIN | -- All tables, even after an ON condition ``` +The clause is detected at the cursor, so a second or third JOIN suggests tables even when an ON condition comes before it. In a table position, tables lead the list ahead of keywords. + {/* Screenshot: Table name suggestions */}