Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
9672ce1
refactor(coordinator): remove the empty driver branches left after re…
datlechin Sep 21, 2026
ee9f425
fix(editor): dim Run and Explain Query in the Query menu for an edito…
datlechin Sep 21, 2026
8f3e5c2
fix(plugins): give Redshift and Teradata their own Explain variants a…
datlechin Sep 21, 2026
c7cba39
fix(mcp): send explain_query only a variant the database declares, an…
datlechin Sep 21, 2026
0eb4b60
fix(connections): open the Redis database a connection names, includi…
datlechin Sep 21, 2026
2fef140
fix(connections): refuse a Redis URL whose path is not a database index
datlechin Sep 21, 2026
c96698f
fix(connection-form): accept only a whole number in a number field
datlechin Sep 21, 2026
0602c2a
test(plugin-redis): test the real Redis command parser instead of a copy
datlechin Sep 21, 2026
44d0c48
refactor(plugin-redis): render Redis replies from a type the tests co…
datlechin Sep 21, 2026
ff7d147
fix(plugin-redis): keep every argument of a Redis command the parser …
datlechin Sep 21, 2026
c0ecd59
fix(sidebar): load the Redis key tree from its own database
datlechin Sep 21, 2026
264ed49
fix(plugin-redis): match a Redis namespace literally when counting it…
datlechin Sep 21, 2026
723e58c
fix(ios): report a Redis command the server refuses instead of a one-…
datlechin Sep 21, 2026
4cec69c
fix(ios): list each Redis key once when SCAN returns it twice
datlechin Sep 21, 2026
3f08522
fix(ios): browse Redis keys with Redis commands instead of SQL
datlechin Sep 21, 2026
a1ae35a
fix(ios): report a Redis ACL refusal as a permission error, not faile…
datlechin Sep 21, 2026
21aba05
fix(plugin-redis): read the server's subcommand routing tips instead …
datlechin Sep 21, 2026
9146600
fix(plugin-redis): report a split Redis Cluster write that only some …
datlechin Sep 21, 2026
bf68ba3
feat(plugin-redis): browse numbered databases on a Valkey 9 cluster
datlechin Sep 21, 2026
dbfbad1
fix(ci): read wrapped and escaped plugin strings into the app catalog
datlechin Sep 21, 2026
f7346d9
docs(plugin-redis): document the Redis follow-ups to #3036
datlechin Sep 21, 2026
968aaea
fix(editor): classify a quoted Redis command as the command the drive…
datlechin Sep 21, 2026
3ba910f
fix(sidebar): read a key opened from the Redis key tree without movin…
datlechin Sep 21, 2026
8a5ef8c
fix(plugin-redis): refuse a cluster command on another database insid…
datlechin Sep 21, 2026
d87ee35
fix(plugin-redis): report a partial Redis Cluster fan-out of a comman…
datlechin Sep 21, 2026
4e9bc1c
fix(ios): drop a Redis key read that a newer one overtook
datlechin Sep 21, 2026
18db107
fix(mcp): refuse analyze with an explain variant that only estimates
datlechin Sep 21, 2026
1c9948b
fix(connections): show the live Redis database in a new window and ke…
datlechin Sep 21, 2026
da8f337
refactor: drop explanatory comments from this branch's new code
datlechin Sep 21, 2026
9ea7159
docs(plugin-redis): say a key opened from the tree leaves the editor'…
datlechin Sep 21, 2026
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
21 changes: 19 additions & 2 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Several label columns beside the key in the foreign key picker, for a parent row only told apart by a combination. (#2996)
- **Saved Filters** in the filter bar's **Filter Settings**, for restoring a table's filter without running it, or not saving it at all. (#3006)
- **Always Show Filter Bar** in **Filter Settings**, split out from the option that also decided what happened to the saved filter.
- Numbered databases on Valkey 9 clusters with `cluster-databases` above 1.
- `DB <index> <command>` in the Redis editor, running one command on another database.
- Explain for Teradata.

### Changed

Expand Down Expand Up @@ -231,14 +234,27 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- No wrong-mode error when a Standalone Redis connection points at a Valkey 8 or later Sentinel or cluster node.
- Redis `MULTI` blocks aborted, or padded with extra replies, by the sidebar, the key browser and the connection check.
- A Redis `MULTI` block or `WATCH` lost without notice when the connection dropped.
- Redis row counts, statistics, DDL preview, export and grid edits using the session's current database instead of the row's own.
- Redis row counts, statistics, DDL preview, export, grid edits and key tree using the session's database, not the row's.
- Refreshing a Redis database tab after a refused switch showing another database's keys.
- Redis key grid showing type UNKNOWN, TTL -1 and empty collections for keys an ACL user cannot read.
- Redis Cluster deletes and key counts reported as complete when a shard refused them, and `SCRIPT EXISTS` answering 0.
- Redis Cluster writes and key counts reported as complete when a shard refused them, and `SCRIPT EXISTS` answering 0.
- Database Index in the Redis connection form stopping at 15.
- Empty Redis key list on iPhone and iPad when the server refuses the scan or a `MULTI` block is open.
- Explain Query failing on Redis with a `DEBUG` command error, and enabled for databases with no query plan.
- Redis key tree showing "No items" when the server refuses the key scan or a `MULTI` block is open.
- Redis commands losing arguments such as `SCAN … TYPE` or `FLUSHDB ASYNC`, and valid `XGROUP` or `OBJECT` commands refused.
- Redis Cluster running `FUNCTION LOAD`, `ACL SETUSER`, `CONFIG REWRITE` and other subcommands on a single node.
- Redis connections whose database is written `db4` opening database 0 on the Mac.
- Negative or non-numeric database index in a Redis connection URL accepted without an error.
- Redis command errors on iPhone and iPad reported as a successful result.
- Redis keys listed twice on iPhone and iPad.
- Redis keys on iPhone and iPad failing to open with `ERR wrong number of arguments for 'select' command`.
- Redis permission errors on iPhone and iPad reported as a failed sign-in.
- Run and Explain Query in the Query menu enabled for an editor holding only whitespace.
- `explain_query` sending an invented `EXPLAIN` to databases without one, and an estimate when `analyze` was asked for.
- Explain on Redshift failing on PostgreSQL's `FORMAT JSON` and `ANALYZE` options.
- Decimal points and minus signs accepted in DuckDB Port and BigQuery Max Bytes Billed.
- Wrong Redis database in the toolbar of a second window opened on the same connection.

### Security

Expand All @@ -260,6 +276,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Statements hidden behind an engine's own literal or comment forms, such as `E'\''`, `'''` or `--1`, skipped Safe Mode.
- Statements hidden the same ways passed the one-statement check on MCP and AI chat queries.
- Writes hidden in a dollar-quoted string, a nested comment or a bracketed identifier skipped Safe Mode on iPhone and iPad.
- A quoted Redis command such as `"FLUSHALL"` skipping Safe Mode and the MCP destructive-statement check.

## [0.75.0] - 2026-09-18

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -50,48 +50,6 @@ public extension DatabaseDriver {
}

func executeStreaming(query: String, options: StreamOptions = .default) -> AsyncThrowingStream<StreamElement, Error> {
AsyncThrowingStream { continuation in
let task = Task {
do {
let result = try await self.execute(query: query)
continuation.yield(.columns(result.columns))

var emitted = 0
for legacyRow in result.rows {
if Task.isCancelled {
continuation.yield(.truncated(reason: .cancelled))
break
}
if emitted >= options.maxRows {
continuation.yield(.truncated(reason: .rowCap(options.maxRows)))
break
}
let cells = legacyRow.enumerated().map { index, value -> Cell in
let typeName = index < result.columns.count ? result.columns[index].typeName : nil
return Cell.from(legacyValue: value, columnTypeName: typeName, options: options)
}
continuation.yield(.row(Row(cells: cells)))
emitted += 1
}

if let message = result.statusMessage {
continuation.yield(.statusMessage(message))
}
if result.rowsAffected != 0 {
continuation.yield(.rowsAffected(result.rowsAffected))
}
if result.isTruncated && emitted < options.maxRows {
continuation.yield(.truncated(reason: .driverLimit("driver returned isTruncated=true")))
}
continuation.finish()
} catch is CancellationError {
continuation.yield(.truncated(reason: .cancelled))
continuation.finish()
} catch {
continuation.finish(throwing: error)
}
}
continuation.onTermination = { _ in task.cancel() }
}
QueryResultStreaming.stream(options: options) { try await self.execute(query: query) }
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import Foundation
import TableProModels

public enum QueryResultStreaming {
public static func stream(
options: StreamOptions,
producing produce: @escaping @Sendable () async throws -> QueryResult
) -> AsyncThrowingStream<StreamElement, Error> {
AsyncThrowingStream { continuation in
let task = Task {
do {
let result = try await produce()
emit(result, options: options, into: continuation)
continuation.finish()
} catch is CancellationError {
continuation.yield(.truncated(reason: .cancelled))
continuation.finish()
} catch {
continuation.finish(throwing: error)
}
}
continuation.onTermination = { _ in task.cancel() }
}
}

private static func emit(
_ result: QueryResult,
options: StreamOptions,
into continuation: AsyncThrowingStream<StreamElement, Error>.Continuation
) {
continuation.yield(.columns(result.columns))

var emitted = 0
for legacyRow in result.rows {
if Task.isCancelled {
continuation.yield(.truncated(reason: .cancelled))
break
}
if emitted >= options.maxRows {
continuation.yield(.truncated(reason: .rowCap(options.maxRows)))
break
}
let cells = legacyRow.enumerated().map { index, value -> Cell in
let typeName = index < result.columns.count ? result.columns[index].typeName : nil
return Cell.from(legacyValue: value, columnTypeName: typeName, options: options)
}
continuation.yield(.row(Row(cells: cells)))
emitted += 1
}

if let message = result.statusMessage {
continuation.yield(.statusMessage(message))
}
if result.rowsAffected != 0 {
continuation.yield(.rowsAffected(result.rowsAffected))
}
if result.isTruncated && emitted < options.maxRows {
continuation.yield(.truncated(reason: .driverLimit("driver returned isTruncated=true")))
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ public enum StatementBlank {
}

public static func hasContent(_ text: String) -> Bool {
text.contains { !isBlank($0) }
text.unicodeScalars.contains { !isBlank($0) }
}

public static func blankLength(in text: NSString, at offset: Int) -> Int {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
import Foundation
import TableProDatabase
import TableProModels
import Testing

private struct ProducerFailure: Error, Equatable {}

private enum StreamEvent: Equatable {
case columns([String])
case row([String?])
case rowsAffected(Int)
case statusMessage(String)
case rowCap(Int)
case cancelled
case memoryPressure
case driverLimit
}

private func collect(_ stream: AsyncThrowingStream<StreamElement, Error>) async throws -> [StreamEvent] {
var events: [StreamEvent] = []
for try await element in stream {
events.append(event(for: element))
}
return events
}

private func event(for element: StreamElement) -> StreamEvent {
switch element {
case .columns(let columns):
return .columns(columns.map(\.name))
case .row(let row):
return .row(row.legacyValues)
case .rowsAffected(let count):
return .rowsAffected(count)
case .statusMessage(let message):
return .statusMessage(message)
case .truncated(let reason):
switch reason {
case .rowCap(let cap): return .rowCap(cap)
case .cancelled: return .cancelled
case .memoryPressure: return .memoryPressure
case .driverLimit: return .driverLimit
}
}
}

private let valueColumn = [ColumnInfo(name: "value", typeName: "string", ordinalPosition: 0)]

@Suite("QueryResultStreaming")
struct QueryResultStreamingTests {
@Test("the columns come first, then one element per row")
func streamsColumnsThenRows() async throws {
let result = QueryResult(columns: valueColumn, rows: [["a"], ["b"]], rowsAffected: 0, executionTime: 0)
let events = try await collect(QueryResultStreaming.stream(options: .default) { result })
#expect(events == [.columns(["value"]), .row(["a"]), .row(["b"])])
}

@Test("rows past the cap are cut and the cut is reported")
func rowCapTruncates() async throws {
let result = QueryResult(columns: valueColumn, rows: [["a"], ["b"], ["c"]], rowsAffected: 0, executionTime: 0)
let options = StreamOptions(maxRows: 2)
let events = try await collect(QueryResultStreaming.stream(options: options) { result })
#expect(events == [.columns(["value"]), .row(["a"]), .row(["b"]), .rowCap(2)])
}

@Test("a status message and an affected count follow the rows")
func statusAndRowsAffected() async throws {
let result = QueryResult(
columns: [],
rows: [],
rowsAffected: 3,
executionTime: 0,
statusMessage: "OK"
)
let events = try await collect(QueryResultStreaming.stream(options: .default) { result })
#expect(events == [.columns([]), .statusMessage("OK"), .rowsAffected(3)])
}

@Test("a result the producer marked truncated says so")
func producerTruncation() async throws {
let result = QueryResult(columns: valueColumn, rows: [["a"]], rowsAffected: 0, executionTime: 0, isTruncated: true)
let events = try await collect(QueryResultStreaming.stream(options: .default) { result })
#expect(events == [.columns(["value"]), .row(["a"]), .driverLimit])
}

@Test("a producer that throws finishes the stream with its error")
func producerErrorFinishesTheStream() async {
let stream = QueryResultStreaming.stream(options: .default) { () async throws -> QueryResult in
throw ProducerFailure()
}
await #expect(throws: ProducerFailure()) {
_ = try await collect(stream)
}
}

@Test("a producer that is cancelled ends the stream as cancelled")
func producerCancellationEndsAsCancelled() async throws {
let stream = QueryResultStreaming.stream(options: .default) { () async throws -> QueryResult in
throw CancellationError()
}
let events = try await collect(stream)
#expect(events == [.cancelled])
}
}
6 changes: 0 additions & 6 deletions Plugins/PostgreSQLDriverPlugin/CockroachPluginDriver.swift
Original file line number Diff line number Diff line change
Expand Up @@ -52,12 +52,6 @@ final class CockroachPluginDriver: LibPQBackedDriver, @unchecked Sendable {
cachedServerVersion ?? core.serverVersion
}

// MARK: - EXPLAIN

func buildExplainQuery(_ sql: String) -> String? {
"EXPLAIN \(sql)"
}

// MARK: - Schema

func fetchTables(schema: String?) async throws -> [PluginTableInfo] {
Expand Down
6 changes: 0 additions & 6 deletions Plugins/PostgreSQLDriverPlugin/PostgreSQLPluginDriver.swift
Original file line number Diff line number Diff line change
Expand Up @@ -107,12 +107,6 @@ class PostgreSQLPluginDriver: LibPQBackedDriver, @unchecked Sendable {
catalogPresence?.hasSequences ?? versionedCapabilities.hasSequencesCatalog
}

// MARK: - EXPLAIN

func buildExplainQuery(_ sql: String) -> String? {
"EXPLAIN \(sql)"
}

// MARK: - Foreign Keys

func foreignKeyDisableStatements() -> [String]? {
Expand Down
6 changes: 0 additions & 6 deletions Plugins/PostgreSQLDriverPlugin/RedshiftPluginDriver.swift
Original file line number Diff line number Diff line change
Expand Up @@ -46,12 +46,6 @@ final class RedshiftPluginDriver: LibPQBackedDriver, @unchecked Sendable {
}
}

// MARK: - EXPLAIN

func buildExplainQuery(_ sql: String) -> String? {
"EXPLAIN \(sql)"
}

// MARK: - Schema

/// Refreshed from `onPostConnect` and whenever the schema list is loaded, so
Expand Down
2 changes: 1 addition & 1 deletion Plugins/RedisDriverPlugin/RedisArgumentCodec.swift
Original file line number Diff line number Diff line change
Expand Up @@ -153,7 +153,7 @@ enum RedisArgumentCodec {
}
}

private static func quotedText(_ text: String) -> String {
static func quotedText(_ text: String) -> String {
var result = "\""
for scalar in text.unicodeScalars {
switch scalar {
Expand Down
11 changes: 11 additions & 0 deletions Plugins/RedisDriverPlugin/RedisClusterAggregator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,17 @@ enum RedisClusterAggregator {
}
}

/// Each primary's `INFO keyspace` added up per database. Nil when any primary declined, the
/// same rule `DBSIZE` follows, because a count missing one primary is short, not zero.
static func keyspace(_ perShard: [[Int: Int]?]) -> [Int: Int]? {
var merged: [Int: Int] = [:]
for shard in perShard {
guard let shard else { return nil }
merged.merge(shard, uniquingKeysWith: +)
}
return merged
}

/// The first shard error in the order the shards were asked, else the first `+QUEUED`.
static func firstNonAnswer(in replies: [RedisReply]) -> RedisReply? {
replies.first(where: \.isError) ?? replies.first(where: \.isQueued)
Expand Down
Loading
Loading