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
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,11 +15,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- 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)
- 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)
- PostgreSQL autocomplete covers about 400 built-in functions and the multi-word syntax people actually type, such as `ON CONFLICT DO UPDATE SET`, `GENERATED ALWAYS AS IDENTITY` and window frame clauses. (#2095)

### Added

- The MongoDB editor accepts mongosh value constructors in filters and pipelines, so a value copied from the grid pastes straight into a query. Covers `ObjectId`, `ISODate`, `Date`, the `Number*` family, `Timestamp`, `BinData`, `HexData`, `MinKey`, `MaxKey` and the UUID names. (#2086)

### Fixed

- A chained method on a MongoDB aggregation no longer goes missing. `db.orders.aggregate([...]).limit(10)` used to drop the limit and return everything the pipeline matched. Chaining a method that a query does not support now reports an error instead of ignoring it. (#2095)

### Changed

- Mobile keeps remote connections open when you switch apps.
Expand Down
415 changes: 415 additions & 0 deletions Plugins/PostgreSQLDriverPlugin/PostgreSQLDialect.swift

Large diffs are not rendered by default.

45 changes: 1 addition & 44 deletions Plugins/PostgreSQLDriverPlugin/PostgreSQLPlugin.swift
Original file line number Diff line number Diff line change
Expand Up @@ -80,50 +80,7 @@ final class PostgreSQLPlugin: NSObject, TableProPlugin, DriverPlugin {
static let supportsTriggers = true
static let supportsTriggerEditing = true

static let sqlDialect: SQLDialectDescriptor? = SQLDialectDescriptor(
identifierQuote: "\"",
keywords: [
"SELECT", "FROM", "WHERE", "JOIN", "INNER", "LEFT", "RIGHT", "OUTER", "CROSS", "FULL",
"ON", "USING", "AND", "OR", "NOT", "IN", "LIKE", "ILIKE", "BETWEEN", "AS",
"ORDER", "BY", "GROUP", "HAVING", "LIMIT", "OFFSET", "FETCH", "FIRST", "ROWS", "ONLY",
"INSERT", "INTO", "VALUES", "UPDATE", "SET", "DELETE",
"CREATE", "ALTER", "DROP", "TABLE", "INDEX", "VIEW", "DATABASE", "SCHEMA",
"PRIMARY", "KEY", "FOREIGN", "REFERENCES", "UNIQUE", "CONSTRAINT",
"ADD", "MODIFY", "COLUMN", "RENAME",
"NULL", "IS", "ASC", "DESC", "DISTINCT", "ALL", "ANY", "SOME",
"CASE", "WHEN", "THEN", "ELSE", "END", "COALESCE", "NULLIF",
"UNION", "INTERSECT", "EXCEPT",
"RETURNING", "WITH", "RECURSIVE", "MATERIALIZED",
"EXPLAIN", "ANALYZE", "VERBOSE",
"WINDOW", "OVER", "PARTITION",
"LATERAL", "ORDINALITY"
],
functions: [
"COUNT", "SUM", "AVG", "MAX", "MIN", "STRING_AGG", "ARRAY_AGG",
"CONCAT", "SUBSTRING", "LEFT", "RIGHT", "LENGTH", "LOWER", "UPPER",
"TRIM", "LTRIM", "RTRIM", "REPLACE", "SPLIT_PART",
"NOW", "CURRENT_DATE", "CURRENT_TIME", "CURRENT_TIMESTAMP",
"DATE_TRUNC", "EXTRACT", "AGE", "TO_CHAR", "TO_DATE",
"ROUND", "CEIL", "CEILING", "FLOOR", "ABS", "MOD", "POW", "POWER", "SQRT",
"CAST", "TO_NUMBER", "TO_TIMESTAMP",
"JSON_BUILD_OBJECT", "JSON_AGG", "JSONB_BUILD_OBJECT"
],
dataTypes: [
"INTEGER", "INT", "SMALLINT", "BIGINT", "SERIAL", "BIGSERIAL", "SMALLSERIAL",
"DECIMAL", "NUMERIC", "REAL", "DOUBLE", "PRECISION",
"CHAR", "CHARACTER", "VARCHAR", "TEXT",
"DATE", "TIME", "TIMESTAMP", "TIMESTAMPTZ", "INTERVAL",
"BOOLEAN", "BOOL", "JSON", "JSONB", "UUID", "BYTEA", "ARRAY"
],
tableOptions: [
"INHERITS", "PARTITION BY", "TABLESPACE", "WITH", "WITHOUT OIDS"
],
regexSyntax: .tilde,
booleanLiteralStyle: .truefalse,
likeEscapeStyle: .explicit,
paginationStyle: .limit,
caseSensitivityStyle: .ilikeOperator
)
static let sqlDialect: SQLDialectDescriptor? = PostgreSQLDialect.descriptor

static func driverVariant(for databaseTypeId: String) -> String? {
switch databaseTypeId {
Expand Down
105 changes: 88 additions & 17 deletions Plugins/TableProPluginKit/MongoShellParser.swift
Original file line number Diff line number Diff line change
Expand Up @@ -342,47 +342,118 @@ public struct MongoShellParser {
throw MongoShellParseError.unsupportedMethod(methodName)
}

// Parse chained methods (.sort(), .limit(), .skip(), .projection())
if !remainder.isEmpty, case .find(let coll, let filter, var opts) = operation {
opts = try parseChainedOptions(remainder, options: opts)
operation = .find(collection: coll, filter: filter, options: opts)
guard !remainder.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
return operation
}

return operation
let calls = try parseChainedCalls(remainder)

switch operation {
case .find(let coll, let filter, let opts):
return .find(collection: coll, filter: filter, options: try applyCursorCalls(calls, to: opts))
case .aggregate(let coll, let pipeline):
return .aggregate(collection: coll, pipeline: try appendPipelineStages(for: calls, to: pipeline))
default:
throw MongoShellParseError.unsupportedMethod(
"\(methodName)() does not return a cursor, so .\(calls[0].method)() cannot be chained onto it"
)
}
}

/// Parse chained find options: .sort({...}).limit(N).skip(N)
private static func parseChainedOptions(_ chain: String, options: MongoFindOptions) throws -> MongoFindOptions {
var opts = options
private struct ChainedCall {
let method: String
let argument: String
}

private static func parseChainedCalls(_ chain: String) throws -> [ChainedCall] {
var calls: [ChainedCall] = []
var remaining = chain.trimmingCharacters(in: .whitespacesAndNewlines)

while remaining.hasPrefix(".") {
while !remaining.isEmpty {
guard remaining.hasPrefix(".") else {
throw MongoShellParseError.invalidSyntax("Unexpected text after the method call: \(remaining)")
}
remaining = String(remaining.dropFirst())

guard let parenIndex = remaining.firstIndex(of: "(") else { break }
guard let parenIndex = remaining.firstIndex(of: "(") else {
throw MongoShellParseError.invalidSyntax("Expected a chained method call with parentheses")
}
let method = String(remaining[remaining.startIndex..<parenIndex])

let argAndRest = try extractParenthesizedArgAndRemainder(from: remaining, startingAt: parenIndex)
let arg = argAndRest.arg
calls.append(ChainedCall(method: method, argument: argAndRest.arg))
remaining = argAndRest.remainder.trimmingCharacters(in: .whitespacesAndNewlines)
}

guard !calls.isEmpty else {
throw MongoShellParseError.invalidSyntax("Expected a chained method call")
}
return calls
}

switch method {
private static func applyCursorCalls(
_ calls: [ChainedCall],
to options: MongoFindOptions
) throws -> MongoFindOptions {
var opts = options

for call in calls {
switch call.method {
case "sort":
opts.sort = arg
opts.sort = call.argument
case "limit":
opts.limit = Int(arg.trimmingCharacters(in: .whitespaces))
opts.limit = try integerArgument(call)
case "skip":
opts.skip = Int(arg.trimmingCharacters(in: .whitespaces))
opts.skip = try integerArgument(call)
case "projection":
opts.projection = arg
opts.projection = call.argument
case "pretty", "toArray", "explain":
continue
default:
break
throw MongoShellParseError.unsupportedMethod(".\(call.method)()")
}
}

return opts
}

private static func appendPipelineStages(for calls: [ChainedCall], to pipeline: String) throws -> String {
var stages: [String] = []

for call in calls {
switch call.method {
case "sort":
stages.append("{\"$sort\":\(call.argument)}")
case "skip":
stages.append("{\"$skip\":\(try integerArgument(call))}")
case "limit":
stages.append("{\"$limit\":\(try integerArgument(call))}")
case "pretty", "toArray", "explain":
continue
default:
throw MongoShellParseError.unsupportedMethod(".\(call.method)()")
}
}

guard !stages.isEmpty else { return pipeline }

let trimmed = pipeline.trimmingCharacters(in: .whitespacesAndNewlines)
guard trimmed.hasPrefix("["), trimmed.hasSuffix("]") else {
throw MongoShellParseError.invalidSyntax("aggregate() expects an array of pipeline stages")
}

let inner = String(trimmed.dropFirst().dropLast()).trimmingCharacters(in: .whitespacesAndNewlines)
let joined = stages.joined(separator: ",")
return inner.isEmpty ? "[\(joined)]" : "[\(inner),\(joined)]"
}

private static func integerArgument(_ call: ChainedCall) throws -> Int {
guard let value = Int(call.argument.trimmingCharacters(in: .whitespacesAndNewlines)) else {
throw MongoShellParseError.invalidSyntax(".\(call.method)() expects a whole number")
}
return value
}

// MARK: - Argument Extraction Helpers

private struct StringLiteral {
Expand Down
90 changes: 89 additions & 1 deletion Plugins/TableProPluginKit/SQLDialectDescriptor.swift
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,52 @@ public enum AutoLimitStyle: String, Sendable {
case none // Don't auto-limit (non-SQL)
}

public struct SQLOperatorDescriptor: Sendable, Hashable {
public enum Placement: String, Sendable {
case infix
case prefix
case postfix
}

public enum Category: String, Sendable {
case cast
case comparison
case predicate
case logical
case math
case bitwise
case string
case pattern
case json
case array
case range
case fullText
case network
case geometric
case vector
}

public let symbol: String
public let summary: String
public let category: Category
public let placement: Placement
public let appliesToTypes: [String]

public init(
symbol: String,
summary: String,
category: Category,
placement: Placement = .infix,
appliesToTypes: [String] = []
) {
self.symbol = symbol
self.summary = summary
self.category = category
self.placement = placement
self.appliesToTypes = appliesToTypes
}
}

public struct SQLDialectDescriptor: Sendable {
public let identifierQuote: String
public let keywords: Set<String>
Expand All @@ -38,6 +84,9 @@ public struct SQLDialectDescriptor: Sendable {
public let caseSensitivityStyle: CaseSensitivityStyle
public let caseFoldFunction: String

// Authoring
public let operators: [SQLOperatorDescriptor]

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

@_disfavoredOverload
public init(
identifierQuote: String,
keywords: Set<String>,
Expand All @@ -121,6 +171,42 @@ public struct SQLDialectDescriptor: Sendable {
autoLimitStyle: AutoLimitStyle = .limit,
caseSensitivityStyle: CaseSensitivityStyle = .unsupported,
caseFoldFunction: String = SQLDialectDescriptor.defaultCaseFoldFunction
) {
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: []
)
}

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] = []
) {
self.identifierQuote = identifierQuote
self.keywords = keywords
Expand All @@ -136,6 +222,7 @@ public struct SQLDialectDescriptor: Sendable {
self.autoLimitStyle = autoLimitStyle
self.caseSensitivityStyle = caseSensitivityStyle
self.caseFoldFunction = caseFoldFunction
self.operators = operators
}

public static let defaultCaseFoldFunction = "LOWER"
Expand All @@ -158,7 +245,8 @@ public struct SQLDialectDescriptor: Sendable {
requiresBackslashEscaping: requiresBackslashEscaping,
autoLimitStyle: autoLimitStyle,
caseSensitivityStyle: style,
caseFoldFunction: caseFoldFunction
caseFoldFunction: caseFoldFunction,
operators: operators
)
}
}
Loading
Loading