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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Drop Schema for PostgreSQL, SQL Server and SurrealDB.
- Quick Switcher can search tables and views across every open connection.
- SSL settings on mobile for MySQL, PostgreSQL and Redis: a mode picker plus CA, client certificate and client key. Import a PEM file, paste one, or use a PKCS#12 file. (#2083)
- The editor underlines a structural mistake as you type: a closing bracket with no opener, or an unterminated comment. On MongoDB it also reports what the query parser rejects, such as an unknown collection method. A half-written statement is never flagged. (#2095)
- PostgreSQL enum values are suggested when you compare against an enum column, so `WHERE status = ` offers the labels the type declares. (#2095)
- Legacy UUID Encoding on a MongoDB connection, so binary UUIDs written by the Java, C# or Python drivers read as UUIDs instead of hex. Filters, edits and MQL exports write the same bytes back. (#2086)
- Autocomplete for MongoDB queries. Typing `db.` lists collections, `db.users.` lists the driver methods, and inside a query you get field names plus the operators that are valid in that spot: query operators in a filter, update operators in an update, stage names in a pipeline, and expression operators inside a stage. (#2095)
- PostgreSQL autocomplete now knows the operators, including `::`, the JSON ones (`->`, `->>`, `#>`, `@>`, `?`, `?|`, `?&`), array and range containment, regex matching and full-text search. Each one shows what it does and which types it works on. Typing `::` offers the type names. (#2095)
Expand Down
21 changes: 21 additions & 0 deletions TablePro/Core/Autocomplete/SQLCompletionProvider.swift
Original file line number Diff line number Diff line change
Expand Up @@ -223,6 +223,7 @@ final class SQLCompletionProvider {
}

case .on:
items += await allowedValueItems(for: context)
// HP-3: ON clause — prioritize columns from joined tables
items += await columnItems(for: context.tableReferences)
for ref in context.tableReferences {
Expand Down Expand Up @@ -257,6 +258,7 @@ final class SQLCompletionProvider {

case .where_, .and, .having:
// HP-8: Columns, operators, logical keywords + clause transitions
items += await allowedValueItems(for: context)
items += await columnItems(for: context.tableReferences)
items += SQLKeywords.operatorItems()
items += dialectOperatorItems()
Expand Down Expand Up @@ -460,6 +462,25 @@ final class SQLCompletionProvider {
.map { SQLCompletionItem.favorite(keyword: $0.key, name: $0.value.name, query: $0.value.query) }
}

/// Values a compared column is restricted to, offered as quoted literals ahead of everything
/// else. Nothing is offered for an ordinary column.
private func allowedValueItems(for context: SQLContext) async -> [SQLCompletionItem] {
guard let column = context.comparisonColumn, let schemaProvider else { return [] }

let values = await schemaProvider.allowedValues(forColumn: column, in: context.tableReferences)
return values.map { value in
var item = SQLCompletionItem(
label: "'\(value)'",
kind: .keyword,
insertText: "'\(value)'",
detail: column,
filterText: value.lowercased()
)
item.sortPriority = 10
return item
}
}

/// Operators the connected dialect declares, with their documented meaning.
private func dialectOperatorItems() -> [SQLCompletionItem] {
guard let descriptor = cachedDialect else { return [] }
Expand Down
64 changes: 61 additions & 3 deletions TablePro/Core/Autocomplete/SQLContextAnalyzer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,7 @@ struct SQLContext {
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
let comparisonColumn: String? // Column being compared against, when the cursor is on the value side

init(
clauseType: SQLClauseType,
Expand All @@ -106,7 +107,8 @@ struct SQLContext {
nestingLevel: Int = 0,
currentFunction: String? = nil,
isAfterComma: Bool = false,
expectsObjectName: Bool = false
expectsObjectName: Bool = false,
comparisonColumn: String? = nil
) {
self.clauseType = clauseType
self.prefix = prefix
Expand All @@ -120,6 +122,7 @@ struct SQLContext {
self.currentFunction = currentFunction
self.isAfterComma = isAfterComma
self.expectsObjectName = expectsObjectName
self.comparisonColumn = comparisonColumn
}

func replacingTableReferences(_ references: [TableReference]) -> SQLContext {
Expand All @@ -135,7 +138,8 @@ struct SQLContext {
nestingLevel: nestingLevel,
currentFunction: currentFunction,
isAfterComma: isAfterComma,
expectsObjectName: expectsObjectName
expectsObjectName: expectsObjectName,
comparisonColumn: comparisonColumn
)
}
}
Expand Down Expand Up @@ -354,6 +358,7 @@ final class SQLContextAnalyzer {
)

let isCastTarget = endsWithCastOperator(nsBeforeCursor, before: prefixStart)
let comparisonColumn = comparisonTarget(in: nsBeforeCursor, before: prefixStart)

return SQLContext(
clauseType: isCastTarget ? .castTarget : resolution.clause,
Expand All @@ -367,7 +372,8 @@ final class SQLContextAnalyzer {
nestingLevel: nestingLevel,
currentFunction: currentFunction,
isAfterComma: isAfterComma,
expectsObjectName: resolution.expectsObjectName
expectsObjectName: resolution.expectsObjectName,
comparisonColumn: comparisonColumn
)
}

Expand Down Expand Up @@ -681,6 +687,58 @@ final class SQLContextAnalyzer {
return false
}

/// The column a value is being compared against, when the cursor sits on the value side of a
/// comparison. Drives value completion for columns with a known set of allowed values.
private func comparisonTarget(in text: NSString, before prefixStart: Int) -> String? {
var index = skippingWhitespaceBackwards(in: text, from: min(prefixStart, text.length))
guard let operatorStart = comparisonOperatorStart(in: text, endingAt: index) else { return nil }

index = skippingWhitespaceBackwards(in: text, from: operatorStart)
let identifierEnd = index
while index > 0, SQLTokenBoundary.isIdentifierChar(text.character(at: index - 1)) {
index -= 1
}
guard index < identifierEnd else { return nil }

let identifier = text.substring(with: NSRange(location: index, length: identifierEnd - index))
guard !Self.comparisonStopWords.contains(identifier.uppercased()) else { return nil }
return identifier
}

private func skippingWhitespaceBackwards(in text: NSString, from start: Int) -> Int {
var index = start
while index > 0 {
let character = text.character(at: index - 1)
guard character == Self.space || character == Self.tab
|| character == Self.newline || character == Self.cr else { break }
index -= 1
}
return index
}

private func comparisonOperatorStart(in text: NSString, endingAt end: Int) -> Int? {
for symbol in Self.comparisonOperators {
let length = (symbol as NSString).length
guard end >= length else { continue }
let range = NSRange(location: end - length, length: length)
guard text.substring(with: range).caseInsensitiveCompare(symbol) == .orderedSame else { continue }

let start = end - length
let isWordOperator = symbol.first?.isLetter == true
if isWordOperator, start > 0, SQLTokenBoundary.isIdentifierChar(text.character(at: start - 1)) {
continue
}
return start
}
return nil
}

private static let comparisonOperators: [String] = ["<>", "!=", "<=", ">=", "=", "<", ">", "IN", "LIKE", "ILIKE"]

private static let comparisonStopWords: Set<String> = [
"AND", "OR", "NOT", "WHERE", "ON", "HAVING", "SELECT", "SET", "WHEN", "THEN", "ELSE", "BY"
]

/// True when the token being typed directly follows a PostgreSQL `::` cast operator.
private func endsWithCastOperator(_ text: NSString, before prefixStart: Int) -> Bool {
guard prefixStart >= 2, prefixStart <= text.length else { return false }
Expand Down
23 changes: 23 additions & 0 deletions TablePro/Core/Autocomplete/SQLSchemaProvider.swift
Original file line number Diff line number Diff line change
Expand Up @@ -409,6 +409,29 @@ actor SQLSchemaProvider {
return paths
}

/// Values a column is restricted to, when the database declares them (a PostgreSQL enum type,
/// a MongoDB `$jsonSchema` enum). Returns nothing for an ordinary column.
/// Reads only what the column cache already holds. Completion runs on every keystroke, so it
/// must never trigger a schema fetch; the eager column preload is what fills this cache.
func allowedValues(forColumn column: String, in references: [TableReference]) -> [String] {
let name = column.lowercased()
let candidates = references.isEmpty
? tables.map { (table: $0.name, schema: String?.none) }
: references.map { (table: $0.tableName, schema: $0.schema) }

for candidate in candidates {
let key = [candidate.schema?.lowercased(), candidate.table.lowercased()]
.compactMap(\.self)
.joined(separator: ".")
guard let columns = columnCache[key] else { continue }
if let match = columns.first(where: { $0.name.lowercased() == name }),
let values = match.allowedValues, !values.isEmpty {
return values
}
}
return []
}

/// Get completion items for all columns of tables in scope
func allColumnsInScope(for references: [TableReference]) async -> [SQLCompletionItem] {
// swiftlint:disable:next large_tuple
Expand Down
55 changes: 55 additions & 0 deletions TablePro/Core/Diagnostics/MongoDiagnosticsProducer.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import Foundation
import TableProPluginKit

struct MongoDiagnosticsProducer: QueryDiagnosticsProducing {
private static let maximumLength = 100_000

func diagnostics(for text: String) -> [QueryDiagnostic] {
let source = text as NSString
guard source.length > 0, source.length <= Self.maximumLength else { return [] }

let structure = QueryBracketScanner.scan(source, allowsLineComments: false)

if let range = structure.unmatchedClose {
return [QueryDiagnostic(range: range, message: String(localized: "No matching opening bracket"))]
}
if let range = structure.unterminatedComment {
return [QueryDiagnostic(range: range, message: String(localized: "Unterminated comment"))]
}

guard !structure.hasUnclosedOpener, !structure.hasUnterminatedString else { return [] }

let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return [] }

do {
_ = try MongoShellParser.parse(trimmed)
return []
} catch let error as MongoShellParseError {
guard let message = error.errorDescription else { return [] }
return [QueryDiagnostic(range: range(for: error, in: source), message: message)]
} catch {
return []
}
}

private func range(for error: MongoShellParseError, in source: NSString) -> NSRange {
if case .unsupportedMethod(let method) = error {
let name = method.trimmingCharacters(in: CharacterSet(charactersIn: ".()"))
let found = source.range(of: name)
if found.location != NSNotFound { return found }
}
return firstStatementLine(in: source)
}

private func firstStatementLine(in source: NSString) -> NSRange {
let full = NSRange(location: 0, length: source.length)
let line = source.lineRange(for: NSRange(location: 0, length: 0))
let trimmedLength = source.substring(with: line)
.trimmingCharacters(in: .whitespacesAndNewlines)
.utf16
.count
guard trimmedLength > 0 else { return full }
return NSRange(location: line.location, length: min(trimmedLength, source.length - line.location))
}
}
126 changes: 126 additions & 0 deletions TablePro/Core/Diagnostics/QueryBracketScanner.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
import Foundation

/// Structural scan shared by the diagnostic producers. Reports only what more typing cannot fix:
/// a closer with no opener, and an unterminated block comment. A missing closer is treated as
/// "still typing" and never reported, which is what keeps the editor quiet while you work.
enum QueryBracketScanner {
struct Result {
let unmatchedClose: NSRange?
let unterminatedComment: NSRange?
let hasUnclosedOpener: Bool
let hasUnterminatedString: Bool
}

private static let openParen = UInt16(UnicodeScalar("(").value)
private static let closeParen = UInt16(UnicodeScalar(")").value)
private static let openBrace = UInt16(UnicodeScalar("{").value)
private static let closeBrace = UInt16(UnicodeScalar("}").value)
private static let openBracket = UInt16(UnicodeScalar("[").value)
private static let closeBracket = UInt16(UnicodeScalar("]").value)
private static let backslash = UInt16(UnicodeScalar("\\").value)
private static let slash = UInt16(UnicodeScalar("/").value)
private static let star = UInt16(UnicodeScalar("*").value)
private static let dash = UInt16(UnicodeScalar("-").value)
private static let newline = UInt16(UnicodeScalar("\n").value)
private static let singleQuote = UInt16(UnicodeScalar("'").value)
private static let doubleQuote = UInt16(UnicodeScalar("\"").value)
private static let backtick = UInt16(UnicodeScalar("`").value)

static func scan(_ text: NSString, allowsLineComments: Bool) -> Result {
var stack: [UInt16] = []
var unmatchedClose: NSRange?
var commentStart: Int?
var stringDelimiter: UInt16 = 0
var isInBlockComment = false
var isInLineComment = false
var index = 0

while index < text.length {
let character = text.character(at: index)

if isInLineComment {
if character == newline { isInLineComment = false }
index += 1
continue
}

if isInBlockComment {
if character == star, index + 1 < text.length, text.character(at: index + 1) == slash {
isInBlockComment = false
commentStart = nil
index += 2
continue
}
index += 1
continue
}

if stringDelimiter != 0 {
if character == backslash {
index += 2
continue
}
if character == stringDelimiter { stringDelimiter = 0 }
index += 1
continue
}

if character == slash, index + 1 < text.length {
let next = text.character(at: index + 1)
if next == star {
isInBlockComment = true
commentStart = index
index += 2
continue
}
if next == slash, allowsLineComments {
isInLineComment = true
index += 2
continue
}
}

if character == dash, index + 1 < text.length, text.character(at: index + 1) == dash {
isInLineComment = true
index += 2
continue
}

if character == singleQuote || character == doubleQuote || character == backtick {
stringDelimiter = character
index += 1
continue
}

switch character {
case openParen, openBrace, openBracket:
stack.append(character)
case closeParen, closeBrace, closeBracket:
if stack.last == opener(for: character) {
stack.removeLast()
} else if unmatchedClose == nil {
unmatchedClose = NSRange(location: index, length: 1)
}
default:
break
}

index += 1
}

return Result(
unmatchedClose: unmatchedClose,
unterminatedComment: isInBlockComment ? commentStart.map { NSRange(location: $0, length: 2) } : nil,
hasUnclosedOpener: !stack.isEmpty,
hasUnterminatedString: stringDelimiter != 0
)
}

private static func opener(for closer: UInt16) -> UInt16 {
switch closer {
case closeParen: return openParen
case closeBrace: return openBrace
default: return openBracket
}
}
}
Loading
Loading