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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Fixed

- Raw SQL filter now suggests columns and keywords at every position in the expression, including after AND and OR, instead of only the first column. (#1346)
- Plugins left incompatible after a TablePro update now update quietly in the background instead of showing a premature "could not be loaded" alert. You are only notified when no compatible version exists yet, and the message tells you what to do. (#1322)
- A plugin you download and install by hand is no longer blocked by macOS Gatekeeper once its signature is verified. (#1322)

Expand Down
39 changes: 37 additions & 2 deletions TablePro/Core/Autocomplete/CompletionEngine.swift
Original file line number Diff line number Diff line change
Expand Up @@ -55,11 +55,45 @@ final class CompletionEngine {
await provider.retrySchemaIfNeeded()
}

/// Completions for a single-table filter expression (a bare WHERE-clause
/// fragment such as `id = 1 AND na`). The fragment is completed as the WHERE
/// clause it denotes and columns are scoped to `tableName`, so suggestions
/// fire at every clause position. Returned ranges are relative to `fragment`.
func filterCompletions(
fragment: String,
cursorPosition: Int,
tableName: String
) async -> CompletionContext? {
let clausePrefix = "WHERE "
let prefixLength = (clausePrefix as NSString).length
let analysisText = clausePrefix + fragment
let references = [TableReference(tableName: tableName, alias: nil)]

guard let context = await getCompletions(
text: analysisText,
cursorPosition: cursorPosition + prefixLength,
forcedTableReferences: references
) else {
return nil
}

let mappedLocation = context.replacementRange.location - prefixLength
guard mappedLocation >= 0 else { return nil }
let mappedRange = NSRange(location: mappedLocation, length: context.replacementRange.length)

return CompletionContext(
items: context.items,
replacementRange: mappedRange,
sqlContext: context.sqlContext
)
}

/// Get completions for the given text and cursor position
/// This is a pure function - no side effects
func getCompletions(
text: String,
cursorPosition: Int
cursorPosition: Int,
forcedTableReferences: [TableReference]? = nil
) async -> CompletionContext? {
let nsText = text as NSString
let textLength = nsText.length
Expand All @@ -85,7 +119,8 @@ final class CompletionEngine {
// Get completions from provider (uses the potentially windowed text)
let (items, context) = await provider.getCompletions(
text: analysisText,
cursorPosition: adjustedCursor
cursorPosition: adjustedCursor,
forcedTableReferences: forcedTableReferences
)

// Don't return empty results
Expand Down
51 changes: 51 additions & 0 deletions TablePro/Core/Autocomplete/RawSQLFilterCompletionProvider.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
//
// RawSQLFilterCompletionProvider.swift
// TablePro
//

import Foundation

struct RawSQLFilterCompletionItem: Equatable {
let label: String
let insertText: String
}

struct RawSQLFilterCompletions {
let items: [RawSQLFilterCompletionItem]
let replacementRange: NSRange
}

@MainActor
final class RawSQLFilterCompletionProvider {
private let engine: CompletionEngine
private let tableName: String

init(schemaProvider: SQLSchemaProvider, databaseType: DatabaseType, tableName: String) {
let dialect = PluginManager.shared.sqlDialect(for: databaseType)
let statementCompletions = PluginManager.shared.statementCompletions(for: databaseType)
self.engine = CompletionEngine(
schemaProvider: schemaProvider,
databaseType: databaseType,
dialect: dialect,
statementCompletions: statementCompletions
)
self.tableName = tableName
}

func completions(fieldText: String, cursor: Int) async -> RawSQLFilterCompletions? {
guard let context = await engine.filterCompletions(
fragment: fieldText,
cursorPosition: cursor,
tableName: tableName
) else {
return nil
}

let items = context.items.map {
RawSQLFilterCompletionItem(label: $0.label, insertText: $0.insertText)
}
guard !items.isEmpty else { return nil }

return RawSQLFilterCompletions(items: items, replacementRange: context.replacementRange)
}
}
13 changes: 10 additions & 3 deletions TablePro/Core/Autocomplete/SQLCompletionProvider.swift
Original file line number Diff line number Diff line change
Expand Up @@ -65,13 +65,20 @@ final class SQLCompletionProvider {

// MARK: - Public API

/// Get completion suggestions for the current cursor position
/// Get completion suggestions for the current cursor position.
/// `forcedTableReferences` overrides the tables in scope, used when the caller
/// already knows the table (e.g. a single-table filter expression) rather than
/// relying on a FROM clause in the analyzed text.
func getCompletions(
text: String,
cursorPosition: Int
cursorPosition: Int,
forcedTableReferences: [TableReference]? = nil
) async -> (items: [SQLCompletionItem], context: SQLContext) {
// Analyze context
let context = contextAnalyzer.analyze(query: text, cursorPosition: cursorPosition)
var context = contextAnalyzer.analyze(query: text, cursorPosition: cursorPosition)
if let forcedTableReferences {
context = context.replacingTableReferences(forcedTableReferences)
}

// Don't complete inside strings or comments
if context.isInsideString || context.isInsideComment {
Expand Down
16 changes: 16 additions & 0 deletions TablePro/Core/Autocomplete/SQLContextAnalyzer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,22 @@ struct SQLContext {
self.currentFunction = currentFunction
self.isAfterComma = isAfterComma
}

func replacingTableReferences(_ references: [TableReference]) -> SQLContext {
SQLContext(
clauseType: clauseType,
prefix: prefix,
prefixRange: prefixRange,
dotPrefix: dotPrefix,
tableReferences: references,
isInsideString: isInsideString,
isInsideComment: isInsideComment,
cteNames: cteNames,
nestingLevel: nestingLevel,
currentFunction: currentFunction,
isAfterComma: isAfterComma
)
}
}

/// Analyzes SQL query to determine completion context
Expand Down
27 changes: 25 additions & 2 deletions TablePro/Views/Filter/FilterPanelView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ struct FilterPanelView: View {
@State private var showSavePresetAlert = false
@State private var newPresetName = ""
@State private var focusedFilterId: UUID?
@State private var rawSQLCompletionProvider: RawSQLFilterCompletionProvider?

private let estimatedFilterRowHeight: CGFloat = 32
private let maxFilterListHeight: CGFloat = 200
Expand All @@ -44,12 +45,17 @@ struct FilterPanelView: View {
coordinator.addFilter(columns: columns, primaryKeyColumn: primaryKeyColumn)
}
focusedFilterId = filterState.filters.last?.id
refreshRawSQLCompletionProvider()
}
.onChange(of: columns) { _, newColumns in
if filterState.filters.isEmpty && !newColumns.isEmpty && filterState.isVisible {
coordinator.addFilter(columns: newColumns, primaryKeyColumn: primaryKeyColumn)
focusedFilterId = filterState.filters.last?.id
}
refreshRawSQLCompletionProvider()
}
.onChange(of: coordinator.currentTableName) { _, _ in
refreshRawSQLCompletionProvider()
}
.sheet(isPresented: $showSQLSheet) {
SQLPreviewSheet(sql: generatedSQL)
Expand Down Expand Up @@ -183,6 +189,7 @@ struct FilterPanelView: View {
columns: columns,
completions: completionItems(),
enumValuesByColumn: enumValuesByColumn,
rawSQLCompletionProvider: rawSQLCompletionProvider,
onAdd: {
coordinator.addFilter(columns: columns, primaryKeyColumn: primaryKeyColumn)
focusedFilterId = filterState.filters.last?.id
Expand Down Expand Up @@ -238,14 +245,30 @@ struct FilterPanelView: View {
onApply(coordinator.selectedTabFilterState.appliedFilters)
}

private func completionItems() -> [String] {
private var isSQLDialect: Bool {
let langName = PluginManager.shared.queryLanguageName(for: databaseType)
let isSQLDialect = langName == "SQL" || langName == "CQL" || langName == "PartiQL"
return langName == "SQL" || langName == "CQL" || langName == "PartiQL"
}

private func completionItems() -> [String] {
let sqlKeywords = [
"AND", "OR", "NOT", "IN", "LIKE", "BETWEEN",
"IS NULL", "IS NOT NULL", "EXISTS",
"CASE", "WHEN", "THEN", "ELSE", "END",
]
return isSQLDialect ? columns + sqlKeywords : columns
}

private func refreshRawSQLCompletionProvider() {
guard isSQLDialect, let tableName = coordinator.currentTableName else {
rawSQLCompletionProvider = nil
return
}
let schemaProvider = SchemaProviderRegistry.shared.getOrCreate(for: coordinator.connection.id)
rawSQLCompletionProvider = RawSQLFilterCompletionProvider(
schemaProvider: schemaProvider,
databaseType: databaseType,
tableName: tableName
)
}
}
12 changes: 10 additions & 2 deletions TablePro/Views/Filter/FilterRowView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ struct FilterRowView: View {
let columns: [String]
let completions: [String]
var enumValuesByColumn: [String: [String]] = [:]
var rawSQLCompletionProvider: RawSQLFilterCompletionProvider?
let onAdd: () -> Void
let onDuplicate: () -> Void
let onRemove: () -> Void
Expand All @@ -20,6 +21,13 @@ struct FilterRowView: View {
[.equal, .notEqual]
}

private var rawSQLCompletionSource: FilterCompletionSource {
if let rawSQLCompletionProvider {
return .sqlTokens(rawSQLCompletionProvider)
}
return .staticValues(completions)
}

private var allowedValuesForCurrentColumn: [String]? {
guard !filter.isRawSQL,
let values = enumValuesByColumn[filter.columnName],
Expand Down Expand Up @@ -85,7 +93,7 @@ struct FilterRowView: View {
focusedId: $focusedFilterId,
identity: filter.id,
placeholder: "e.g. id = 1",
completions: completions,
completionSource: rawSQLCompletionSource,
allowsMultiLine: true,
onSubmit: onSubmit
)
Expand All @@ -100,7 +108,7 @@ struct FilterRowView: View {
focusedId: $focusedFilterId,
identity: filter.id,
placeholder: String(localized: "Value"),
completions: completions,
completionSource: .staticValues(completions),
onSubmit: onSubmit
)
.frame(minWidth: 80)
Expand Down
Loading
Loading