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
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- **Query > Clear Query** and **Query > Clear Results**.
- Result chooser in the status bar, naming the result on screen and offering Pin, Unpin, Close and Close Others.
- A reason on a dimmed Run, Explain, Format or Favorite saying why it cannot run.
- **Keyword case** in Settings > Editor: completed keywords and functions follow the case you type. (#2833)

### Changed

Expand Down Expand Up @@ -65,6 +66,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- `Ctrl+Cmd+J` from the editor's reserved shortcuts, so it can be bound in Settings > Keyboard.
- Result tab strip above the query results, and the "Query" heading above the editor.
- Trash button that cleared the query and the results under one name.
- **Auto-uppercase keywords** in Settings > Editor, replaced by **Keyword case**.

### Fixed

Expand Down Expand Up @@ -182,6 +184,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- An AWS profile backed by IAM Identity Center, or an assume-role chain rooted on one, failing to authenticate.
- AWS SSO sign-in leaving the `aws` CLI unable to refresh its own token.
- AWS SSO, STS and RDS unreachable in the China, GovCloud and secret partitions.
- MongoDB autocomplete inserting `$MATCH` and `DB`, which the server rejects.
- ClickHouse autocomplete offering 18 function names the server rejects, `TOSTRING` and `UNIQ` among them.
- Completion inserted beside a non-ASCII prefix instead of replacing it: `SELECT 名` became `SELECT 名名前`.
- Caret landing after the closing parenthesis when accepting a function in the filter panel's Raw SQL field.

### Security

Expand Down
24 changes: 13 additions & 11 deletions Plugins/ClickHouseDriverPlugin/ClickHousePlugin.swift
Original file line number Diff line number Diff line change
Expand Up @@ -77,17 +77,17 @@ final class ClickHousePlugin: NSObject, TableProPlugin, DriverPlugin {
"MATERIALIZED", "WITH"
],
functions: [
"COUNT", "SUM", "AVG", "MAX", "MIN",
"CONCAT", "SUBSTRING", "LEFT", "RIGHT", "LENGTH", "LOWER", "UPPER",
"TRIM", "LTRIM", "RTRIM", "REPLACE",
"NOW", "TODAY", "YESTERDAY",
"count", "sum", "avg", "max", "min",
"concat", "substring", "left", "right", "length", "lower", "upper",
"trim", "ltrim", "rtrim", "replace",
"now", "today", "yesterday",
"CAST",
"UNIQ", "UNIQEXACT", "ARGMIN", "ARGMAX", "GROUPARRAY",
"TOSTRING", "TOINT32", "FORMATDATETIME",
"IF", "MULTIIF",
"ARRAYMAP", "ARRAYJOIN",
"MATCH", "CURRENTDATABASE", "VERSION",
"QUANTILE", "TOPK"
"uniq", "uniqExact", "argMin", "argMax", "groupArray",
"toString", "toInt32", "formatDateTime",
"if", "multiIf",
"arrayMap", "arrayJoin",
"match", "currentDatabase", "version",
"quantile", "topK"
],
dataTypes: [
"INT8", "INT16", "INT32", "INT64", "INT128", "INT256",
Expand All @@ -111,7 +111,9 @@ final class ClickHousePlugin: NSObject, TableProPlugin, DriverPlugin {
paginationStyle: .limit,
requiresBackslashEscaping: true,
caseSensitivityStyle: .caseFoldFunction,
caseFoldFunction: "lowerUTF8"
caseFoldFunction: "lowerUTF8",
textCastTypeName: nil,
functionNamesAreCaseInsensitive: false
)

func createDriver(config: DriverConnectionConfig) -> any PluginDatabaseDriver {
Expand Down
55 changes: 54 additions & 1 deletion Plugins/TableProPluginKit/SQLDialectDescriptor.swift
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,16 @@ public struct SQLDialectDescriptor: Sendable {
// Authoring
public let operators: [SQLOperatorDescriptor]

/// Whether the engine matches built-in function names case-insensitively.
///
/// True for standard SQL, so completion may present and insert a function name in whatever
/// case the user is typing. ClickHouse is the exception: measured on 26.9.1.52, `toString`,
/// `uniq`, `multiIf`, `arrayJoin` and `topK` are all rejected as UNKNOWN_FUNCTION in any other
/// case, while a curated SQL-compatibility set (`COUNT`, `IF`, `NOW`, `CAST`, `CONCAT`,
/// `LOWER`, `SUBSTRING`) is accepted in either. No rule derived from the declared spelling can
/// tell those apart, which is why the dialect has to say.
public let functionNamesAreCaseInsensitive: Bool

public enum CaseSensitivityStyle: String, Sendable {
case ilikeOperator // PostgreSQL, CockroachDB, PGlite, DuckDB, Snowflake
case caseFoldFunction // Oracle, BigQuery, ClickHouse, Redshift
Expand Down Expand Up @@ -235,6 +245,7 @@ public struct SQLDialectDescriptor: Sendable {
)
}

@_disfavoredOverload
public init(
identifierQuote: String,
keywords: Set<String>,
Expand All @@ -252,6 +263,46 @@ public struct SQLDialectDescriptor: Sendable {
caseFoldFunction: String = SQLDialectDescriptor.defaultCaseFoldFunction,
operators: [SQLOperatorDescriptor] = [],
textCastTypeName: String?
) {
self.init(
identifierQuote: identifierQuote,
keywords: keywords,
functions: functions,
dataTypes: dataTypes,
tableOptions: tableOptions,
regexSyntax: regexSyntax,
booleanLiteralStyle: booleanLiteralStyle,
likeEscapeStyle: likeEscapeStyle,
paginationStyle: paginationStyle,
offsetFetchOrderBy: offsetFetchOrderBy,
requiresBackslashEscaping: requiresBackslashEscaping,
autoLimitStyle: autoLimitStyle,
caseSensitivityStyle: caseSensitivityStyle,
caseFoldFunction: caseFoldFunction,
operators: operators,
textCastTypeName: textCastTypeName,
functionNamesAreCaseInsensitive: true
)
}

public init(
identifierQuote: String,
keywords: Set<String>,
functions: Set<String>,
dataTypes: Set<String>,
tableOptions: [String] = [],
regexSyntax: RegexSyntax = .unsupported,
booleanLiteralStyle: BooleanLiteralStyle = .numeric,
likeEscapeStyle: LikeEscapeStyle = .explicit,
paginationStyle: PaginationStyle = .limit,
offsetFetchOrderBy: String = "ORDER BY (SELECT NULL)",
requiresBackslashEscaping: Bool = false,
autoLimitStyle: AutoLimitStyle = .limit,
caseSensitivityStyle: CaseSensitivityStyle = .unsupported,
caseFoldFunction: String = SQLDialectDescriptor.defaultCaseFoldFunction,
operators: [SQLOperatorDescriptor] = [],
textCastTypeName: String?,
functionNamesAreCaseInsensitive: Bool
) {
self.identifierQuote = identifierQuote
self.keywords = keywords
Expand All @@ -269,6 +320,7 @@ public struct SQLDialectDescriptor: Sendable {
self.caseFoldFunction = caseFoldFunction
self.operators = operators
self.textCastTypeName = textCastTypeName
self.functionNamesAreCaseInsensitive = functionNamesAreCaseInsensitive
}

public static let defaultCaseFoldFunction = "LOWER"
Expand All @@ -293,7 +345,8 @@ public struct SQLDialectDescriptor: Sendable {
caseSensitivityStyle: style,
caseFoldFunction: caseFoldFunction,
operators: operators,
textCastTypeName: textCastTypeName
textCastTypeName: textCastTypeName,
functionNamesAreCaseInsensitive: functionNamesAreCaseInsensitive
)
}
}
25 changes: 23 additions & 2 deletions TablePro/Core/Autocomplete/CompletionEngine.swift
Original file line number Diff line number Diff line change
Expand Up @@ -70,14 +70,33 @@ final class CompletionEngine {
provider.allFavoriteItems()
}

/// Filters, ranks and cases an open session's candidates for `prefix`.
///
/// The engine is the single gate every SQL completion item passes through on its way to a UI
/// surface, so the case policy is applied here rather than at each surface. `prefix` arrives in
/// the case the user typed it; the matcher lowercases internally.
func rank(
_ items: [SQLCompletionItem],
prefix: String,
context: SQLContext,
keywordCase: SQLKeywordCase
) -> [SQLCompletionItem] {
SQLCompletionCasing.applied(
to: provider.filterRankAndLimit(items, prefix: prefix, context: context),
typedPrefix: prefix,
policy: keywordCase
)
}

/// 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
tableName: String,
keywordCase: SQLKeywordCase = .default
) async -> CompletionContext? {
let clausePrefix = "WHERE "
let prefixLength = (clausePrefix as NSString).length
Expand All @@ -87,6 +106,7 @@ final class CompletionEngine {
guard let context = await getCompletions(
text: analysisText,
cursorPosition: cursorPosition + prefixLength,
keywordCase: keywordCase,
forcedTableReferences: references
) else {
return nil
Expand All @@ -109,6 +129,7 @@ final class CompletionEngine {
func getCompletions(
text: String,
cursorPosition: Int,
keywordCase: SQLKeywordCase = .default,
forcedTableReferences: [TableReference]? = nil
) async -> CompletionContext? {
let nsText = text as NSString
Expand Down Expand Up @@ -167,7 +188,7 @@ final class CompletionEngine {
)

return CompletionContext(
items: items,
items: SQLCompletionCasing.applied(to: items, typedPrefix: context.prefix, policy: keywordCase),
candidates: candidates,
replacementRange: replacementRange,
sqlContext: adjustedContext
Expand Down
22 changes: 15 additions & 7 deletions TablePro/Core/Autocomplete/Mongo/MongoCompletionService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,9 @@ final class MongoCompletionService: QueryCompletionService {
var triggerCharacters: Set<String> { [".", "$", "{", "[", "\"", "'", ":", " "] }

func seedItems() -> [SQLCompletionItem] {
MongoVocabulary.shellCommands.map { SQLCompletionItem.keyword($0.name, documentation: $0.detail) }
MongoVocabulary.shellCommands.map {
SQLCompletionItem.keyword($0.name, documentation: $0.detail, caseFolding: .fixed)
}
}

func prepare() async {}
Expand Down Expand Up @@ -94,7 +96,9 @@ final class MongoCompletionService: QueryCompletionService {
case .suppressed:
return []
case .statementStart:
return seedItems() + favoriteItems() + [SQLCompletionItem.keyword("db", documentation: "Current database")]
return seedItems() + favoriteItems() + [
SQLCompletionItem.keyword("db", documentation: "Current database", caseFolding: .fixed)
]
case .databaseMember:
return await collectionItems() + methodItems(MongoVocabulary.databaseMethods)
case .collectionMethod:
Expand Down Expand Up @@ -126,24 +130,28 @@ final class MongoCompletionService: QueryCompletionService {
}

private func methodItems(_ methods: [(name: String, detail: String)]) -> [SQLCompletionItem] {
methods.map { SQLCompletionItem.function($0.name, signature: "()", documentation: $0.detail) }
methods.map {
SQLCompletionItem.function($0.name, signature: "()", documentation: $0.detail, caseFolding: .fixed)
}
}

private func operatorItems(_ operators: [(name: String, detail: String)]) -> [SQLCompletionItem] {
operators.map { SQLCompletionItem.operator($0.name, documentation: $0.detail) }
operators.map { SQLCompletionItem.operator($0.name, documentation: $0.detail, caseFolding: .fixed) }
}

private func stageItems(_ stages: [(name: String, detail: String)]) -> [SQLCompletionItem] {
stages.map { SQLCompletionItem.keyword($0.name, documentation: $0.detail) }
stages.map { SQLCompletionItem.keyword($0.name, documentation: $0.detail, caseFolding: .fixed) }
}

private func variableItems() -> [SQLCompletionItem] {
MongoVocabulary.systemVariables.map { SQLCompletionItem.keyword($0.name, documentation: $0.detail) }
MongoVocabulary.systemVariables.map {
SQLCompletionItem.keyword($0.name, documentation: $0.detail, caseFolding: .fixed)
}
}

private func constructorItems() -> [SQLCompletionItem] {
MongoVocabulary.bsonConstructors.map {
SQLCompletionItem.function($0.name, signature: "()", documentation: $0.detail)
SQLCompletionItem.function($0.name, signature: "()", documentation: $0.detail, caseFolding: .fixed)
}
}

Expand Down
16 changes: 13 additions & 3 deletions TablePro/Core/Autocomplete/RawSQLFilterCompletionProvider.swift
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,10 @@ import Foundation
struct RawSQLFilterCompletionItem: Equatable {
let label: String
let insertText: String
/// Where the caret lands relative to the insertion start, in UTF-16 units. Resolved by
/// `SQLCompletionInsertion` so this field behaves exactly as the editor's popup does; the
/// filter field used to splice the raw text and park the caret past the closing parenthesis.
let cursorOffset: Int
}

struct RawSQLFilterCompletions {
Expand Down Expand Up @@ -36,13 +40,19 @@ final class RawSQLFilterCompletionProvider {
guard let context = await engine.filterCompletions(
fragment: fieldText,
cursorPosition: cursor,
tableName: tableName
tableName: tableName,
keywordCase: AppSettingsManager.shared.editor.keywordCase
) else {
return nil
}

let items = context.items.map {
RawSQLFilterCompletionItem(label: $0.label, insertText: $0.insertText)
let items = context.items.map { item in
let resolution = SQLCompletionInsertion.resolve(for: item)
return RawSQLFilterCompletionItem(
label: item.label,
insertText: resolution.text,
cursorOffset: resolution.cursorOffset
)
}
guard !items.isEmpty else { return nil }

Expand Down
Loading
Loading