diff --git a/CHANGELOG.md b/CHANGELOG.md index 6529349111..2ffc6ed47b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -39,6 +39,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Assistant conversations belong to one connection, and outlive the window that opened them. - iCloud sync and usage data on iPhone and iPad stay off until you turn them on. - Group rows in the iOS connection list take swipe actions and show even when no connection is saved. +- Typed entry beside the stepper for number settings in the connection form. ### Removed @@ -225,6 +226,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Compare & Sync showing an Oracle unit missing the `;` after its `END` as identical. - SSH jump hosts dropped from a connection synced to iPhone and iPad, and that connection then skipped on the way back. - An SSH tunnel pinned to port 22, and its auth method read back as Password, after a round trip through iPhone and iPad. +- Redis database list failing on servers that refuse `CONFIG` or `INFO`, such as AWS ElastiCache and Azure Cache for Redis. (#3036) +- Empty tab after clicking a Redis database the server refuses to switch to. +- 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. +- 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. +- 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. ### Security diff --git a/Plugins/RedisDriverPlugin/RedisClusterAggregator.swift b/Plugins/RedisDriverPlugin/RedisClusterAggregator.swift index 12fd781fb9..f7e1f65518 100644 --- a/Plugins/RedisDriverPlugin/RedisClusterAggregator.swift +++ b/Plugins/RedisDriverPlugin/RedisClusterAggregator.swift @@ -11,36 +11,66 @@ import Foundation enum RedisClusterAggregator { + /// A shard that refused its part, or queued it into an open `MULTI` block, wins over every + /// policy but one_succeeded, whose whole meaning is that some shards may fail. Folding it in + /// instead counted it as zero: a split `DEL` one shard refused reported the other shard's + /// deletions as the total, and `DBSIZE` reported part of the keyspace as all of it. static func combine(_ replies: [RedisReply], policy: RedisResponsePolicy?) -> RedisReply { guard let first = replies.first else { return .array([]) } guard replies.count > 1 else { return first } + if policy != .oneSucceeded, let failure = firstNonAnswer(in: replies) { return failure } switch policy { case .aggSum: - return .integer(replies.reduce(Int64(0)) { $0 + numericValue($1) }) + return aggregated(replies) { $0.reduce(0, +) } case .aggMin: - return .integer(replies.map(numericValue).min() ?? 0) + return aggregated(replies) { $0.min() ?? 0 } case .aggMax: - return .integer(replies.map(numericValue).max() ?? 0) + return aggregated(replies) { $0.max() ?? 0 } case .aggLogicalAnd: - return .integer(replies.allSatisfy { numericValue($0) != 0 } ? 1 : 0) + return aggregated(replies) { $0.allSatisfy { $0 != 0 } ? 1 : 0 } case .aggLogicalOr: - return .integer(replies.contains { numericValue($0) != 0 } ? 1 : 0) + return aggregated(replies) { $0.contains { $0 != 0 } ? 1 : 0 } case .oneSucceeded: return replies.first { !$0.isError } ?? first case .allSucceeded: - if let failure = replies.first(where: { $0.isError }) { return failure } - return replies.first { !$0.isError } ?? first + return first case .special, .none: return concatenated(replies) } } + /// 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) + } + + /// A reply the policy cannot count is handed back whole rather than as a number made up for + /// it, the way a keyed reply the planner cannot scatter is. + private static func aggregated(_ replies: [RedisReply], _ reduce: ([Int64]) -> Int64) -> RedisReply { + folded(replies, reduce) ?? .array(replies) + } + + /// Integers fold to one integer. Arrays of one width fold position by position, which is + /// what `SCRIPT EXISTS` (one flag per script) and `WAITAOF` (local and replica counts) need. + private static func folded(_ replies: [RedisReply], _ reduce: ([Int64]) -> Int64) -> RedisReply? { + let numbers = replies.compactMap(numericValue) + if numbers.count == replies.count { return .integer(reduce(numbers)) } + + let arrays = replies.compactMap(\.arrayValue) + guard arrays.count == replies.count, let width = arrays.first?.count, + arrays.allSatisfy({ $0.count == width }) else { return nil } + var elements: [RedisReply] = [] + elements.reserveCapacity(width) + for position in 0 ..< width { + guard let element = folded(arrays.map { $0[position] }, reduce) else { return nil } + elements.append(element) + } + return .array(elements) + } + /// The no-policy default for a keyless fan-out: pack every shard's nested reply into one. - /// A shard that answered with an error still wins, because a partial answer read as complete - /// is worse than a visible failure. - static func concatenated(_ replies: [RedisReply]) -> RedisReply { - if let failure = replies.first(where: { $0.isError }) { return failure } + private static func concatenated(_ replies: [RedisReply]) -> RedisReply { var merged: [RedisReply] = [] var sawArray = false for reply in replies { @@ -57,11 +87,11 @@ enum RedisClusterAggregator { return .array(merged) } - private static func numericValue(_ reply: RedisReply) -> Int64 { + private static func numericValue(_ reply: RedisReply) -> Int64? { switch reply { case .integer(let value): return value - case .string(let text), .status(let text): return Int64(text) ?? 0 - default: return 0 + case .string(let text), .status(let text): return Int64(text) + default: return nil } } } diff --git a/Plugins/RedisDriverPlugin/RedisClusterChannel.swift b/Plugins/RedisDriverPlugin/RedisClusterChannel.swift index 231d0164ef..e992e15d56 100644 --- a/Plugins/RedisDriverPlugin/RedisClusterChannel.swift +++ b/Plugins/RedisDriverPlugin/RedisClusterChannel.swift @@ -113,7 +113,7 @@ final class RedisClusterChannel: RedisCommandChannel, @unchecked Sendable { func currentDatabase() -> Int { 0 } - func selectDatabase(_ index: Int) async throws { + func selectDatabase(_ index: Int, scope: RedisCommandScope) async throws { guard index == 0 else { throw RedisPluginError( code: 0, @@ -124,27 +124,27 @@ final class RedisClusterChannel: RedisCommandChannel, @unchecked Sendable { // MARK: - Command dispatch - func executeCommand(_ args: [Data]) async throws -> RedisReply { + func executeCommand(_ args: [Data], scope: RedisCommandScope) async throws -> RedisReply { guard !args.isEmpty else { return .null } let snapshot = snapshotState() let spec = snapshot.routing.spec(for: args) switch spec?.requestPolicy { case .allNodes: - return try await broadcast(args, to: snapshot.topology.allNodes, policy: spec?.responsePolicy) + return try await broadcast(args, to: snapshot.topology.allNodes, policy: spec?.responsePolicy, scope: scope) case .allShards: guard spec?.responsePolicy != .special else { - return try await routeToAnyMaster(args, snapshot: snapshot) + return try await routeToAnyMaster(args, snapshot: snapshot, scope: scope) } - return try await broadcast(args, to: snapshot.topology.masters, policy: spec?.responsePolicy) + return try await broadcast(args, to: snapshot.topology.masters, policy: spec?.responsePolicy, scope: scope) case .multiShard: - return try await runMultiShard(args, spec: spec, snapshot: snapshot) + return try await runMultiShard(args, spec: spec, snapshot: snapshot, scope: scope) case .special, .none: - return try await routeSingle(args, spec: spec, snapshot: snapshot) + return try await routeSingle(args, spec: spec, snapshot: snapshot, scope: scope) } } - func executePipeline(_ commands: [[Data]]) async throws -> [RedisReply] { + func executePipeline(_ commands: [[Data]], scope: RedisCommandScope) async throws -> [RedisReply] { guard !commands.isEmpty else { return [] } let snapshot = snapshotState() @@ -163,19 +163,26 @@ final class RedisClusterChannel: RedisCommandChannel, @unchecked Sendable { for target in order { guard let entries = grouped[target], let address = address(forIdentifier: target) else { continue } let connection = try await connection(to: address) - let batch = try await connection.executePipeline(entries.map(\.command)) + let batch = try await connection.executePipeline(entries.map(\.command), scope: scope) for (entry, reply) in zip(entries, batch) { replies[entry.index] = try await resolveRedirectIfNeeded( reply, command: entry.command, - from: address + from: address, + scope: scope ) } } return replies } - func scanKeyspace(cursor: String, pattern: String?, type: String?, count: Int) async throws -> RedisKeyspacePage { + func scanKeyspace( + cursor: String, + pattern: String?, + type: String?, + count: Int, + scope: RedisCommandScope + ) async throws -> RedisKeyspacePage { let snapshot = snapshotState() let ordered = snapshot.topology.orderedMasters let nodeIds = ordered.map(\.id) @@ -193,7 +200,7 @@ final class RedisClusterChannel: RedisCommandChannel, @unchecked Sendable { if let type { args += ["TYPE", type] } let connection = try await connection(to: node.address) - let reply = try await connection.executeCommand(args).throwIfError().throwIfQueued("SCAN") + let reply = try await connection.executeCommand(args, scope: scope).throwIfError().throwIfQueued("SCAN") let page = RedisScanReply.parse(reply) let next = RedisClusterCursor.advance( after: node.id, @@ -240,72 +247,111 @@ final class RedisClusterChannel: RedisCommandChannel, @unchecked Sendable { return snapshot.topology.master(forSlot: RedisKeySlot.slot(for: first)) } - private func routeSingle(_ args: [Data], spec: RedisCommandSpec?, snapshot: Snapshot) async throws -> RedisReply { + private func routeSingle( + _ args: [Data], + spec: RedisCommandSpec?, + snapshot: Snapshot, + scope: RedisCommandScope + ) async throws -> RedisReply { if spec?.hasMovableKeys == true, let resolved = try? await serverResolvedKeys(for: args, snapshot: snapshot) { guard RedisKeySlot.slotsAreEqual(for: resolved) else { throw RedisPluginError(code: 0, message: Self.crossSlotMessage(for: args)) } if let first = resolved.first, let node = snapshot.topology.master(forSlot: RedisKeySlot.slot(for: first)) { - return try await send(args, to: node.address) + return try await send(args, to: node.address, scope: scope) } } guard let node = try owningNode(for: args, snapshot: snapshot) else { - return try await routeToAnyMaster(args, snapshot: snapshot) + return try await routeToAnyMaster(args, snapshot: snapshot, scope: scope) } - return try await send(args, to: node.address) + return try await send(args, to: node.address, scope: scope) } - private func routeToAnyMaster(_ args: [Data], snapshot: Snapshot) async throws -> RedisReply { + private func routeToAnyMaster(_ args: [Data], snapshot: Snapshot, scope: RedisCommandScope) async throws -> RedisReply { guard let node = snapshot.topology.orderedMasters.first else { throw RedisPluginError.notConnected } - return try await send(args, to: node.address) + return try await send(args, to: node.address, scope: scope) } - private func runMultiShard(_ args: [Data], spec: RedisCommandSpec?, snapshot: Snapshot) async throws -> RedisReply { - guard let spec else { return try await routeSingle(args, spec: nil, snapshot: snapshot) } + private func runMultiShard( + _ args: [Data], + spec: RedisCommandSpec?, + snapshot: Snapshot, + scope: RedisCommandScope + ) async throws -> RedisReply { + guard let spec else { return try await routeSingle(args, spec: nil, snapshot: snapshot, scope: scope) } guard let groups = RedisMultiShardPlanner.split(arguments: args, spec: spec) else { - return try await routeSingle(args, spec: spec, snapshot: snapshot) + return try await routeSingle(args, spec: spec, snapshot: snapshot, scope: scope) } var replies: [RedisReply] = [] + var nodes: [RedisNodeAddress] = [] replies.reserveCapacity(groups.count) + nodes.reserveCapacity(groups.count) for group in groups { guard let node = snapshot.topology.master(forSlot: group.slot) else { throw RedisPluginError.notConnected } - replies.append(try await send(group.arguments, to: node.address)) + replies.append(try await send(group.arguments, to: node.address, scope: scope)) + nodes.append(node.address) } - guard let policy = spec.responsePolicy else { - return RedisMultiShardPlanner.scatterInKeyOrder( + let combined: RedisReply + if let policy = spec.responsePolicy { + combined = RedisClusterAggregator.combine(replies, policy: policy) + } else { + combined = RedisMultiShardPlanner.scatterInKeyOrder( groups: groups, replies: replies, keyIndices: spec.keyIndices(forArgumentCount: args.count) ) } - return RedisClusterAggregator.combine(replies, policy: policy) + noteShardFailures(of: args, combined: combined, replies: replies, nodes: nodes) + return combined } private func broadcast( _ args: [Data], to nodes: [RedisClusterNode], - policy: RedisResponsePolicy? + policy: RedisResponsePolicy?, + scope: RedisCommandScope ) async throws -> RedisReply { let targets = nodes.isEmpty ? snapshotState().topology.masters : nodes guard !targets.isEmpty else { throw RedisPluginError.notConnected } var replies: [RedisReply] = [] replies.reserveCapacity(targets.count) for node in targets { - replies.append(try await send(args, to: node.address, followRedirects: false)) + replies.append(try await send(args, to: node.address, followRedirects: false, scope: scope)) + } + let combined = RedisClusterAggregator.combine(replies, policy: policy) + noteShardFailures(of: args, combined: combined, replies: replies, nodes: targets.map(\.address)) + return combined + } + + /// The reply the user sees is the refusing shard's own, which names neither the node nor the + /// fact that the other shards ran their part, so the log keeps both. The class only, because + /// the rest of an error message can quote a key. + private func noteShardFailures( + of args: [Data], + combined: RedisReply, + replies: [RedisReply], + nodes: [RedisNodeAddress] + ) { + guard combined.isError else { return } + let name = args.first.flatMap { String(data: $0, encoding: .utf8) }?.uppercased() ?? "" + for (position, (reply, node)) in zip(replies, nodes).enumerated() { + guard let message = reply.errorMessage else { continue } + let errorClass = RedisConnectProbe.errorClass(of: message) + let part = "\(node.identifier), part \(position + 1) of \(replies.count)" + logger.notice("\(name, privacy: .public) failed on \(part, privacy: .public): \(errorClass, privacy: .public)") } - return RedisClusterAggregator.combine(replies, policy: policy) } private func serverResolvedKeys(for args: [Data], snapshot: Snapshot) async throws -> [Data] { guard let node = snapshot.topology.orderedMasters.first else { return [] } var request: [Data] = [Data("COMMAND".utf8), Data("GETKEYS".utf8)] request.append(contentsOf: args) - let reply = try await send(request, to: node.address, followRedirects: false) + let reply = try await send(request, to: node.address, followRedirects: false, scope: .outsideBlock) guard case .array(let items) = reply else { return [] } return items.compactMap { item in switch item { @@ -321,7 +367,8 @@ final class RedisClusterChannel: RedisCommandChannel, @unchecked Sendable { private func send( _ args: [Data], to address: RedisNodeAddress, - followRedirects: Bool = true + followRedirects: Bool = true, + scope: RedisCommandScope ) async throws -> RedisReply { var target = address var redirects = 0 @@ -329,7 +376,7 @@ final class RedisClusterChannel: RedisCommandChannel, @unchecked Sendable { while true { let connection = try await connection(to: target) - let reply = try await connection.executeCommand(args) + let reply = try await connection.executeCommand(args, scope: scope) guard followRedirects, let message = reply.errorMessage, let redirect = RedisClusterRedirect.parse(message, fallbackHost: target.host) else { return reply @@ -344,7 +391,7 @@ final class RedisClusterChannel: RedisCommandChannel, @unchecked Sendable { case .ask(_, let asked): guard redirects < Limits.maxRedirects else { return reply } redirects += 1 - return try await sendAsking(args, to: asked) + return try await sendAsking(args, to: asked, scope: scope) case .tryAgain(let slot): guard busyRetries < Limits.maxBusyRetries else { throw RedisPluginError(code: 0, message: Self.migrationMessage(slot: slot)) @@ -361,16 +408,17 @@ final class RedisClusterChannel: RedisCommandChannel, @unchecked Sendable { /// ASKING is a one-shot flag, so it has to travel in the same pipeline as the command it /// applies to. Measured: sending it on its own leaves the next command answering MOVED. - private func sendAsking(_ args: [Data], to address: RedisNodeAddress) async throws -> RedisReply { + private func sendAsking(_ args: [Data], to address: RedisNodeAddress, scope: RedisCommandScope) async throws -> RedisReply { let connection = try await connection(to: address) - let replies = try await connection.executePipeline([[Data("ASKING".utf8)], args]) + let replies = try await connection.executePipeline([[Data("ASKING".utf8)], args], scope: scope) return replies.last ?? .null } private func resolveRedirectIfNeeded( _ reply: RedisReply, command: [Data], - from address: RedisNodeAddress + from address: RedisNodeAddress, + scope: RedisCommandScope ) async throws -> RedisReply { guard let message = reply.errorMessage, let redirect = RedisClusterRedirect.parse(message, fallbackHost: address.host) else { @@ -379,9 +427,9 @@ final class RedisClusterChannel: RedisCommandChannel, @unchecked Sendable { switch redirect { case .moved(let slot, let moved): noteMoved(slot: slot, to: moved) - return try await send(command, to: moved) + return try await send(command, to: moved, scope: scope) case .ask(_, let asked): - return try await sendAsking(command, to: asked) + return try await sendAsking(command, to: asked, scope: scope) default: return reply } @@ -488,7 +536,7 @@ final class RedisClusterChannel: RedisCommandChannel, @unchecked Sendable { ) async throws -> RedisClusterTopology { let connection = try await connection(to: seed) - let shardsReply = try await connection.executeCommand(["CLUSTER", "SHARDS"]) + let shardsReply = try await connection.executeCommand(["CLUSTER", "SHARDS"], scope: .outsideBlock) if let parsed = RedisClusterTopologyParser.parseShards(shardsReply, fallbackHost: seed.host) { return parsed } @@ -496,7 +544,7 @@ final class RedisClusterChannel: RedisCommandChannel, @unchecked Sendable { throw RedisPluginError(code: 0, message: RedisTopologyDiagnostics.notAClusterMessage) } - let slotsReply = try await connection.executeCommand(["CLUSTER", "SLOTS"]) + let slotsReply = try await connection.executeCommand(["CLUSTER", "SLOTS"], scope: .outsideBlock) if let message = slotsReply.errorMessage { if message.contains("cluster support disabled") { throw RedisPluginError(code: 0, message: RedisTopologyDiagnostics.notAClusterMessage) @@ -525,7 +573,7 @@ final class RedisClusterChannel: RedisCommandChannel, @unchecked Sendable { if let first = discovered.orderedMasters.first { let connection = try await connection(to: first.address) - let info = (try? await connection.executeCommand(["INFO", "server"]))?.stringValue + let info = (try? await connection.executeCommand(["INFO", "server"], scope: .outsideBlock))?.stringValue adoptVersion(info.flatMap(RedisServerInfo.version(from:))) } } @@ -535,7 +583,7 @@ final class RedisClusterChannel: RedisCommandChannel, @unchecked Sendable { /// denied CLUSTER SLOTS too, so there is no case where lazy lookups would have helped. private func fetchRouting(from seed: RedisNodeAddress) async throws -> RedisCommandRouting { let connection = try await connection(to: seed) - let reply = try await connection.executeCommand(["COMMAND"]) + let reply = try await connection.executeCommand(["COMMAND"], scope: .outsideBlock) guard let parsed = RedisCommandRouting.parse(commandReply: reply) else { logger.notice("COMMAND unavailable; using the curated routing table") return RedisCommandRouting() diff --git a/Plugins/RedisDriverPlugin/RedisCommandChannel.swift b/Plugins/RedisDriverPlugin/RedisCommandChannel.swift index 09870ba8c8..811908dd0a 100644 --- a/Plugins/RedisDriverPlugin/RedisCommandChannel.swift +++ b/Plugins/RedisDriverPlugin/RedisCommandChannel.swift @@ -33,12 +33,25 @@ protocol RedisCommandChannel: AnyObject, Sendable { func serverVersion() -> String? func currentDatabase() -> Int - - func executeCommand(_ args: [Data]) async throws -> RedisReply - func executePipeline(_ commands: [[Data]]) async throws -> [RedisReply] - func selectDatabase(_ index: Int) async throws - - func scanKeyspace(cursor: String, pattern: String?, type: String?, count: Int) async throws -> RedisKeyspacePage + /// The database the next command runs on: a SELECT queued in an open block has not moved the + /// session yet, but everything after it in the block runs there. + func databaseForNextCommand() -> Int + /// Where the session belongs: the database the user or the app's navigation last selected. + func homeDatabase() -> Int + /// Moves the session for one read the app makes, without moving where it belongs. + func visitDatabase(_ index: Int) async throws + + func executeCommand(_ args: [Data], scope: RedisCommandScope) async throws -> RedisReply + func executePipeline(_ commands: [[Data]], scope: RedisCommandScope) async throws -> [RedisReply] + func selectDatabase(_ index: Int, scope: RedisCommandScope) async throws + + func scanKeyspace( + cursor: String, + pattern: String?, + type: String?, + count: Int, + scope: RedisCommandScope + ) async throws -> RedisKeyspacePage /// Confirms the channel still points at a node that accepts writes, re-pointing it if not. /// Sentinel needs this because a demoted primary keeps answering `role:master` and keeps @@ -50,16 +63,40 @@ extension RedisCommandChannel { var supportsDatabaseSelection: Bool { true } var supportsTransactions: Bool { true } + func databaseForNextCommand() -> Int { currentDatabase() } + + func homeDatabase() -> Int { currentDatabase() } + + func visitDatabase(_ index: Int) async throws { + try await selectDatabase(index, scope: .outsideBlock) + } + func connect() async throws { try await connect(reportingStage: { _ in }) } - func executeCommand(_ args: [String]) async throws -> RedisReply { - try await executeCommand(args.map { Data($0.utf8) }) + func executeCommand(_ args: [Data]) async throws -> RedisReply { + try await executeCommand(args, scope: .session) + } + + func executeCommand(_ args: [String], scope: RedisCommandScope = .session) async throws -> RedisReply { + try await executeCommand(args.map { Data($0.utf8) }, scope: scope) + } + + func executePipeline(_ commands: [[Data]]) async throws -> [RedisReply] { + try await executePipeline(commands, scope: .session) } - func executePipeline(_ commands: [[String]]) async throws -> [RedisReply] { - try await executePipeline(commands.map { $0.map { Data($0.utf8) } }) + func executePipeline(_ commands: [[String]], scope: RedisCommandScope = .session) async throws -> [RedisReply] { + try await executePipeline(commands.map { $0.map { Data($0.utf8) } }, scope: scope) + } + + func selectDatabase(_ index: Int) async throws { + try await selectDatabase(index, scope: .session) + } + + func scanKeyspace(cursor: String, pattern: String?, type: String?, count: Int) async throws -> RedisKeyspacePage { + try await scanKeyspace(cursor: cursor, pattern: pattern, type: type, count: count, scope: .session) } func verifyStillPrimary() async throws {} @@ -73,25 +110,49 @@ extension RedisCommandChannel { /// "QUEUED" as a stored value. Every command site goes through here rather than reading the /// reply straight. @discardableResult - func run(_ args: [String]) async throws -> RedisReply { + func run(_ args: [String], scope: RedisCommandScope = .session) async throws -> RedisReply { let name = args.first ?? "" - return try await executeCommand(args).throwIfError(name).throwIfQueued(name) + return try await executeCommand(args, scope: scope).throwIfError(name).throwIfQueued(name) } @discardableResult - func run(_ args: [Data]) async throws -> RedisReply { + func run(_ args: [Data], scope: RedisCommandScope = .session) async throws -> RedisReply { let name = args.first.flatMap { String(data: $0, encoding: .utf8) } ?? "" - return try await executeCommand(args).throwIfError(name).throwIfQueued(name) + return try await executeCommand(args, scope: scope).throwIfError(name).throwIfQueued(name) + } + + /// The health monitor's question. Only a session with no identity fails it, because a + /// reconnect is what the monitor does with a no. A user's open block holds the probe back + /// rather than queueing a PING into it, which for a user without `+ping` would abort the block. + func probeHealth() async throws { + let reply: RedisReply + do { + reply = try await executeCommand(RedisConnectProbe.command, scope: .outsideBlock) + } catch is RedisHeldBackCommand { + return + } + guard RedisConnectProbe.outcome(errorMessage: reply.errorMessage) == .unauthenticated else { return } + throw RedisPluginError( + code: 3, + message: RedisConnectProbe.unauthenticatedMessage, + detail: RedisConnectProbe.unauthenticatedHint + ) } /// The single-node walk. A cluster channel replaces this with one that visits every master. - func scanKeyspace(cursor: String, pattern: String?, type: String?, count: Int) async throws -> RedisKeyspacePage { + func scanKeyspace( + cursor: String, + pattern: String?, + type: String?, + count: Int, + scope: RedisCommandScope + ) async throws -> RedisKeyspacePage { var args = ["SCAN", cursor == RedisClusterCursor.start ? "0" : cursor] if let pattern { args += ["MATCH", pattern] } args += ["COUNT", String(count)] if let type { args += ["TYPE", type] } - let reply = try await executeCommand(args).throwIfError().throwIfQueued("SCAN") + let reply = try await executeCommand(args, scope: scope).throwIfError().throwIfQueued("SCAN") let page = RedisScanReply.parse(reply) return RedisKeyspacePage(cursor: page.cursor, keys: page.keys, isIncomplete: false) } diff --git a/Plugins/RedisDriverPlugin/RedisCommandParser.swift b/Plugins/RedisDriverPlugin/RedisCommandParser.swift index cd5ab04a7f..c73c1b303d 100644 --- a/Plugins/RedisDriverPlugin/RedisCommandParser.swift +++ b/Plugins/RedisDriverPlugin/RedisCommandParser.swift @@ -17,7 +17,7 @@ enum RedisOperation { case del(keys: [String]) case keys(pattern: String) case scan(cursor: String, pattern: String?, count: Int?) - case keyBrowse(pattern: String?, typeScope: String?, limit: Int, offset: Int) + case keyBrowse(pattern: String?, typeScope: String?, limit: Int, offset: Int, database: Int? = nil) case keyTree(pattern: String?, limit: Int) case type(key: String) case ttl(key: String) @@ -177,7 +177,7 @@ struct RedisCommandParser { return try parseServerCommand(command, args: args, tokens: tokens) case "KEYBROWSE": - return parseKeyBrowse(args) + return try parseKeyBrowse(args) case "KEYTREE": return parseKeyTree(args) @@ -187,14 +187,29 @@ struct RedisCommandParser { } } - private static func parseKeyBrowse(_ args: [RedisArgument]) -> RedisOperation { + /// `DB` names the database the browse reads, so a table's own query reaches it whichever + /// database the session is on: a refresh, a later page and an export all read the database + /// the row names rather than the one the session last moved to. + private static func parseKeyBrowse(_ args: [RedisArgument]) throws -> RedisOperation { var pattern: String? var typeScope: String? var limit = 200 var offset = 0 + var database: Int? var i = 0 while i < args.count { switch args[i].text.uppercased() { + case "DB": + guard i + 1 < args.count else { + throw RedisParseError.missingArgument(String(localized: "KEYBROWSE DB requires a database index")) + } + guard let index = RedisDatabaseIndex.parse(args[i + 1].text), index >= 0 else { + throw RedisParseError.invalidArgument( + String(format: String(localized: "%@ is not a Redis database index."), args[i + 1].text) + ) + } + database = index + i += 1 case "MATCH": if i + 1 < args.count { pattern = args[i + 1].text @@ -220,7 +235,7 @@ struct RedisCommandParser { } i += 1 } - return .keyBrowse(pattern: pattern, typeScope: typeScope, limit: limit, offset: offset) + return .keyBrowse(pattern: pattern, typeScope: typeScope, limit: limit, offset: offset, database: database) } private static func parseKeyTree(_ args: [RedisArgument]) -> RedisOperation { diff --git a/Plugins/RedisDriverPlugin/RedisConnectProbe.swift b/Plugins/RedisDriverPlugin/RedisConnectProbe.swift index c702ca307f..ce3a99b8f4 100644 --- a/Plugins/RedisDriverPlugin/RedisConnectProbe.swift +++ b/Plugins/RedisDriverPlugin/RedisConnectProbe.swift @@ -38,7 +38,7 @@ nonisolated enum RedisConnectProbe { /// RESP puts the error class in the first word, so the class is compared whole. A prefix test /// would let a future `NOAUTHZ` read as `NOAUTH`. - private static func errorClass(of message: String) -> String { + static func errorClass(of message: String) -> String { String(message.prefix { !$0.isWhitespace }).uppercased() } } diff --git a/Plugins/RedisDriverPlugin/RedisDatabaseIndex.swift b/Plugins/RedisDriverPlugin/RedisDatabaseIndex.swift index cdf6773e30..422961fbe9 100644 --- a/Plugins/RedisDriverPlugin/RedisDatabaseIndex.swift +++ b/Plugins/RedisDriverPlugin/RedisDatabaseIndex.swift @@ -2,6 +2,7 @@ import Foundation nonisolated enum RedisDatabaseIndex { static let fieldName = "redisDatabase" + static let selectable: ClosedRange = 0...(Int(Int32.max) - 1) /// The driver names databases `db0` upward everywhere it shows one, and `switchDatabase` /// reads that spelling back, so connecting has to accept it too. Taking only a bare integer diff --git a/Plugins/RedisDriverPlugin/RedisDatabaseListing.swift b/Plugins/RedisDriverPlugin/RedisDatabaseListing.swift new file mode 100644 index 0000000000..03136e716c --- /dev/null +++ b/Plugins/RedisDriverPlugin/RedisDatabaseListing.swift @@ -0,0 +1,76 @@ +// +// RedisDatabaseListing.swift +// RedisDriverPlugin +// +// How many numbered databases a server has, and how many keys each holds, for the sidebar and +// the database list. Both lists go through here so they cannot disagree about the count. +// + +import Foundation + +enum RedisDatabaseCount { + static let assumed = 16 + static let limit = RedisDatabaseIndex.selectable.count + + static func reported(by reply: RedisReply) -> Int? { + guard let pair = reply.arrayValue, pair.count >= 2, let count = pair[1].intValue, + (1 ... limit).contains(count) else { return nil } + return count + } + + /// Without the server's own answer the count is at least Redis's default of 16, and at least + /// one past every database known to exist: one `INFO keyspace` names because it holds keys, + /// and the one the session is on. Azure allows 64 databases and Memorystore 100, both with + /// `CONFIG` refused, so a flat 16 would hide a populated `db20`. + static func resolve(reported: Int?, keyspace: [Int: Int]?, currentDatabase: Int) -> Int { + if let reported { return reported } + let known = (keyspace.map { Array($0.keys) } ?? []) + [currentDatabase] + let highest = known.filter { (0 ..< limit).contains($0) }.max() ?? 0 + return max(assumed, highest + 1) + } +} + +struct RedisDatabaseListing: Equatable, Sendable { + let databaseCount: Int + /// Nil when the server declined `INFO keyspace`, which leaves every count unknown rather + /// than zero. + let keyCounts: [Int: Int]? + + func keyCount(forDatabase index: Int) -> Int? { + guard let keyCounts else { return nil } + return keyCounts[index] ?? 0 + } +} + +extension RedisCommandChannel { + /// `INFO keyspace` is read when key counts are wanted, and when the server would not say how + /// many databases it has, because then the keyspace is what can widen the count. + func databaseListing(includingKeyCounts: Bool) async throws -> RedisDatabaseListing { + guard supportsDatabaseSelection else { + return RedisDatabaseListing( + databaseCount: 1, + keyCounts: includingKeyCounts ? try await keyCountsByDatabase() : nil + ) + } + let reported = try await runMetadataRead(["CONFIG", "GET", "databases"]) + .flatMap(RedisDatabaseCount.reported(by:)) + let keyCounts = includingKeyCounts || reported == nil ? try await keyCountsByDatabase() : nil + let count = RedisDatabaseCount.resolve( + reported: reported, + keyspace: keyCounts, + currentDatabase: currentDatabase() + ) + return RedisDatabaseListing(databaseCount: count, keyCounts: includingKeyCounts ? keyCounts : nil) + } + + /// Nil when the server declines `INFO`, which an ACL user outside `@dangerous` is. A cluster + /// answers `INFO` from one master, so its single keyspace is counted with `DBSIZE`, which + /// every master answers and which is nil when any of them declines. + func keyCountsByDatabase() async throws -> [Int: Int]? { + guard supportsDatabaseSelection else { + return try await runMetadataRead(["DBSIZE"])?.intValue.map { [0: $0] } + } + guard let reply = try await runMetadataRead(["INFO", "keyspace"]) else { return nil } + return RedisServerInfo.keyspace(from: reply.stringValue ?? "") + } +} diff --git a/Plugins/RedisDriverPlugin/RedisDatabaseTarget.swift b/Plugins/RedisDriverPlugin/RedisDatabaseTarget.swift new file mode 100644 index 0000000000..705cc16795 --- /dev/null +++ b/Plugins/RedisDriverPlugin/RedisDatabaseTarget.swift @@ -0,0 +1,73 @@ +// +// RedisDatabaseTarget.swift +// RedisDriverPlugin +// +// A Redis connection reads whichever numbered database its session last selected, while the +// app addresses each database as its own table. So a read the app makes for one row goes to that +// row's database and returns the session to where it was, and a move the session is already past +// sends nothing: a read-only ACL user is refused SELECT even for the database it is on. +// + +import Foundation +import os +import TableProPluginKit + +private let logger = Logger(subsystem: "com.TablePro.RedisDriver", category: "RedisDatabaseTarget") + +enum RedisDatabaseTarget { + typealias Statement = (statement: String, parameters: [PluginCellValue]) + + /// A grid's writes belong to the database its rows came from, which the session is not on + /// when the user moved it elsewhere. Run inside the save's `MULTI`, the SELECTs are queued + /// with the writes and applied together by `EXEC`, which leaves the session where it was. + static func addressing(_ statements: [Statement], toDatabase index: Int?, from home: Int) -> [Statement] { + guard let index, index != home, !statements.isEmpty else { return statements } + return [(statement: "SELECT \(index)", parameters: [])] + + statements + + [(statement: "SELECT \(home)", parameters: [])] + } +} + +extension RedisCommandChannel { + func moveToDatabase(_ index: Int) async throws { + guard databaseForNextCommand() != index || homeDatabase() != index else { return } + try await selectDatabase(index, scope: .outsideBlock) + } + + /// Everything the body sends runs on `index`, and the session returns to where it belongs + /// afterwards, even when the body throws. A refused SELECT throws before the body runs. + func withDatabase(_ index: Int?, _ body: () async throws -> T) async throws -> T { + guard let index else { return try await body() } + let home = homeDatabase() + return try await RedisDatabaseVisit.$database.withValue(index) { + guard index != databaseForNextCommand() else { return try await body() } + try await visitDatabase(index) + do { + let value = try await body() + await returnToDatabase(home) + return value + } catch { + await returnToDatabase(home) + throw error + } + } + } + + /// Exact for the database the session belongs on. Any other comes from `INFO keyspace`, which + /// is nil when the server declines it and zero for a database it does not list. + func keyCount(inDatabase index: Int) async throws -> Int? { + if index == homeDatabase() { + return try await runMetadataRead(["DBSIZE"])?.intValue + } + guard supportsDatabaseSelection, let counts = try await keyCountsByDatabase() else { return nil } + return counts[index] ?? 0 + } + + private func returnToDatabase(_ origin: Int) async { + do { + try await visitDatabase(origin) + } catch { + logger.warning("Could not return the session to database \(origin, privacy: .public)") + } + } +} diff --git a/Plugins/RedisDriverPlugin/RedisKeySummary.swift b/Plugins/RedisDriverPlugin/RedisKeySummary.swift index a627360928..a2adb59c72 100644 --- a/Plugins/RedisDriverPlugin/RedisKeySummary.swift +++ b/Plugins/RedisDriverPlugin/RedisKeySummary.swift @@ -4,6 +4,7 @@ // import Foundation +import TableProPluginKit enum RedisKeyKind: String, CaseIterable { case string @@ -107,3 +108,75 @@ enum RedisKeySummary { return String(data: data, encoding: .utf8) } } + +/// What the server said about one key. Either half is nil when the server would not say, which +/// an ACL user whose key patterns do not cover the key gets for both, while `SCAN` still lists it. +struct RedisKeyDescription: Equatable, Sendable { + let typeName: String? + let ttlSeconds: Int? + + var kind: RedisKeyKind? { + typeName.flatMap(RedisKeyKind.init(typeName:)) + } + + var typeCell: PluginCellValue { + .fromOptional(typeName?.uppercased()) + } + + var ttlCell: PluginCellValue { + .fromOptional(ttlSeconds.map(String.init)) + } +} + +struct RedisKeyContents { + let kind: RedisKeyKind + let length: Int? + let preview: RedisReply? + + var lengthCell: PluginCellValue { + .fromOptional(length.map(String.init)) + } +} + +extension RedisCommandChannel { + func keyTypeNames(_ keys: [String]) async throws -> [String?] { + try await runMetadataReads(keys.map { ["TYPE", $0] }).map { $0?.stringValue } + } + + func describeKeys(_ keys: [String]) async throws -> [RedisKeyDescription] { + let answers = try await runMetadataReads(keys.flatMap { [["TYPE", $0], ["TTL", $0]] }) + return stride(from: 0, to: answers.count - 1, by: 2).map { index in + RedisKeyDescription(typeName: answers[index]?.stringValue, ttlSeconds: answers[index + 1]?.intValue) + } + } + + /// A key whose type is unknown gets no probe at all, because the length and preview commands + /// are chosen by type. + func readContents( + of keys: [String], + describedAs descriptions: [RedisKeyDescription] + ) async throws -> [RedisKeyContents?] { + let kinds = zip(keys, descriptions).map { (key: $0, kind: $1.kind) } + var commands: [[String]] = [] + commands.reserveCapacity(kinds.count * 2) + for entry in kinds { + guard let kind = entry.kind else { continue } + commands.append(RedisKeySummary.lengthCommand(for: kind, key: entry.key)) + commands.append(RedisKeySummary.previewCommand(for: kind, key: entry.key)) + } + let answers = try await runMetadataReads(commands) + + var contents: [RedisKeyContents?] = [] + contents.reserveCapacity(kinds.count) + var next = answers.startIndex + for entry in kinds { + guard let kind = entry.kind, next + 1 < answers.endIndex else { + contents.append(nil) + continue + } + contents.append(RedisKeyContents(kind: kind, length: answers[next]?.intValue, preview: answers[next + 1])) + next += 2 + } + return contents + } +} diff --git a/Plugins/RedisDriverPlugin/RedisMetadataRead.swift b/Plugins/RedisDriverPlugin/RedisMetadataRead.swift new file mode 100644 index 0000000000..7836e13198 --- /dev/null +++ b/Plugins/RedisDriverPlugin/RedisMetadataRead.swift @@ -0,0 +1,73 @@ +// +// RedisMetadataRead.swift +// RedisDriverPlugin +// +// A read the driver makes on its own to describe the server, as opposed to a command the user +// typed. Managed services remove commands outright (ElastiCache and Azure answer CONFIG with +// `ERR unknown command`) or deny them by ACL (Memorystore answers `NOPERM`), and neither means +// the connection failed: the view the read was for should carry on without that answer. +// + +import Foundation +import os + +private let logger = Logger(subsystem: "com.TablePro.RedisDriver", category: "RedisMetadataRead") + +enum RedisMetadataRead { + /// `ERR` is also what a subscribed session answers for any other command, which is fine to + /// treat the same way: a degraded list costs less than a view that refuses to open. Every + /// other class (`BUSY`, `NOAUTH`, `LOADING`, `MASTERDOWN`) is a state the user needs to see. + static let declinedClasses: Set = ["ERR", "NOPERM"] + + static func declinedClass(of reply: RedisReply) -> String? { + guard let message = reply.errorMessage else { return nil } + let errorClass = RedisConnectProbe.errorClass(of: message) + return declinedClasses.contains(errorClass) ? errorClass : nil + } + + /// Nil when the server declined, the reply itself when it answered, and a throw for every + /// other error and for a `+QUEUED` acknowledgement, labelled with the command. + static func answer(_ reply: RedisReply, to command: String) throws -> RedisReply? { + guard declinedClass(of: reply) == nil else { return nil } + return try reply.throwIfError(command).throwIfQueued(command) + } +} + +extension RedisCommandChannel { + /// Nil when the server declines the read. Everything else behaves exactly as `run`: a + /// transport failure and a `-BUSY` throw, and so does an open `MULTI` block, which the read + /// is held back from rather than sent into. + func runMetadataRead(_ args: [String]) async throws -> RedisReply? { + let name = args.first ?? "" + let reply = try await executeCommand(args, scope: .outsideBlock) + if let declinedClass = RedisMetadataRead.declinedClass(of: reply) { + logger.notice("\(name, privacy: .public) declined with \(declinedClass, privacy: .public); continuing without it") + } + return try RedisMetadataRead.answer(reply, to: name) + } + + /// The same rule over one pipeline, one answer per command in the order they were sent. A key + /// the user's ACL does not cover is declined on its own (`-NOPERM No permissions to access a + /// key`) while the keys around it answer, so a refusal stays in its own place instead of + /// failing the batch or reading as a value. + func runMetadataReads(_ commands: [[String]]) async throws -> [RedisReply?] { + guard !commands.isEmpty else { return [] } + let replies = try await executePipeline(commands, scope: .outsideBlock) + let answers = try zip(commands, replies).map { command, reply in + try RedisMetadataRead.answer(reply, to: command.first ?? "") + } + noteDeclined(commands: commands, answers: answers) + return answers + } + + private func noteDeclined(commands: [[String]], answers: [RedisReply?]) { + let declined = zip(commands, answers).compactMap { command, answer -> String? in + answer == nil ? command.first ?? "" : nil + } + guard !declined.isEmpty else { return } + let names = Set(declined).sorted().joined(separator: ", ") + logger.notice( + "\(declined.count, privacy: .public) of \(commands.count, privacy: .public) reads declined (\(names, privacy: .public)); continuing without them" + ) + } +} diff --git a/Plugins/RedisDriverPlugin/RedisMultiShardPlanner.swift b/Plugins/RedisDriverPlugin/RedisMultiShardPlanner.swift index d86add67e6..68b1f58855 100644 --- a/Plugins/RedisDriverPlugin/RedisMultiShardPlanner.swift +++ b/Plugins/RedisDriverPlugin/RedisMultiShardPlanner.swift @@ -63,14 +63,15 @@ enum RedisMultiShardPlanner { } /// The no-policy default for a keyed command: the caller expects one element per input key, - /// in the order it asked for them. + /// in the order it asked for them. A group that failed or was queued has no elements to + /// scatter, so it is the answer rather than a run of nils. static func scatterInKeyOrder( groups: [RedisMultiShardGroup], replies: [RedisReply], keyIndices: [Int] ) -> RedisReply { guard groups.count == replies.count else { return .array(replies) } - if let failure = replies.first(where: { $0.isError }) { return failure } + if let failure = RedisClusterAggregator.firstNonAnswer(in: replies) { return failure } var byOriginalIndex: [Int: RedisReply] = [:] for (group, reply) in zip(groups, replies) { diff --git a/Plugins/RedisDriverPlugin/RedisPlugin.swift b/Plugins/RedisDriverPlugin/RedisPlugin.swift index 3ff7abc1dd..1e64012716 100644 --- a/Plugins/RedisDriverPlugin/RedisPlugin.swift +++ b/Plugins/RedisDriverPlugin/RedisPlugin.swift @@ -92,7 +92,7 @@ final class RedisPlugin: NSObject, TableProPlugin, DriverPlugin { id: "redisDatabase", label: String(localized: "Database Index"), defaultValue: "0", - fieldType: .stepper(range: ConnectionField.IntRange(0...15)), + fieldType: .stepper(range: ConnectionField.IntRange(RedisDatabaseIndex.selectable)), visibleWhen: FieldVisibilityRule( fieldId: RedisConnectionMode.fieldId, values: [RedisConnectionMode.standalone.rawValue, RedisConnectionMode.sentinel.rawValue] diff --git a/Plugins/RedisDriverPlugin/RedisPluginConnection.swift b/Plugins/RedisDriverPlugin/RedisPluginConnection.swift index 03a339d653..bbcf9c957e 100644 --- a/Plugins/RedisDriverPlugin/RedisPluginConnection.swift +++ b/Plugins/RedisDriverPlugin/RedisPluginConnection.swift @@ -67,8 +67,8 @@ final class RedisPluginConnection: RedisCommandChannel, @unchecked Sendable { private var _isConnected: Bool = false private var _isShuttingDown: Bool = false private var _cachedServerVersion: String? - private var _currentDatabase: Int - private var _queuedDatabase = RedisQueuedDatabase() + private var _database: RedisSessionDatabase + private var _footprint = RedisSessionFootprint() var isConnected: Bool { stateLock.lock() @@ -107,7 +107,7 @@ final class RedisPluginConnection: RedisCommandChannel, @unchecked Sendable { self.database = database self.sslConfig = sslConfig self.connectTimeout = connectTimeout - self._currentDatabase = database + self._database = RedisSessionDatabase(database) } deinit { @@ -145,7 +145,7 @@ final class RedisPluginConnection: RedisCommandChannel, @unchecked Sendable { stateLock.lock() _cachedServerVersion = versionString _isConnected = true - _currentDatabase = database + _database = RedisSessionDatabase(database) stateLock.unlock() logger.info("Connected to Redis \(versionString ?? "unknown")") @@ -167,8 +167,8 @@ final class RedisPluginConnection: RedisCommandChannel, @unchecked Sendable { #endif _isConnected = false _cachedServerVersion = nil - _currentDatabase = database - _queuedDatabase.clear() + _database = RedisSessionDatabase(database) + _footprint = RedisSessionFootprint() stateLock.unlock() #if canImport(CRedis) @@ -208,21 +208,28 @@ final class RedisPluginConnection: RedisCommandChannel, @unchecked Sendable { func currentDatabase() -> Int { stateLock.lock() defer { stateLock.unlock() } - return _currentDatabase + return _database.current } - // MARK: - Command Execution - - func executeCommand(_ args: [String]) async throws -> RedisReply { - try await executeCommand(args.map { Data($0.utf8) }) + func databaseForNextCommand() -> Int { + stateLock.lock() + defer { stateLock.unlock() } + return _footprint.pendingDatabase ?? _database.current } - func executePipeline(_ commands: [[String]]) async throws -> [RedisReply] { - try await executePipeline(commands.map { $0.map { Data($0.utf8) } }) + func homeDatabase() -> Int { + stateLock.lock() + defer { stateLock.unlock() } + return _database.home } - func executeCommand(_ args: [Data]) async throws -> RedisReply { + // MARK: - Command Execution + + /// The session's held state is read on the serial queue, right before the send, so a `MULTI` + /// already queued ahead of this command is what it is checked against. + func executeCommand(_ args: [Data], scope: RedisCommandScope) async throws -> RedisReply { #if canImport(CRedis) + let visiting = RedisDatabaseVisit.database return try await pluginDispatchAsync(on: queue) { [self] in guard !isShuttingDown else { throw RedisPluginError.notConnected @@ -233,9 +240,11 @@ final class RedisPluginConnection: RedisCommandChannel, @unchecked Sendable { throw RedisPluginError.notConnected } stateLock.unlock() + try admit(scope, command: args.first) + try moveToCommandDatabase(visiting: visiting) let generation = cancellationGate.beginQuery() defer { cancellationGate.endQuery(generation) } - let result = try executeCommandSyncRetrying(args) + let result = try executeCommandSyncRetrying(args, scope: scope) try throwIfCancelled(generation) return result } @@ -244,8 +253,9 @@ final class RedisPluginConnection: RedisCommandChannel, @unchecked Sendable { #endif } - func executePipeline(_ commands: [[Data]]) async throws -> [RedisReply] { + func executePipeline(_ commands: [[Data]], scope: RedisCommandScope) async throws -> [RedisReply] { #if canImport(CRedis) + let visiting = RedisDatabaseVisit.database return try await pluginDispatchAsync(on: queue) { [self] in guard !isShuttingDown else { throw RedisPluginError.notConnected @@ -256,9 +266,11 @@ final class RedisPluginConnection: RedisCommandChannel, @unchecked Sendable { throw RedisPluginError.notConnected } stateLock.unlock() + try admit(scope, command: commands.first?.first) + try moveToCommandDatabase(visiting: visiting) let generation = cancellationGate.beginQuery() defer { cancellationGate.endQuery(generation) } - let results = try executePipelineSyncRetrying(commands) + let results = try executePipelineSyncRetrying(commands, scope: scope) try throwIfCancelled(generation) return results } @@ -267,13 +279,78 @@ final class RedisPluginConnection: RedisCommandChannel, @unchecked Sendable { #endif } + /// Hands a lost block or `WATCH` to the connection that replaces this one, so a Sentinel + /// failover that re-points mid-block still reports it. + func sessionStateForHandOver() -> RedisHeldState? { + stateLock.lock() + defer { stateLock.unlock() } + return _footprint.pendingLoss ?? _footprint.heldState + } + + func adoptLostSessionState(_ held: RedisHeldState?) { + stateLock.lock() + _footprint.adoptLoss(held) + stateLock.unlock() + } + + /// Runs on the serial queue right before the send, so no other command can land between the + /// move and the command it is for. An open block is left alone: a visit is held back from one, + /// so the session cannot be away from home while it is open. + private func moveToCommandDatabase(visiting: Int?) throws { + stateLock.lock() + let target = _footprint.hasOpenBlock ? nil : _database.databaseToMoveTo(visiting: visiting) + stateLock.unlock() + guard let target else { return } + try select(target, scope: .outsideBlock) + stateLock.lock() + _database.visited(target) + stateLock.unlock() + } + + private func select(_ index: Int, scope: RedisCommandScope) throws { + let reply = try executeCommandSyncRetrying(["SELECT", String(index)].map { Data($0.utf8) }, scope: scope) + if case .error(let msg) = reply { + throw RedisPluginError(code: 2, message: "SELECT \(index) failed: \(msg)") + } + guard reply.isQueued else { return } + stateLock.lock() + _footprint.queueDatabase(index) + stateLock.unlock() + throw RedisQueuedCommand(command: "SELECT") + } + + private func admit(_ scope: RedisCommandScope, command: Data?) throws { + let name = command.flatMap { String(data: $0, encoding: .utf8) } ?? "" + stateLock.lock() + defer { stateLock.unlock() } + if scope == .session, let lost = _footprint.takePendingLoss() { + throw RedisSessionStateLost(held: lost, outcomeUnknown: false) + } + if let held = _footprint.heldBack(scope) { + throw RedisHeldBackCommand(command: name, held: held) + } + } + // MARK: - Database Selection /// A `SELECT` the server queued into an open `MULTI` block has not moved the session, so the - /// index is held aside until the block resolves rather than recorded now. Recording it now is - /// right only if `EXEC` follows: after a `DISCARD` the session is still on the old database, - /// and a `FLUSHDB` staged against the row the app believed it was on would empty that one. - func selectDatabase(_ index: Int) async throws { + /// index is held aside until the block resolves rather than recorded now, and the caller hears + /// it was queued. Recording it now is right only if `EXEC` follows: after a `DISCARD` the + /// session is still on the old database, and a `FLUSHDB` staged against the row the app + /// believed it was on would empty that one. + func selectDatabase(_ index: Int, scope: RedisCommandScope) async throws { + try await moveSession(to: index, scope: scope) { $0.selected(index) } + } + + func visitDatabase(_ index: Int) async throws { + try await moveSession(to: index, scope: .outsideBlock) { $0.visited(index) } + } + + private func moveSession( + to index: Int, + scope: RedisCommandScope, + recording move: @escaping @Sendable (inout RedisSessionDatabase) -> Void + ) async throws { #if canImport(CRedis) try await pluginDispatchAsync(on: queue) { [self] in guard !isShuttingDown else { @@ -285,19 +362,12 @@ final class RedisPluginConnection: RedisCommandChannel, @unchecked Sendable { throw RedisPluginError.notConnected } stateLock.unlock() + try admit(scope, command: Data("SELECT".utf8)) let generation = cancellationGate.beginQuery() defer { cancellationGate.endQuery(generation) } - let reply = try executeCommandSyncRetrying(["SELECT", String(index)]) - if case .error(let msg) = reply { - throw RedisPluginError(code: 2, message: "SELECT \(index) failed: \(msg)") - } + try select(index, scope: scope) stateLock.lock() - if reply.isQueued { - _queuedDatabase.queue(index) - } else { - _queuedDatabase.clear() - _currentDatabase = index - } + move(&_database) stateLock.unlock() } #else @@ -464,7 +534,7 @@ private extension RedisPluginConnection { context = nil sslContext = nil _isConnected = false - _queuedDatabase.clear() + _footprint.sessionEnded() stateLock.unlock() if let handle { redisFree(handle) } if let ssl { redisFreeSSLContext(ssl) } @@ -485,10 +555,6 @@ private extension RedisPluginConnection { try executeCommandSync(args.map { Data($0.utf8) }) } - func executeCommandSyncRetrying(_ args: [String]) throws -> RedisReply { - try executeCommandSyncRetrying(args.map { Data($0.utf8) }) - } - /// A lost connection is only safe to replay over when the command provably never ran. /// /// hiredis reports a read timeout as REDIS_ERR_IO, exactly like a failed write, so the old @@ -496,44 +562,81 @@ private extension RedisPluginConnection { /// server made one INCR count twice. The write and the read are split so the failure knows /// which side it happened on. An incomplete RESP command is never executed, so a failed write /// is always replayable; once the command is on the wire only a read-only command is. - func executeCommandSyncRetrying(_ args: [Data]) throws -> RedisReply { - let reply = try sendAllowingReplay(args) - resolveQueuedDatabase(command: args.first, reply: reply) + func executeCommandSyncRetrying(_ args: [Data], scope: RedisCommandScope) throws -> RedisReply { + let reply = try sendAllowingReplay(args, scope: scope) + observe(command: args.first, reply: reply) return reply } - private func sendAllowingReplay(_ args: [Data]) throws -> RedisReply { + private func sendAllowingReplay(_ args: [Data], scope: RedisCommandScope) throws -> RedisReply { do { return try executeCommandSync(args) - } catch let failure as RedisTransportFailure where !isShuttingDown && canReplay(args, after: failure) { + } catch let failure as RedisTransportFailure where !isShuttingDown { + try reportLostSessionState(for: args.first, scope: scope, after: failure) + guard canReplay(args, after: failure) else { throw failure } try reconnectSync() return try executeCommandSync(args) } } - /// The block a queued `SELECT` was held in has resolved, so the session's database follows the - /// server's own answer. `reconnectSync` frees the context, which drops any pending index, so a - /// replay never promotes one the lost session had queued. - private func resolveQueuedDatabase(command: Data?, reply: RedisReply) { + /// The user's own command was meant for a block or a `WATCH` the dropped session held, so + /// sending it again on a new session would run it outside the transaction it belongs to. It + /// reports the loss instead, and the connection is reopened so the next command works. A read + /// the app makes replays as before and leaves the loss for the user's next command. + private func reportLostSessionState( + for command: Data?, + scope: RedisCommandScope, + after failure: RedisTransportFailure + ) throws { + guard scope == .session, let held = heldOrLostState() else { return } + let isExec = command.flatMap { String(data: $0, encoding: .utf8) }?.uppercased() == "EXEC" + do { + try reconnectSync() + } catch { + logger.warning("Reconnect after a lost \(String(describing: held), privacy: .public) failed") + } + stateLock.lock() + _ = _footprint.takePendingLoss() + stateLock.unlock() + throw RedisSessionStateLost(held: held, outcomeUnknown: isExec && failure.wasDelivered) + } + + /// A pipeline that fails has already let go of its context, which latched what it held. + private func heldOrLostState() -> RedisHeldState? { + stateLock.lock() + defer { stateLock.unlock() } + return _footprint.heldState ?? _footprint.pendingLoss + } + + /// Every reply tells the footprint what the session holds now. The block a queued `SELECT` was + /// held in may have resolved, so the session's database follows the server's own answer. + /// `reconnectSync` frees the context, which drops any pending index, so a replay never promotes + /// one the lost session had queued. + private func observe(command: Data?, reply: RedisReply) { let name = command.flatMap { String(data: $0, encoding: .utf8) } stateLock.lock() - if let selected = _queuedDatabase.resolve(command: name, reply: reply) { - _currentDatabase = selected + if let selected = _footprint.observe(command: name, reply: reply) { + _database.selected(selected) } stateLock.unlock() } /// A pipeline puts several commands in one buffer, so a read failure part-way through cannot /// say which of them ran. Replaying is only safe when none of them writes. - func executePipelineSyncRetrying(_ commands: [[Data]]) throws -> [RedisReply] { + func executePipelineSyncRetrying(_ commands: [[Data]], scope: RedisCommandScope) throws -> [RedisReply] { + let replies: [RedisReply] do { - return try executePipelineSync(commands) - } catch let failure as RedisTransportFailure - where !isShuttingDown && (!failure.wasDelivered || commands.allSatisfy({ routing.isReadOnly($0) })) - { + replies = try executePipelineSync(commands) + } catch let failure as RedisTransportFailure where !isShuttingDown { + try reportLostSessionState(for: commands.first?.first, scope: scope, after: failure) + guard !failure.wasDelivered || commands.allSatisfy({ routing.isReadOnly($0) }) else { throw failure } try reconnectSync() - return try executePipelineSync(commands) + replies = try executePipelineSync(commands) + } + for (command, reply) in zip(commands, replies) { + observe(command: command.first, reply: reply) } + return replies } func canReplay(_ args: [Data], after failure: RedisTransportFailure) -> Bool { @@ -640,6 +743,7 @@ private extension RedisPluginConnection { let handle = context context = nil _isConnected = false + _footprint.sessionEnded() stateLock.unlock() #if canImport(CRedis) if let handle { diff --git a/Plugins/RedisDriverPlugin/RedisPluginDriver+Operations.swift b/Plugins/RedisDriverPlugin/RedisPluginDriver+Operations.swift index ef5d65c38e..2f46eb4f48 100644 --- a/Plugins/RedisDriverPlugin/RedisPluginDriver+Operations.swift +++ b/Plugins/RedisDriverPlugin/RedisPluginDriver+Operations.swift @@ -38,11 +38,13 @@ extension RedisPluginDriver { case .get, .set, .del, .keys, .scan, .type, .ttl, .pttl, .expire, .persist, .rename, .exists: return try await executeKeyOperation(operation, connection: conn, startTime: startTime) - case .keyBrowse(let pattern, let typeScope, let limit, let offset): - return try await executeKeyBrowse( - pattern: pattern, typeScope: typeScope, limit: limit, offset: offset, - connection: conn, startTime: startTime - ) + case .keyBrowse(let pattern, let typeScope, let limit, let offset, let database): + return try await conn.withDatabase(database) { + try await executeKeyBrowse( + pattern: pattern, typeScope: typeScope, limit: limit, offset: offset, + connection: conn, startTime: startTime + ) + } case .keyTree(let pattern, let limit): return try await executeKeyTree( diff --git a/Plugins/RedisDriverPlugin/RedisPluginDriver+ResultBuilding.swift b/Plugins/RedisDriverPlugin/RedisPluginDriver+ResultBuilding.swift index bfee5a2b52..3f2971b55b 100644 --- a/Plugins/RedisDriverPlugin/RedisPluginDriver+ResultBuilding.swift +++ b/Plugins/RedisDriverPlugin/RedisPluginDriver+ResultBuilding.swift @@ -6,12 +6,6 @@ import Foundation import TableProPluginKit -private struct RedisKeyProbe { - let kind: RedisKeyKind - let lengthIndex: Int - let previewIndex: Int -} - extension RedisPluginDriver { static let keyBrowseColumns = ["Key", "Type", "TTL", "Length", "Value"] static let keyBrowseColumnTypeNames = ["String", "RedisType", "RedisInt", "Int64", "RedisRaw"] @@ -24,13 +18,9 @@ extension RedisPluginDriver { startTime: Date, isTruncated: Bool ) async throws -> PluginQueryResult { - var rows: [PluginRow] = [] - if !keys.isEmpty { - let typeReplies = try await conn.executePipeline(keys.map { ["TYPE", $0] }) - rows.reserveCapacity(keys.count) - for (i, key) in keys.enumerated() { - rows.append([.text(key), .text((typeReplies[i].stringValue ?? "unknown").uppercased())]) - } + let typeNames = try await conn.keyTypeNames(keys) + let rows: [PluginRow] = zip(keys, typeNames).map { key, typeName in + [.text(key), .fromOptional(typeName?.uppercased())] } return PluginQueryResult( @@ -70,63 +60,22 @@ extension RedisPluginDriver { ) async throws -> [PluginRow] { guard !keys.isEmpty else { return [] } - var typeAndTtlCommands: [[String]] = [] - typeAndTtlCommands.reserveCapacity(keys.count * 2) - for key in keys { - typeAndTtlCommands.append(["TYPE", key]) - typeAndTtlCommands.append(["TTL", key]) - } - let typeAndTtlReplies = try await conn.executePipeline(typeAndTtlCommands) - - var typeNames: [String] = [] - typeNames.reserveCapacity(keys.count) - var ttlValues: [Int] = [] - ttlValues.reserveCapacity(keys.count) - for i in 0 ..< keys.count { - typeNames.append((typeAndTtlReplies[i * 2].stringValue ?? "unknown").uppercased()) - ttlValues.append(typeAndTtlReplies[i * 2 + 1].intValue ?? -1) - } - - var probeCommands: [[String]] = [] - probeCommands.reserveCapacity(keys.count * 2) - var probes: [RedisKeyProbe?] = [] - probes.reserveCapacity(keys.count) - - for (i, key) in keys.enumerated() { - guard let kind = RedisKeyKind(typeName: typeNames[i]) else { - probes.append(nil) - continue - } - let lengthIndex = probeCommands.count - probeCommands.append(RedisKeySummary.lengthCommand(for: kind, key: key)) - let previewIndex = probeCommands.count - probeCommands.append(RedisKeySummary.previewCommand(for: kind, key: key)) - probes.append(RedisKeyProbe(kind: kind, lengthIndex: lengthIndex, previewIndex: previewIndex)) - } - - var probeReplies: [RedisReply] = [] - if !probeCommands.isEmpty { - probeReplies = try await conn.executePipeline(probeCommands) - } - - var rows: [PluginRow] = [] - rows.reserveCapacity(keys.count) - for (i, key) in keys.enumerated() { - var length: String? - var value = PluginCellValue.null - if let probe = probes[i], probe.previewIndex < probeReplies.count { - length = probeReplies[probe.lengthIndex].intValue.map(String.init) - value = previewCell(probeReplies[probe.previewIndex], kind: probe.kind) - } - rows.append([ + let descriptions = try await conn.describeKeys(keys) + let contents = try await conn.readContents(of: keys, describedAs: descriptions) + return zip(keys, zip(descriptions, contents)).map { key, summary in + let (description, content) = summary + return [ .text(key), - .text(typeNames[i]), - .text(String(ttlValues[i])), - PluginCellValue.fromOptional(length), - value - ]) + description.typeCell, + description.ttlCell, + content?.lengthCell ?? .null, + content.flatMap(valueCell(for:)) ?? .null + ] } - return rows + } + + private func valueCell(for content: RedisKeyContents) -> PluginCellValue? { + content.preview.map { previewCell($0, kind: content.kind) } } func previewCell(_ reply: RedisReply, kind: RedisKeyKind) -> PluginCellValue { diff --git a/Plugins/RedisDriverPlugin/RedisPluginDriver+Scan.swift b/Plugins/RedisDriverPlugin/RedisPluginDriver+Scan.swift index 4f88bcb5a8..6d4f25e4d4 100644 --- a/Plugins/RedisDriverPlugin/RedisPluginDriver+Scan.swift +++ b/Plugins/RedisDriverPlugin/RedisPluginDriver+Scan.swift @@ -20,7 +20,7 @@ extension RedisPluginDriver { repeat { try Task.checkCancellation() let page = try await conn.scanKeyspace( - cursor: cursor, pattern: pattern, type: typeFilter, count: 1_000 + cursor: cursor, pattern: pattern, type: typeFilter, count: 1_000, scope: .outsideBlock ) cursor = page.cursor allKeys.append(contentsOf: page.keys) diff --git a/Plugins/RedisDriverPlugin/RedisPluginDriver.swift b/Plugins/RedisDriverPlugin/RedisPluginDriver.swift index 9a21020572..0c32b8003c 100644 --- a/Plugins/RedisDriverPlugin/RedisPluginDriver.swift +++ b/Plugins/RedisDriverPlugin/RedisPluginDriver.swift @@ -59,7 +59,7 @@ final class RedisPluginDriver: PluginDatabaseDriver, @unchecked Sendable { func quoteIdentifier(_ name: String) -> String { name } func defaultExportQuery(table: String) -> String? { - "SCAN 0 MATCH \"*\" COUNT 10000" + RedisQueryBuilder().buildExportQuery(database: RedisDatabaseIndex.parse(table)) } init(config: DriverConnectionConfig) { @@ -140,7 +140,7 @@ final class RedisPluginDriver: PluginDatabaseDriver, @unchecked Sendable { /// cleanly and then fails on every real command. INFO says which kind of server answered, so /// the mismatch is reported once, at connect, naming the field to change. private func verifyServerMode(_ expected: RedisConnectionMode, on channel: any RedisCommandChannel) async throws { - guard let info = try? await channel.executeCommand(["INFO", "server"]).stringValue, + guard let info = try? await channel.executeCommand(["INFO", "server"], scope: .outsideBlock).stringValue, let actual = RedisServerInfo.mode(from: info) else { return } let isTunneled = config.additionalFields["preTunnelHost"]?.isEmpty == false guard let message = RedisTopologyDiagnostics.mismatch( @@ -164,25 +164,15 @@ final class RedisPluginDriver: PluginDatabaseDriver, @unchecked Sendable { /// socket, and reconnecting cannot talk a restricted user into `+ping` or hurry a busy script /// along. /// - /// A lost socket does not reach here either, but not for the reason this used to give: rather - /// than throwing, `executeCommand` reconnects and replays through - /// `executeCommandSyncRetrying`. That is survivable for Redis in a way it is not for the SQL - /// engines, whose pings are deliberately non-reconnecting, because `reconnectSync` re-selects - /// the database and Redis carries almost no other session state. What it does not restore is - /// the connection's startup commands, so a probe can still report success on a session that - /// lost them. + /// A lost socket does not reach here either: `executeCommand` reconnects and replays through + /// `executeCommandSyncRetrying`. The one session state a replay cannot carry is a user's open + /// block or watched keys, and the probe never sends into those, so the reconnect reports the + /// loss to the user's next command instead. func ping() async throws { guard let conn = redisConnection else { throw RedisPluginError.notConnected } - let reply = try await conn.executeCommand(RedisConnectProbe.command) - if RedisConnectProbe.outcome(errorMessage: reply.errorMessage) == .unauthenticated { - throw RedisPluginError( - code: 3, - message: RedisConnectProbe.unauthenticatedMessage, - detail: RedisConnectProbe.unauthenticatedHint - ) - } + try await conn.probeHealth() try await conn.verifyStillPrimary() } @@ -243,39 +233,26 @@ final class RedisPluginDriver: PluginDatabaseDriver, @unchecked Sendable { guard let conn = redisConnection else { throw RedisPluginError.notConnected } - guard conn.supportsDatabaseSelection else { - let count = try await conn.run(["DBSIZE"]).intValue ?? 0 - return [PluginTableInfo(name: Self.clusterDatabaseName, type: "TABLE", rowCount: count)] - } - - let databases = try await databaseCount(on: conn) - let result = try await conn.run(["INFO", "keyspace"]) - let info = result.stringValue ?? "" - - return (0 ..< databases).map { index in - let dbName = "db\(index)" - let count = RedisServerInfo.keyCount(forDatabase: dbName, in: info) ?? 0 - return PluginTableInfo(name: dbName, type: "TABLE", rowCount: count) + let listing = try await conn.databaseListing(includingKeyCounts: true) + return (0 ..< listing.databaseCount).map { index in + PluginTableInfo(name: "db\(index)", type: "TABLE", rowCount: listing.keyCount(forDatabase: index)) } } static let clusterDatabaseName = "db0" - /// A cluster node answers CONFIG GET databases with 1, and refuses SELECT with any other - /// index, so the tree shows the one keyspace that exists rather than fifteen that do not. - private func databaseCount(on conn: any RedisCommandChannel) async throws -> Int { - guard conn.supportsDatabaseSelection else { return 1 } - let reply = try await conn.run(["CONFIG", "GET", "databases"]) - guard let array = reply.arrayValue, array.count >= 2, let count = array[1].intValue, count > 0 else { - return 16 + static func databaseIndex(of name: String) throws -> Int { + guard let index = RedisDatabaseIndex.parse(name), index >= 0 else { + let template = String(localized: "%@ is not a Redis database index.") + throw RedisPluginError(code: 0, message: String(format: template, name)) } - return count + return index } func fetchColumns(table: String, schema: String?) async throws -> [PluginColumnInfo] { [ PluginColumnInfo(name: "Key", dataType: "String", isNullable: false, isPrimaryKey: true), - PluginColumnInfo(name: "Type", dataType: "String", isNullable: false), + PluginColumnInfo(name: "Type", dataType: "String", isNullable: true), PluginColumnInfo(name: "TTL", dataType: "Int64", isNullable: true), PluginColumnInfo(name: "Length", dataType: "Int64", isNullable: true, isGenerated: true), PluginColumnInfo(name: "Value", dataType: "String", isNullable: true), @@ -304,8 +281,8 @@ final class RedisPluginDriver: PluginDatabaseDriver, @unchecked Sendable { guard let conn = redisConnection else { throw RedisPluginError.notConnected } - let result = try await conn.run(["DBSIZE"]) - return result.intValue + guard let index = RedisDatabaseIndex.parse(table) else { return nil } + return try await conn.keyCount(inDatabase: index) } func fetchTableDDL(table: String, schema: String?) async throws -> String { @@ -313,33 +290,29 @@ final class RedisPluginDriver: PluginDatabaseDriver, @unchecked Sendable { throw RedisPluginError.notConnected } - let result = try await conn.run(["DBSIZE"]) - let keyCount = result.intValue ?? 0 + let index = try Self.databaseIndex(of: table) + let keyCount = try await conn.keyCount(inDatabase: index) var lines: [String] = [ "// Redis database: \(table)", - "// Keys: \(keyCount)", + "// Keys: \(keyCount.map(String.init) ?? "unknown")", "// Use SCAN 0 MATCH * COUNT 200 to browse keys", ] - let keys = try await scanAllKeys(connection: conn, pattern: nil, maxKeys: 100) - if !keys.isEmpty { - let typeCommands = keys.map { ["TYPE", $0] } - let replies = try await conn.executePipeline(typeCommands) - - var typeCounts: [String: Int] = [:] - for reply in replies { - if let typeName = reply.stringValue { - typeCounts[typeName, default: 0] += 1 - } - } + let (keys, typeNames) = try await conn.withDatabase(index) { + let keys = try await scanAllKeys(connection: conn, pattern: nil, maxKeys: 100) + return (keys, try await conn.keyTypeNames(keys)) + } + var typeCounts: [String: Int] = [:] + for typeName in typeNames.compactMap({ $0 }) { + typeCounts[typeName, default: 0] += 1 + } - if !typeCounts.isEmpty { - lines.append("//") - lines.append("// Type distribution (sampled \(keys.count) keys):") - for (type, count) in typeCounts.sorted(by: { $0.key < $1.key }) { - lines.append("// \(type): \(count)") - } + if !typeCounts.isEmpty { + lines.append("//") + lines.append("// Type distribution (sampled \(keys.count) keys):") + for (type, count) in typeCounts.sorted(by: { $0.key < $1.key }) { + lines.append("// \(type): \(count)") } } @@ -355,12 +328,10 @@ final class RedisPluginDriver: PluginDatabaseDriver, @unchecked Sendable { throw RedisPluginError.notConnected } - let result = try await conn.run(["DBSIZE"]) - let keyCount = result.intValue ?? 0 - + let keyCount = try await conn.keyCount(inDatabase: Self.databaseIndex(of: table)) return PluginTableMetadata( tableName: table, - rowCount: Int64(keyCount), + rowCount: keyCount.map(Int64.init), engine: "Redis" ) } @@ -369,7 +340,8 @@ final class RedisPluginDriver: PluginDatabaseDriver, @unchecked Sendable { guard let conn = redisConnection else { throw RedisPluginError.notConnected } - return try await (0 ..< databaseCount(on: conn)).map { "db\($0)" } + let listing = try await conn.databaseListing(includingKeyCounts: false) + return (0 ..< listing.databaseCount).map { "db\($0)" } } func fetchDatabaseMetadata(_ database: String) async throws -> PluginDatabaseMetadata { @@ -378,19 +350,11 @@ final class RedisPluginDriver: PluginDatabaseDriver, @unchecked Sendable { } let dbName = database.hasPrefix("db") ? database : "db\(database)" - - guard conn.supportsDatabaseSelection else { - let count = try await conn.run(["DBSIZE"]).intValue ?? 0 - return PluginDatabaseMetadata(name: Self.clusterDatabaseName, tableCount: count) - } - - let infoResult = try await conn.run(["INFO", "keyspace"]) - guard let infoStr = infoResult.stringValue else { - return PluginDatabaseMetadata(name: dbName, tableCount: 0) - } + let keyCounts = try await conn.keyCountsByDatabase() + let index = RedisDatabaseIndex.parse(database) return PluginDatabaseMetadata( name: dbName, - tableCount: RedisServerInfo.keyCount(forDatabase: dbName, in: infoStr) ?? 0 + tableCount: keyCounts.map { counts in index.flatMap { counts[$0] } ?? 0 } ) } @@ -417,7 +381,7 @@ final class RedisPluginDriver: PluginDatabaseDriver, @unchecked Sendable { func beginTransaction() async throws { guard let conn = redisConnection else { throw RedisPluginError.notConnected } clearQueuedCommands() - try await conn.run(["MULTI"]) + try await conn.run(["MULTI"], scope: .cleanSession) } func commitTransaction() async throws { @@ -440,11 +404,7 @@ final class RedisPluginDriver: PluginDatabaseDriver, @unchecked Sendable { func switchDatabase(to database: String) async throws { guard let conn = redisConnection else { throw RedisPluginError.notConnected } - guard let dbIndex = RedisDatabaseIndex.parse(database) else { - let template = String(localized: "%@ is not a Redis database index.") - throw RedisPluginError(code: 0, message: String(format: template, database)) - } - try await conn.selectDatabase(dbIndex) + try await conn.moveToDatabase(Self.databaseIndex(of: database)) } // MARK: - Table Operations @@ -460,7 +420,7 @@ final class RedisPluginDriver: PluginDatabaseDriver, @unchecked Sendable { guard conn.supportsDatabaseSelection else { return table == Self.clusterDatabaseName ? ["FLUSHDB"] : nil } - guard let index = RedisDatabaseIndex.parse(table), index == conn.currentDatabase() else { return nil } + guard let index = RedisDatabaseIndex.parse(table), index == conn.homeDatabase() else { return nil } return ["FLUSHDB"] } @@ -469,45 +429,6 @@ final class RedisPluginDriver: PluginDatabaseDriver, @unchecked Sendable { nil } - // MARK: - EXPLAIN - - func buildExplainQuery(_ sql: String) -> String? { - guard let operation = try? RedisCommandParser.parse(sql) else { - return nil - } - - let key: String? = { - switch operation { - case .get(let k), .type(let k), .ttl(let k), .pttl(let k), - .expire(let k, _), .persist(let k), - .hget(let k, _), .hgetall(let k), .hdel(let k, _), - .lrange(let k, _, _), .llen(let k), - .smembers(let k), .scard(let k), - .zrange(let k, _, _, _), .zcard(let k), - .xrange(let k, _, _, _), .xlen(let k): - return k - case .set(let k, _, _): - return k - case .hset(let k, _): - return k - case .lpush(let k, _), .rpush(let k, _): - return k - case .sadd(let k, _), .srem(let k, _): - return k - case .zadd(let k, _, _), .zrem(let k, _): - return k - case .del(let keys) where keys.count == 1: - return keys[0] - default: - return nil - } - }() - - guard let key else { return nil } - let quoted = key.contains(" ") || key.contains("\"") ? "\"\(key.replacingOccurrences(of: "\"", with: "\\\""))\"" : key - return "DEBUG OBJECT \(quoted)" - } - // MARK: - View Templates func createViewTemplate() -> String? { @@ -552,11 +473,17 @@ final class RedisPluginDriver: PluginDatabaseDriver, @unchecked Sendable { switch operation { case .scan(_, let pattern, _): - try await streamScanRows(connection: conn, pattern: pattern, continuation: continuation) - case .keyBrowse(let pattern, let typeScope, _, _): - try await streamScanRows( - connection: conn, pattern: pattern, typeFilter: typeScope, continuation: continuation - ) + try await streamScanRows(connection: conn, pattern: pattern, scope: .session, continuation: continuation) + case .keyBrowse(let pattern, let typeScope, _, _, let database): + try await conn.withDatabase(database) { + try await streamScanRows( + connection: conn, + pattern: pattern, + typeFilter: typeScope, + scope: .outsideBlock, + continuation: continuation + ) + } default: let startTime = Date() let result = try await executeOperation(operation, connection: conn, startTime: startTime) @@ -576,6 +503,7 @@ final class RedisPluginDriver: PluginDatabaseDriver, @unchecked Sendable { connection conn: any RedisCommandChannel, pattern: String?, typeFilter: String? = nil, + scope: RedisCommandScope, continuation: AsyncThrowingStream.Continuation ) async throws { continuation.yield(.header(PluginStreamHeader( @@ -591,7 +519,7 @@ final class RedisPluginDriver: PluginDatabaseDriver, @unchecked Sendable { try Task.checkCancellation() let page = try await conn.scanKeyspace( - cursor: cursor, pattern: pattern, type: typeFilter, count: 1_000 + cursor: cursor, pattern: pattern, type: typeFilter, count: 1_000, scope: scope ) cursor = page.cursor @@ -622,7 +550,7 @@ final class RedisPluginDriver: PluginDatabaseDriver, @unchecked Sendable { ) -> String? { let builder = RedisQueryBuilder() return builder.buildBaseQuery( - namespace: "", sortColumns: sortColumns, + namespace: "", database: RedisDatabaseIndex.parse(table), sortColumns: sortColumns, columns: columns, limit: limit, offset: offset ) } @@ -638,7 +566,7 @@ final class RedisPluginDriver: PluginDatabaseDriver, @unchecked Sendable { ) -> String? { let builder = RedisQueryBuilder() return builder.buildFilteredQuery( - namespace: "", filters: filters, + namespace: "", database: RedisDatabaseIndex.parse(table), filters: filters, logicMode: logicMode, limit: limit, offset: offset ) } @@ -653,9 +581,15 @@ final class RedisPluginDriver: PluginDatabaseDriver, @unchecked Sendable { insertedRowIndices: Set ) -> [(statement: String, parameters: [PluginCellValue])]? { let generator = RedisStatementGenerator(namespaceName: table, columns: columns) - return generator.generateStatements( + let statements = generator.generateStatements( from: changes, insertedRowData: insertedRowData, deletedRowIndices: deletedRowIndices, insertedRowIndices: insertedRowIndices ) + guard let conn = redisConnection, conn.supportsDatabaseSelection else { return statements } + return RedisDatabaseTarget.addressing( + statements, + toDatabase: RedisDatabaseIndex.parse(table), + from: conn.homeDatabase() + ) } } diff --git a/Plugins/RedisDriverPlugin/RedisQueryBuilder.swift b/Plugins/RedisDriverPlugin/RedisQueryBuilder.swift index d42da7428e..004cafa0cc 100644 --- a/Plugins/RedisDriverPlugin/RedisQueryBuilder.swift +++ b/Plugins/RedisDriverPlugin/RedisQueryBuilder.swift @@ -16,13 +16,14 @@ struct RedisQueryBuilder { /// to completion (bounded) and honors LIMIT/OFFSET, so paging returns every key. func buildBaseQuery( namespace: String, + database: Int? = nil, sortColumns: [(columnIndex: Int, ascending: Bool)] = [], columns: [String] = [], limit: Int = 200, offset: Int = 0 ) -> String { let pattern = namespace.isEmpty ? nil : "\(namespace)*" - return buildKeyBrowseQuery(pattern: pattern, typeScope: nil, limit: limit, offset: offset) + return buildKeyBrowseQuery(pattern: pattern, typeScope: nil, database: database, limit: limit, offset: offset) } /// Build a key-browse command from filter tuples. @@ -31,6 +32,7 @@ struct RedisQueryBuilder { /// Legacy Key operators (CONTAINS, STARTS WITH, ...) still resolve to an escaped glob. func buildFilteredQuery( namespace: String, + database: Int? = nil, filters: [(column: String, op: String, value: String)], logicMode: String = "and", limit: Int = 200, @@ -40,14 +42,31 @@ struct RedisQueryBuilder { let typeScope = extractTypeScope(from: filters) guard pattern != nil || typeScope != nil else { - return buildBaseQuery(namespace: namespace, limit: limit, offset: offset) + return buildBaseQuery(namespace: namespace, database: database, limit: limit, offset: offset) } - return buildKeyBrowseQuery(pattern: pattern, typeScope: typeScope, limit: limit, offset: offset) + return buildKeyBrowseQuery( + pattern: pattern, typeScope: typeScope, database: database, limit: limit, offset: offset + ) } - func buildKeyBrowseQuery(pattern: String?, typeScope: String?, limit: Int, offset: Int) -> String { + /// Streamed, so the whole database is read and `LIMIT` is never consulted. + func buildExportQuery(database: Int?) -> String { + guard let database else { return "KEYBROWSE" } + return "KEYBROWSE DB \(database)" + } + + func buildKeyBrowseQuery( + pattern: String?, + typeScope: String?, + database: Int? = nil, + limit: Int, + offset: Int + ) -> String { var command = "KEYBROWSE" + if let database { + command += " DB \(database)" + } if let pattern, !pattern.isEmpty { command += " MATCH \"\(quoteForCommand(pattern))\"" } diff --git a/Plugins/RedisDriverPlugin/RedisSentinelChannel.swift b/Plugins/RedisDriverPlugin/RedisSentinelChannel.swift index 804c9186d1..99b189fb89 100644 --- a/Plugins/RedisDriverPlugin/RedisSentinelChannel.swift +++ b/Plugins/RedisDriverPlugin/RedisSentinelChannel.swift @@ -82,24 +82,32 @@ final class RedisSentinelChannel: RedisCommandChannel, @unchecked Sendable { func currentDatabase() -> Int { current?.currentDatabase() ?? database } - func executeCommand(_ args: [Data]) async throws -> RedisReply { + func databaseForNextCommand() -> Int { current?.databaseForNextCommand() ?? database } + + func homeDatabase() -> Int { current?.homeDatabase() ?? database } + + func visitDatabase(_ index: Int) async throws { + try await withFailoverRetry(isReplayable: { _ in true }) { try await $0.visitDatabase(index) } + } + + func executeCommand(_ args: [Data], scope: RedisCommandScope) async throws -> RedisReply { try await withFailoverRetry(isReplayable: { replayable($0, ifReadOnly: args) }) { - try await $0.executeCommand(args) + try await $0.executeCommand(args, scope: scope) } } - func executePipeline(_ commands: [[Data]]) async throws -> [RedisReply] { + func executePipeline(_ commands: [[Data]], scope: RedisCommandScope) async throws -> [RedisReply] { try await withFailoverRetry( isReplayable: { failure in !failure.wasDelivered || commands.allSatisfy { replayable(failure, ifReadOnly: $0) } } ) { - try await $0.executePipeline(commands) + try await $0.executePipeline(commands, scope: scope) } } - func selectDatabase(_ index: Int) async throws { - try await withFailoverRetry(isReplayable: { _ in true }) { try await $0.selectDatabase(index) } + func selectDatabase(_ index: Int, scope: RedisCommandScope) async throws { + try await withFailoverRetry(isReplayable: { _ in true }) { try await $0.selectDatabase(index, scope: scope) } } /// Re-asks the quorum and re-points the connection when the primary has moved. Called from the @@ -154,6 +162,7 @@ final class RedisSentinelChannel: RedisCommandChannel, @unchecked Sendable { ) try await opened.connect(reportingStage: report) + opened.adoptLostSessionState(current?.sessionStateForHandOver()) guard let previous = adopt(opened, at: address) else { opened.disconnect() throw RedisPluginError.notConnected diff --git a/Plugins/RedisDriverPlugin/RedisSessionFootprint.swift b/Plugins/RedisDriverPlugin/RedisSessionFootprint.swift new file mode 100644 index 0000000000..51deac9740 --- /dev/null +++ b/Plugins/RedisDriverPlugin/RedisSessionFootprint.swift @@ -0,0 +1,219 @@ +// +// RedisSessionFootprint.swift +// RedisDriverPlugin +// +// The state a user's MULTI or WATCH leaves on the server session, which every command on the +// connection shares because Redis never pools. Measured on Redis 8.10.1: +// +// - Inside a block only EXEC, DISCARD, MULTI, WATCH, QUIT and RESET run; every other command +// answers `+QUEUED`, or an error that marks the block so EXEC answers EXECABORT. +// - A nested MULTI, a WATCH inside a block and a refused MULTI or DISCARD leave the block as it was. +// - EXEC ends the block and every WATCH whatever it answers; with no block open it is refused and +// the WATCH stays. RESET ends both and moves the session to database 0. +// - A server drops a client's block and its watched keys when the connection closes. +// +// So the app's own reads have to stay out of an open block, and a reconnect over one has to say +// the block is gone rather than replay into a session that never had it. +// + +import Foundation +import TableProPluginKit + +enum RedisCommandScope: Equatable, Sendable { + /// A command the user typed, which belongs in their block when one is open. + case session + /// A read the app makes on its own, which must never join the user's block. + case outsideBlock + /// The app's own transaction, which needs a session holding no block and no watched keys. + case cleanSession +} + +enum RedisHeldState: Equatable, Sendable { + case openBlock + case watchedKeys +} + +struct RedisSessionFootprint: Equatable, Sendable { + private(set) var hasOpenBlock = false + private(set) var isWatching = false + private(set) var pendingLoss: RedisHeldState? + private var queuedDatabase = RedisQueuedDatabase() + + var pendingDatabase: Int? { queuedDatabase.pending } + + var heldState: RedisHeldState? { + if hasOpenBlock { return .openBlock } + return isWatching ? .watchedKeys : nil + } + + func heldBack(_ scope: RedisCommandScope) -> RedisHeldState? { + switch scope { + case .session: + return nil + case .outsideBlock: + return hasOpenBlock ? .openBlock : nil + case .cleanSession: + return heldState + } + } + + /// A loss is reported once, to the next command the user sends. + mutating func takePendingLoss() -> RedisHeldState? { + defer { pendingLoss = nil } + return pendingLoss + } + + mutating func adoptLoss(_ held: RedisHeldState?) { + guard let held, pendingLoss == nil else { return } + pendingLoss = held + } + + mutating func queueDatabase(_ index: Int) { + queuedDatabase.queue(index) + } + + /// The session the state lived on is gone, so whatever it held is latched as lost and the + /// footprint starts again from a clean session. + mutating func sessionEnded() { + adoptLoss(heldState) + hasOpenBlock = false + isWatching = false + queuedDatabase.clear() + } + + /// Returns the database the session moved to when the reply moved it. + mutating func observe(command: String?, reply: RedisReply) -> Int? { + let name = command?.uppercased() ?? "" + let movedTo = queuedDatabase.resolve(command: name, reply: reply) + if reply.isQueued { + hasOpenBlock = true + return movedTo + } + switch name { + case "MULTI": + if !reply.isError { hasOpenBlock = true } + case "EXEC": + guard hasOpenBlock else { return movedTo } + endTransaction() + case "DISCARD": + if !reply.isError { endTransaction() } + case "RESET": + guard !reply.isError else { return movedTo } + endTransaction() + return 0 + case "WATCH": + if !reply.isError { isWatching = true } + case "UNWATCH": + if !reply.isError { isWatching = false } + default: + if !reply.isError { hasOpenBlock = false } + } + return movedTo + } + + private mutating func endTransaction() { + hasOpenBlock = false + isWatching = false + } +} + +/// Which numbered database the session is on, and which one it belongs on. +/// +/// A read the app makes for one row visits that row's database and returns, and every other +/// command runs where the session belongs. Each command checks right before it is sent, because +/// neither the move nor the return is atomic with the commands around it: a cancelled stream can +/// release the driver before its return reaches the server, and the health monitor's PING is not +/// held back by the session gate, so it can arrive in the middle of a visit. +struct RedisSessionDatabase: Equatable, Sendable { + private(set) var current: Int + private(set) var home: Int + + init(_ index: Int) { + current = index + home = index + } + + /// A SELECT the user typed, or a move the app made on their behalf. + mutating func selected(_ index: Int) { + current = index + home = index + } + + mutating func visited(_ index: Int) { + current = index + } + + /// The database a command has to move to before it runs, or nil when the session is already + /// there: the one being visited for a command that is part of a visit, home for any other. + func databaseToMoveTo(visiting: Int?) -> Int? { + let target = visiting ?? home + return current == target ? nil : target + } +} + +/// The database a read the app makes for one row is visiting, for the length of that read. +enum RedisDatabaseVisit { + @TaskLocal static var database: Int? +} + +/// A command the app did not send because the user's session holds state it would disturb. +struct RedisHeldBackCommand: Error, Equatable { + let command: String + let held: RedisHeldState +} + +extension RedisHeldBackCommand: PluginDriverError { + var pluginErrorMessage: String { + switch held { + case .openBlock: + return String(format: String(localized: "%@ was not sent because a MULTI block is open on this connection."), commandName) + case .watchedKeys: + return String(format: String(localized: "%@ was not sent because this connection is watching keys."), commandName) + } + } + + var pluginErrorDetail: String? { + switch held { + case .openBlock: + return String(localized: "Run EXEC to apply the block, or DISCARD to drop it.") + case .watchedKeys: + return String(localized: "Run EXEC, DISCARD or UNWATCH first.") + } + } + + private var commandName: String { + command.isEmpty ? String(localized: "The command") : command.uppercased() + } +} + +/// The connection dropped while the session held a block or watched keys, which the server +/// discards with the connection. +struct RedisSessionStateLost: Error, Equatable { + let held: RedisHeldState + /// EXEC reached the server before the connection dropped, so the block may have run. + let outcomeUnknown: Bool +} + +extension RedisSessionStateLost: PluginDriverError { + var pluginErrorMessage: String { + switch held { + case .openBlock where outcomeUnknown: + return String(localized: "The connection to Redis dropped after EXEC was sent, so whether the block ran is unknown.") + case .openBlock: + return String(localized: "The connection to Redis dropped, so the open MULTI block was lost and nothing in it ran.") + case .watchedKeys: + return String(localized: "The connection to Redis dropped, so the keys this session was watching are no longer watched.") + } + } + + var pluginErrorDetail: String? { + switch held { + case .openBlock where outcomeUnknown: + return String(localized: "Check the keys the block writes before running it again.") + case .openBlock: + return String(localized: "Start the block again with MULTI.") + case .watchedKeys: + return String(localized: "Run WATCH again before starting the block.") + } + } +} diff --git a/Plugins/RedisDriverPlugin/RedisStatementGenerator.swift b/Plugins/RedisDriverPlugin/RedisStatementGenerator.swift index ef2e7bb6be..60b8895e2c 100644 --- a/Plugins/RedisDriverPlugin/RedisStatementGenerator.swift +++ b/Plugins/RedisDriverPlugin/RedisStatementGenerator.swift @@ -188,14 +188,7 @@ struct RedisStatementGenerator { return key }() - let redisType: String? = { - guard let ti = typeColumnIndex, - let originalRow = change.originalRow, - ti < originalRow.count else { - return nil - } - return originalRow[ti].asText - }() + let valueType = valueWriteType(of: change) for cellChange in change.cellChanges { switch cellChange.columnName { @@ -203,7 +196,10 @@ struct RedisStatementGenerator { continue // Already handled above case "Value": guard let encodedValue = Self.encodedArgument(cellChange.newValue) else { continue } - let typeLower = redisType?.lowercased() ?? "string" + guard let typeLower = valueType else { + Self.logger.warning("Skipping Value update for key '\(effectiveKey)' - its type is unknown") + continue + } if typeLower != "string" { // Non-string types show a preview; blindly SET would destroy the data structure Self.logger.warning( @@ -231,6 +227,15 @@ struct RedisStatementGenerator { // MARK: - Helpers + /// A grid with no Type column holds strings. One with a Type column holds whatever the server + /// said, and a Type cell the server would not fill leaves nothing safe to write: `SET` over a + /// hash replaces the hash. + private func valueWriteType(of change: PluginRowChange) -> String? { + guard let typeIndex = typeColumnIndex else { return "string" } + guard let originalRow = change.originalRow, typeIndex < originalRow.count else { return nil } + return originalRow[typeIndex].asText?.lowercased() + } + /// Extract the key value from a PluginRowChange's original row private func extractKey(from change: PluginRowChange) -> String? { guard let keyIndex = keyColumnIndex, diff --git a/Plugins/RedisDriverPlugin/RedisTopologyDiagnostics.swift b/Plugins/RedisDriverPlugin/RedisTopologyDiagnostics.swift index d3478d5889..b0169bc30f 100644 --- a/Plugins/RedisDriverPlugin/RedisTopologyDiagnostics.swift +++ b/Plugins/RedisDriverPlugin/RedisTopologyDiagnostics.swift @@ -28,15 +28,29 @@ enum RedisServerInfo { static func version(from info: String) -> String? { value("redis_version", in: info) } + /// Valkey 8 and later write `server_mode` unless `extended-redis-compatibility` is on, so a + /// Sentinel port or a cluster node reporting only that line would otherwise pass as Standalone. static func mode(from info: String) -> RedisServerMode? { - value("redis_mode", in: info).flatMap(RedisServerMode.init(rawValue:)) + (value("redis_mode", in: info) ?? value("server_mode", in: info)).flatMap(RedisServerMode.init(rawValue:)) } - /// `INFO keyspace` reports one `dbN:keys=...` line per non-empty database on this node alone. - static func keyCount(forDatabase name: String, in info: String) -> Int? { - guard let stats = value(name, in: info) else { return nil } - for pair in stats.components(separatedBy: ",") { - let parts = pair.components(separatedBy: "=") + /// `INFO keyspace` reports one `dbN:keys=...` line per non-empty database on this node alone, + /// so a database missing from the answer holds no keys. + static func keyspace(from info: String) -> [Int: Int] { + var counts: [Int: Int] = [:] + for line in info.components(separatedBy: .newlines) { + let trimmed = line.trimmingCharacters(in: .whitespaces) + guard trimmed.hasPrefix("db"), let separator = trimmed.firstIndex(of: ":"), + let index = RedisDatabaseIndex.parse(String(trimmed[..= 0, + let keys = keyCount(inStats: trimmed[trimmed.index(after: separator)...]) else { continue } + counts[index] = keys + } + return counts + } + + private static func keyCount(inStats stats: Substring) -> Int? { + for pair in stats.split(separator: ",") { + let parts = pair.split(separator: "=", maxSplits: 1) if parts.count == 2, parts[0] == "keys", let count = Int(parts[1]) { return count } } return nil diff --git a/TablePro/Core/Coordinators/QueryExecutionCoordinator+Helpers.swift b/TablePro/Core/Coordinators/QueryExecutionCoordinator+Helpers.swift index 6fa7d3a6ba..61addab285 100644 --- a/TablePro/Core/Coordinators/QueryExecutionCoordinator+Helpers.swift +++ b/TablePro/Core/Coordinators/QueryExecutionCoordinator+Helpers.swift @@ -794,25 +794,10 @@ extension QueryExecutionCoordinator { helpersLogger.error( "Query failed on tab \(tabId, privacy: .public): \(error.publicLogShape, privacy: .public)" ) - parent.tabManager.mutate(tabId: tabId) { tab in - tab.execution.errorMessage = message + presentTabFailure(message, announcing: diagnosis, onTab: tabId) { tab in tab.execution.errorQuery = sql tab.execution.lastExecutedAt = Date() tab.execution.executionTime = nil - tab.pagination.isLoading = false - - // The banner lives at the top of the results pane, so a collapsed pane hides the only - // thing telling the user their query failed. Every success path opens it the same way. - if tab.display.isResultsCollapsed { - tab.display.isResultsCollapsed = false - } - } - // The toolbar mirrors the selected tab, so a failure on a tab in the background describes - // itself on its own tab and leaves the window chrome to whatever is actually on screen. - if parent.tabManager.selectedTabId == tabId { - parent.toolbarState.isResultsCollapsed = false - parent.toolbarState.clearQueryTiming(forTab: tabId) - parent.announceQueryError(diagnosis) } recordHistory( @@ -832,6 +817,37 @@ extension QueryExecutionCoordinator { } } +extension QueryExecutionCoordinator { + /// Shows a failure on the tab it belongs to without recording a statement that never ran, so a + /// step that fails before the tab's query, such as the database switch in front of it, leaves + /// no history row and nothing for Fix with AI to rewrite. + func presentTabFailure( + _ message: String, + announcing diagnosis: String, + onTab tabId: UUID, + recordingExecution: (inout QueryTab) -> Void = { _ in } + ) { + parent.tabManager.mutate(tabId: tabId) { tab in + tab.execution.errorMessage = message + tab.pagination.isLoading = false + recordingExecution(&tab) + + // The banner lives at the top of the results pane, so a collapsed pane hides the only + // thing telling the user their query failed. Every success path opens it the same way. + if tab.display.isResultsCollapsed { + tab.display.isResultsCollapsed = false + } + } + // The toolbar mirrors the selected tab, so a failure on a tab in the background describes + // itself on its own tab and leaves the window chrome to whatever is actually on screen. + if parent.tabManager.selectedTabId == tabId { + parent.toolbarState.isResultsCollapsed = false + parent.toolbarState.clearQueryTiming(forTab: tabId) + parent.announceQueryError(diagnosis) + } + } +} + internal struct ExactCountInput: Sendable { internal let sql: String? internal let filters: [TableFilter] diff --git a/TablePro/Core/Plugins/ConnectionField+IntegerEntry.swift b/TablePro/Core/Plugins/ConnectionField+IntegerEntry.swift new file mode 100644 index 0000000000..785e68f977 --- /dev/null +++ b/TablePro/Core/Plugins/ConnectionField+IntegerEntry.swift @@ -0,0 +1,49 @@ +// +// ConnectionField+IntegerEntry.swift +// TablePro +// + +import Foundation +import TableProPluginKit + +extension ConnectionField.IntRange { + func clamping(_ value: Int) -> Int { + min(max(value, lowerBound), upperBound) + } + + /// Only a value no further digit can bring back into range is capped while typing. A positive + /// entry grows with each digit, so one below the lower bound is left alone: forcing a typed + /// "0" up to 1 on the way to "10" would make that value impossible to enter. + func fieldText(sanitizing text: String) -> String { + let scalars = text.trimmingCharacters(in: .whitespacesAndNewlines).unicodeScalars + let isNegative = lowerBound < 0 && scalars.first == "-" + let digits = String(String.UnicodeScalarView(scalars.filter { ("0"..."9").contains($0) })) + guard !digits.isEmpty else { return isNegative ? "-" : "" } + guard let magnitude = Int(digits) else { + return String(isNegative ? lowerBound : upperBound) + } + return isNegative ? String(max(-magnitude, lowerBound)) : String(min(magnitude, upperBound)) + } + + /// An empty field is saved empty, and every driver reads that as the field's default, so the + /// stepper steps from the default rather than from the lower bound. + func stepperValue(fromFieldText text: String, defaultValue: String?) -> Int { + let emptyValue = Int(fieldText(sanitizing: defaultValue ?? "")) ?? lowerBound + return clamping(Int(fieldText(sanitizing: text)) ?? emptyValue) + } +} + +extension ConnectionField { + /// A stepper field keeps the text as typed so a value can be entered digit by digit, which + /// leaves one below the range possible when the user stops typing. Saving it would hand the + /// driver a number the field says it never accepts. + func rangeIssue(in value: String) -> String? { + guard case .stepper(let range) = fieldType, + let number = Int(value.trimmingCharacters(in: .whitespaces)), + range.clamping(number) != number else { return nil } + return String( + format: String(localized: "%@ must be between %@ and %@"), + label, String(range.lowerBound), String(range.upperBound) + ) + } +} diff --git a/TablePro/Core/Plugins/PluginDriverAdapter.swift b/TablePro/Core/Plugins/PluginDriverAdapter.swift index a41f059787..97df2999f9 100644 --- a/TablePro/Core/Plugins/PluginDriverAdapter.swift +++ b/TablePro/Core/Plugins/PluginDriverAdapter.swift @@ -864,12 +864,6 @@ final class PluginDriverAdapter: DatabaseDriver, SchemaSwitchable, DatabaseRepor pluginDriver.allTablesMetadataSQL(schema: schema) } - // MARK: - EXPLAIN - - func buildExplainQuery(_ sql: String) -> String? { - pluginDriver.buildExplainQuery(sql) - } - // MARK: - View Templates func createViewTemplate() -> String? { diff --git a/TablePro/Core/Plugins/PluginMetadataRegistry+RegistryDefaults.swift b/TablePro/Core/Plugins/PluginMetadataRegistry+RegistryDefaults.swift index 3de6bcc16b..6c1e455f1b 100644 --- a/TablePro/Core/Plugins/PluginMetadataRegistry+RegistryDefaults.swift +++ b/TablePro/Core/Plugins/PluginMetadataRegistry+RegistryDefaults.swift @@ -210,7 +210,7 @@ extension PluginMetadataRegistry { id: "redisDatabase", label: String(localized: "Database Index"), defaultValue: "0", - fieldType: .stepper(range: ConnectionField.IntRange(0...15)), + fieldType: .stepper(range: ConnectionField.IntRange(0...(Int(Int32.max) - 1))), visibleWhen: FieldVisibilityRule( fieldId: "redisMode", values: ["standalone", "sentinel"] diff --git a/TablePro/Core/Services/Infrastructure/MainSplitViewController+MenuValidation.swift b/TablePro/Core/Services/Infrastructure/MainSplitViewController+MenuValidation.swift index b35a35b98d..587322cff1 100644 --- a/TablePro/Core/Services/Infrastructure/MainSplitViewController+MenuValidation.swift +++ b/TablePro/Core/Services/Infrastructure/MainSplitViewController+MenuValidation.swift @@ -107,6 +107,9 @@ struct MenuValidationContext: Equatable { var supportsServerDashboard = false var supportsUserManagement = false var supportsSchemaSwitching = false + /// Whether the engine declares an EXPLAIN variant. Read through the same rule the editor bar + /// uses, so the menu item cannot run a statement the bar's button refuses to. + var supportsExplain = false var hasSessionContexts = false var canFilterDatabases = false var canFavoriteActiveDatabase = false @@ -133,6 +136,7 @@ extension MainSplitViewController: NSMenuItemValidation { /// nothing to clear. `MenuValidationCoverageTests` reads the nil to say so. static func resolvedEnablement(_ selector: Selector, context: MenuValidationContext) -> Bool? { if let find = isFindCommandEnabled(selector, context: context) { return find } + if let query = isQueryCommandEnabled(selector, context: context) { return query } switch selector { case #selector(exportTables(_:)), @@ -196,42 +200,14 @@ extension MainSplitViewController: NSMenuItemValidation { /// Safe Mode has to stop it the same way Restore is stopped. return context.isConnected && context.supportsServerSideExport && !context.isReadOnly - case #selector(executeQuery(_:)), - #selector(executeAllStatements(_:)), - #selector(executeQueryWithoutLimit(_:)), - #selector(explainQuery(_:)), - #selector(formatQuery(_:)): - return context.isConnected && context.hasQueryText - /// Both hand their statement to the assistant, which will not open with the feature off. - /// They validated on the query alone, so with AI off the item stayed enabled, the shortcut - /// fired and nothing happened at all: no pane, no alert, nothing. - case #selector(explainQueryWithAI(_:)), - #selector(optimizeQueryWithAI(_:)): - return context.isConnected && context.hasQueryText && AppSettingsManager.shared.ai.enabled /// Reachable while the connection is still dialling: agent mode draws the prompt the user /// typed, which is exactly what they are waiting with, so gating on `isConnected` would make /// the command dead in the one state it is most wanted. case #selector(setContentModeFromMenu(_:)), #selector(toggleContentModeFromMenu(_:)): return context.hasSelectedWorkspace && AppSettingsManager.shared.ai.enabled - case #selector(toggleFold(_:)), #selector(foldAll(_:)), #selector(unfoldAll(_:)): - return context.hasEditorForFind - case #selector(removeInvisibleCharacters(_:)): - return context.hasEditorForFind && context.hasQueryText - case #selector(goToPreviousStatement(_:)), #selector(goToNextStatement(_:)): - return context.isQueryTab - case #selector(runStatementAndAdvance(_:)): - return context.isQueryTab && context.isConnected && context.hasQueryText && !context.isQueryExecuting - case #selector(cancelQuery(_:)): - return context.isQueryExecuting && context.isQueryStoppable - case #selector(clearQuery(_:)): - return context.canClearQuery - case #selector(clearResults(_:)): - return context.canClearResults case #selector(previewSQL(_:)): return context.isConnected && context.hasDataPendingChanges - case #selector(saveAsFavorite(_:)): - return context.canSaveAsFavorite case #selector(addRow(_:)), #selector(duplicateRow(_:)): return context.isConnected && context.isCurrentTabEditable && !context.isReadOnly @@ -374,6 +350,48 @@ extension MainSplitViewController: NSMenuItemValidation { } } + /// The commands that act on the selected tab's editor and the statements in it. + private static func isQueryCommandEnabled(_ selector: Selector, context: MenuValidationContext) -> Bool? { + switch selector { + case #selector(executeQuery(_:)), + #selector(executeAllStatements(_:)), + #selector(executeQueryWithoutLimit(_:)), + #selector(formatQuery(_:)): + return context.isConnected && context.hasQueryText + case #selector(explainQuery(_:)): + return QueryCommandAvailability.canExplain( + isConnected: context.isConnected, + hasQueryText: context.hasQueryText, + isExecuting: context.isQueryExecuting, + supportsExplain: context.supportsExplain + ) + /// Both hand their statement to the assistant, which will not open with the feature off. + /// They validated on the query alone, so with AI off the item stayed enabled, the shortcut + /// fired and nothing happened at all: no pane, no alert, nothing. + case #selector(explainQueryWithAI(_:)), + #selector(optimizeQueryWithAI(_:)): + return context.isConnected && context.hasQueryText && AppSettingsManager.shared.ai.enabled + case #selector(toggleFold(_:)), #selector(foldAll(_:)), #selector(unfoldAll(_:)): + return context.hasEditorForFind + case #selector(removeInvisibleCharacters(_:)): + return context.hasEditorForFind && context.hasQueryText + case #selector(goToPreviousStatement(_:)), #selector(goToNextStatement(_:)): + return context.isQueryTab + case #selector(runStatementAndAdvance(_:)): + return context.isQueryTab && context.isConnected && context.hasQueryText && !context.isQueryExecuting + case #selector(cancelQuery(_:)): + return context.isQueryExecuting && context.isQueryStoppable + case #selector(clearQuery(_:)): + return context.canClearQuery + case #selector(clearResults(_:)): + return context.canClearResults + case #selector(saveAsFavorite(_:)): + return context.canSaveAsFavorite + default: + return nil + } + } + /// The commands that act on the object selected in the sidebar. They answer on the same facts /// the sidebar's own contextual menu reads, so a command the sidebar omits is dimmed here rather /// than enabled over an object it cannot act on. @@ -469,6 +487,7 @@ extension MainSplitViewController: NSMenuItemValidation { supportsServerDashboard: actions.supportsServerDashboard, supportsUserManagement: actions.supportsUserManagement, supportsSchemaSwitching: actions.supportsSchemaSwitching, + supportsExplain: actions.supportsExplain, hasSessionContexts: actions.hasSessionContexts, canFilterDatabases: actions.canFilterDatabases, canFavoriteActiveDatabase: actions.canFavoriteActiveDatabase, diff --git a/TablePro/Models/Query/ExplainRequest.swift b/TablePro/Models/Query/ExplainRequest.swift index 82ed143657..0206b31eb2 100644 --- a/TablePro/Models/Query/ExplainRequest.swift +++ b/TablePro/Models/Query/ExplainRequest.swift @@ -18,14 +18,9 @@ struct ExplainRequest: Equatable { /// rather than two. let variantKey: QueryPlanVariantKey - /// A driver that declares no variants and builds its own statement may return anything, - /// including a multi-column document. Those results go through the ordinary query pipeline - /// so they keep their grid rather than being forced into a plan pane. - let isDriverBuilt: Bool - /// Picks the variant to run: the one the user chose, otherwise the driver's first declared - /// one. Returns nil when the driver declares none, which is the caller's cue to fall back to - /// `buildExplainQuery`. + /// one. Returns nil when the driver declares none, because an engine that declares no plan + /// has nothing to explain. static func make( variant: ExplainVariant?, declaredVariants: [ExplainVariant], @@ -37,22 +32,7 @@ struct ExplainRequest: Equatable { sql: "\(resolved.sqlPrefix) \(statement)", subjectSQL: statement, format: ExplainFormatResolver.resolve(declared: resolved.format, databaseType: databaseType), - variantKey: .declared(resolved.id), - isDriverBuilt: false - ) - } - - static func driverBuilt( - sql: String, - databaseType: DatabaseType, - subjectSQL: String? = nil - ) -> ExplainRequest { - ExplainRequest( - sql: sql, - subjectSQL: subjectSQL ?? sql, - format: ExplainFormatResolver.resolve(declared: .plainText, databaseType: databaseType), - variantKey: .driverBuilt, - isDriverBuilt: true + variantKey: .declared(resolved.id) ) } } diff --git a/TablePro/Models/Query/QueryCommandAvailability.swift b/TablePro/Models/Query/QueryCommandAvailability.swift index d995bf54fb..e12dd7185f 100644 --- a/TablePro/Models/Query/QueryCommandAvailability.swift +++ b/TablePro/Models/Query/QueryCommandAvailability.swift @@ -49,7 +49,12 @@ struct QueryCommandAvailability { self.explainVariants = explainVariants canRun = isConnected && hasQueryText && !isExecuting canStop = isExecuting && isStoppable - canExplain = isConnected && hasQueryText && !isExecuting && !explainVariants.isEmpty + canExplain = Self.canExplain( + isConnected: isConnected, + hasQueryText: hasQueryText, + isExecuting: isExecuting, + supportsExplain: !explainVariants.isEmpty + ) /// Formatting rewrites text the reader already has, so it does not wait for a server. canFormat = hasQueryText canSaveAsFavorite = hasQueryText @@ -83,6 +88,12 @@ struct QueryCommandAvailability { ) } + /// The one rule for Explain, shared by the editor bar and the Query menu so the button and the + /// menu item's shortcut cannot disagree. An engine explains only through a variant it declares. + static func canExplain(isConnected: Bool, hasQueryText: Bool, isExecuting: Bool, supportsExplain: Bool) -> Bool { + isConnected && hasQueryText && !isExecuting && supportsExplain + } + private static func blockedReason(isConnected: Bool, hasQueryText: Bool, isExecuting: Bool) -> String? { if isExecuting { return String(localized: "A query is already running.") } if !hasQueryText { return String(localized: "There is nothing to run yet.") } diff --git a/TablePro/Models/UI/RedisKeyNode.swift b/TablePro/Models/UI/RedisKeyNode.swift index cf53034116..524d72bfd0 100644 --- a/TablePro/Models/UI/RedisKeyNode.swift +++ b/TablePro/Models/UI/RedisKeyNode.swift @@ -5,9 +5,9 @@ import Foundation -internal enum RedisKeyNode: Identifiable, Hashable { +internal enum RedisKeyNode: Identifiable, Hashable, Sendable { case namespace(name: String, fullPrefix: String, children: [RedisKeyNode], keyCount: Int) - case key(name: String, fullKey: String, keyType: String) + case key(name: String, fullKey: String, keyType: String?) var id: String { switch self { @@ -43,14 +43,14 @@ internal enum RedisKeyNode: Identifiable, Hashable { extension RedisKeyNode { /// Lifted out of the sidebar view so the outline row and anything else that renders a key can /// agree on the glyph without importing SwiftUI. - static func iconName(forKeyType type: String) -> String { - switch type.lowercased() { - case "string": return "textformat" - case "hash": return "square.grid.2x2" - case "list": return "list.bullet" - case "set": return "circle.grid.3x3" - case "zset": return "chart.bar" - case "stream": return "waveform" + static func iconName(forKeyType type: String?) -> String { + switch type?.lowercased() { + case "string"?: return "textformat" + case "hash"?: return "square.grid.2x2" + case "list"?: return "list.bullet" + case "set"?: return "circle.grid.3x3" + case "zset"?: return "chart.bar" + case "stream"?: return "waveform" default: return "key" } } diff --git a/TablePro/Models/UI/RedisKeyTreeContent.swift b/TablePro/Models/UI/RedisKeyTreeContent.swift new file mode 100644 index 0000000000..ca35afffd5 --- /dev/null +++ b/TablePro/Models/UI/RedisKeyTreeContent.swift @@ -0,0 +1,53 @@ +// +// RedisKeyTreeContent.swift +// TablePro +// + +import Foundation +import TableProPluginKit + +/// One successful key tree load: the keys one database answered with, and the tree built from them. +/// +/// The database travels with the keys so a later load can tell a refresh of the same database, whose +/// rows it keeps while it runs, from a move to another one, whose rows describe a scope it has left. +internal struct RedisKeyTreeContent: Sendable { + let database: String + let separator: String + let keys: [(key: String, type: String?)] + let rootNodes: [RedisKeyNode] + + var isTruncated: Bool { + keys.count >= RedisKeyTreeViewModel.maxKeys + } + + init(database: String, separator: String, keys: [(key: String, type: String?)]) { + self.database = database + self.separator = separator + self.keys = keys + self.rootNodes = RedisKeyTreeViewModel.buildTree(keys: keys, separator: separator) + } + + init(result: QueryResult, database: String, separator: String) { + let keyColumnIndex = result.columns.firstIndex(of: "Key") ?? 0 + let typeColumnIndex = result.columns.firstIndex(of: "Type") ?? 1 + + var keys: [(key: String, type: String?)] = [] + for row in result.rows { + guard keyColumnIndex < row.count, + let keyName = row[keyColumnIndex].asText else { continue } + let keyType = typeColumnIndex < row.count ? row[typeColumnIndex].asText : nil + keys.append((key: keyName, type: keyType)) + if keys.count >= RedisKeyTreeViewModel.maxKeys { break } + } + self.init(database: database, separator: separator, keys: keys) + } + + func displayNodes(searchText: String) -> [RedisKeyNode] { + guard !searchText.isEmpty else { return rootNodes } + + let filtered = keys.filter { $0.key.localizedCaseInsensitiveContains(searchText) } + if filtered.isEmpty { return [] } + + return RedisKeyTreeViewModel.buildTree(keys: filtered, separator: separator) + } +} diff --git a/TablePro/Resources/Localizable.xcstrings b/TablePro/Resources/Localizable.xcstrings index af791b41d3..ca872c27b9 100644 --- a/TablePro/Resources/Localizable.xcstrings +++ b/TablePro/Resources/Localizable.xcstrings @@ -182378,6 +182378,42 @@ }, "%@ takes nothing after it, and %@ was given." : { + }, + "KEYBROWSE DB requires a database index" : { + + }, + "%@ was not sent because a MULTI block is open on this connection." : { + + }, + "%@ was not sent because this connection is watching keys." : { + + }, + "Run EXEC to apply the block, or DISCARD to drop it." : { + + }, + "Run EXEC, DISCARD or UNWATCH first." : { + + }, + "The command" : { + + }, + "The connection to Redis dropped after EXEC was sent, so whether the block ran is unknown." : { + + }, + "The connection to Redis dropped, so the open MULTI block was lost and nothing in it ran." : { + + }, + "The connection to Redis dropped, so the keys this session was watching are no longer watched." : { + + }, + "Check the keys the block writes before running it again." : { + + }, + "Start the block again with MULTI." : { + + }, + "Run WATCH again before starting the block." : { + } }, "version" : "1.1" diff --git a/TablePro/ViewModels/RedisKeyTreeViewModel.swift b/TablePro/ViewModels/RedisKeyTreeViewModel.swift index 2d61da8b6a..e0a74d8c5a 100644 --- a/TablePro/ViewModels/RedisKeyTreeViewModel.swift +++ b/TablePro/ViewModels/RedisKeyTreeViewModel.swift @@ -6,83 +6,89 @@ import Combine import Foundation import os -import TableProPluginKit +/// The sidebar's Redis key tree for one connection. +/// +/// A load owns its outcome only while it is the latest one: selecting another database supersedes +/// it, and a superseded load commits nothing, whatever it came back with. A load that fails with no +/// keys of its database on screen is kept as a failure rather than folded into an empty tree, so a +/// refused `SCAN` reads as the refusal it is. A failed refresh keeps the keys it was refreshing. @MainActor internal final class RedisKeyTreeViewModel: ObservableObject { nonisolated private static let logger = Logger(subsystem: "com.TablePro", category: "RedisKeyTree") - internal static let maxKeys = 50_000 + nonisolated internal static let maxKeys = 50_000 - @Published var rootNodes: [RedisKeyNode] = [] - @Published var isLoading = false - @Published var isTruncated = false - @Published var separator: String = ":" + @Published private(set) var state: MetadataLoadState = .idle - @Published private(set) var allKeys: [(key: String, type: String)] = [] + private let metadataProvider: any ScopedMetadataProviding + private var loadTask: Task? + private var loadFence = CommitFence() + private var lastRequest: LoadRequest? - /// Test-only setter for allKeys - var allKeysForTesting: [(key: String, type: String)] { - get { allKeys } - set { allKeys = newValue } + private struct LoadRequest: Sendable { + let connectionId: UUID + let database: String + let separator: String } - func loadKeys(connectionId: UUID, database: String, separator: String) async { - self.separator = separator - isLoading = true - isTruncated = false - defer { isLoading = false } - - guard DatabaseManager.shared.driver(for: connectionId) != nil else { - clear() - return - } + init(metadataProvider: any ScopedMetadataProviding = DatabaseManager.shared) { + self.metadataProvider = metadataProvider + } - let scope = DatabaseScope(connectionId: connectionId, database: database, schema: nil) - let limit = Self.maxKeys - do { - let result = try await DatabaseManager.shared.withMetadataDriver(scope: scope) { driver in - try await driver.execute(query: "KEYTREE LIMIT \(limit)") - } + @discardableResult + func loadKeys(connectionId: UUID, database: String, separator: String) -> Task { + load(LoadRequest(connectionId: connectionId, database: database, separator: separator)) + } - let keyColumnIndex = result.columns.firstIndex(of: "Key") ?? 0 - let typeColumnIndex = result.columns.firstIndex(of: "Type") ?? 1 + /// Runs the most recent load again. Nil when nothing has been asked for yet, since there is no + /// database to reload. + @discardableResult + func reload() -> Task? { + guard let lastRequest else { return nil } + return load(lastRequest) + } - var keys: [(key: String, type: String)] = [] - for row in result.rows { - guard keyColumnIndex < row.count, - let keyName = row[keyColumnIndex].asText else { continue } - let keyType = typeColumnIndex < row.count ? (row[typeColumnIndex].asText ?? "string") : "string" - keys.append((key: keyName, type: keyType)) - if keys.count >= Self.maxKeys { break } - } + private func load(_ request: LoadRequest) -> Task { + lastRequest = request + loadTask?.cancel() + let token = loadFence.supersede(request.connectionId) + state = state.value?.database == request.database ? state.enteringLoad : .loading - isTruncated = keys.count >= Self.maxKeys - allKeys = keys - rootNodes = Self.buildTree(keys: keys, separator: separator) - } catch { - Self.logger.error("Failed to load Redis keys: \(error.publicLogShape, privacy: .public)") - clear() + let provider = metadataProvider + let task = Task { [weak self] in + let outcome = await Self.fetch(request, from: provider) + self?.commit(outcome, of: request, token: token) } + loadTask = task + return task } - func clear() { - rootNodes = [] - allKeys = [] - isTruncated = false + private func commit(_ outcome: MetadataFetchOutcome, of request: LoadRequest, token: Int) { + guard loadFence.isCurrent(token, for: request.connectionId) else { return } + state = state.settled(by: outcome, discardingValue: state.value?.database != request.database) } - func displayNodes(searchText: String) -> [RedisKeyNode] { - guard !searchText.isEmpty else { return rootNodes } - - let filtered = allKeys.filter { $0.key.localizedCaseInsensitiveContains(searchText) } - if filtered.isEmpty { return [] } - - return Self.buildTree(keys: filtered, separator: separator) + private static func fetch( + _ request: LoadRequest, + from provider: any ScopedMetadataProviding + ) async -> MetadataFetchOutcome { + let scope = DatabaseScope(connectionId: request.connectionId, database: request.database, schema: nil) + let limit = maxKeys + do { + let result = try await provider.withMetadataDriver(scope: scope) { driver in + try await driver.execute(query: "KEYTREE LIMIT \(limit)") + } + return .fetched(RedisKeyTreeContent(result: result, database: request.database, separator: request.separator)) + } catch { + if DatabaseCancellationDiagnosis.isCancellation(error) { return .cancelled } + logger.error("Failed to load Redis keys: \(error.publicLogShape, privacy: .public)") + return .failed(error.localizedDescription) + } } // MARK: - Tree Building (Pure Function) - static func buildTree(keys: [(key: String, type: String)], separator: String) -> [RedisKeyNode] { + nonisolated static func buildTree(keys: [(key: String, type: String?)], separator: String) -> [RedisKeyNode] { guard !separator.isEmpty else { return keys.sorted { $0.key < $1.key } .map { .key(name: $0.key, fullKey: $0.key, keyType: $0.type) } @@ -102,9 +108,9 @@ internal final class RedisKeyTreeViewModel: ObservableObject { private class TrieNode { var children: [String: TrieNode] = [:] - var leafKeys: [(fullKey: String, keyType: String)] = [] + var leafKeys: [(fullKey: String, keyType: String?)] = [] - func insert(parts: [String], fullKey: String, keyType: String) { + func insert(parts: [String], fullKey: String, keyType: String?) { guard !parts.isEmpty else { leafKeys.append((fullKey: fullKey, keyType: keyType)) return diff --git a/TablePro/ViewModels/SidebarViewModel.swift b/TablePro/ViewModels/SidebarViewModel.swift index ddccc87586..55dd8dfb92 100644 --- a/TablePro/ViewModels/SidebarViewModel.swift +++ b/TablePro/ViewModels/SidebarViewModel.swift @@ -127,10 +127,6 @@ final class SidebarViewModel: ObservableObject { ) } } - var redisKeyTreeViewModel: RedisKeyTreeViewModel? { - get { sharedState.redisKeyTreeViewModel } - set { sharedState.redisKeyTreeViewModel = newValue } - } @Published var showOperationDialog = false @Published var pendingOperationType: TableOperationType? @Published var pendingOperationTables: [DatabaseTreeTableRef] = [] diff --git a/TablePro/Views/Connection/ConnectionFieldRow.swift b/TablePro/Views/Connection/ConnectionFieldRow.swift index b4a88a37a8..9e8c472711 100644 --- a/TablePro/Views/Connection/ConnectionFieldRow.swift +++ b/TablePro/Views/Connection/ConnectionFieldRow.swift @@ -67,15 +67,12 @@ struct ConnectionFieldRow: View { ) ) case .stepper(let range): - Stepper( - value: Binding( - get: { Int(value) ?? range.lowerBound }, - set: { value = String($0) } - ), - in: range.closedRange - ) { - Text(verbatim: "\(field.label): \(Int(value) ?? range.lowerBound)") - } + ConnectionStepperField( + label: field.label, + range: range, + defaultValue: field.defaultValue, + value: $value + ) case .hostList: EmptyView() } diff --git a/TablePro/Views/Connection/ConnectionStepperField.swift b/TablePro/Views/Connection/ConnectionStepperField.swift new file mode 100644 index 0000000000..cf46f3af9f --- /dev/null +++ b/TablePro/Views/Connection/ConnectionStepperField.swift @@ -0,0 +1,48 @@ +// +// ConnectionStepperField.swift +// TablePro +// + +import SwiftUI +import TableProPluginKit + +struct ConnectionStepperField: View { + let label: String + let range: ConnectionField.IntRange + let defaultValue: String? + @Binding var value: String + + var body: some View { + LabeledContent(label) { + HStack(spacing: 4) { + TextField(label, text: $value, prompt: Text(verbatim: String(emptyValue))) + .labelsHidden() + .multilineTextAlignment(.trailing) + .frame(width: 96) + Stepper(label, value: steppedValue, in: range.closedRange) + .labelsHidden() + } + } + .accessibilityElement(children: .contain) + .onChange(of: value) { newValue in + sanitize(newValue) + } + } + + private var emptyValue: Int { + range.stepperValue(fromFieldText: "", defaultValue: defaultValue) + } + + private var steppedValue: Binding { + Binding( + get: { range.stepperValue(fromFieldText: value, defaultValue: defaultValue) }, + set: { value = String($0) } + ) + } + + private func sanitize(_ text: String) { + let sanitized = range.fieldText(sanitizing: text) + guard sanitized != text else { return } + value = sanitized + } +} diff --git a/TablePro/Views/ConnectionForm/ViewModels/AdvancedPaneViewModel.swift b/TablePro/Views/ConnectionForm/ViewModels/AdvancedPaneViewModel.swift index 5b16902cf2..66d3fd31b2 100644 --- a/TablePro/Views/ConnectionForm/ViewModels/AdvancedPaneViewModel.swift +++ b/TablePro/Views/ConnectionForm/ViewModels/AdvancedPaneViewModel.swift @@ -32,6 +32,7 @@ final class AdvancedPaneViewModel: ObservableObject { issues.append(String(format: String(localized: "%@ is required"), field.label)) } } + issues += advancedFields.filter(isFieldVisible).compactMap { $0.rangeIssue(in: additionalFieldValues[$0.id] ?? "") } return issues } diff --git a/TablePro/Views/ConnectionForm/ViewModels/AuthPaneViewModel.swift b/TablePro/Views/ConnectionForm/ViewModels/AuthPaneViewModel.swift index 9d4ebf7c26..dfb8f969b1 100644 --- a/TablePro/Views/ConnectionForm/ViewModels/AuthPaneViewModel.swift +++ b/TablePro/Views/ConnectionForm/ViewModels/AuthPaneViewModel.swift @@ -158,6 +158,7 @@ final class AuthPaneViewModel: ObservableObject { issues.append(String(format: String(localized: "%@ is required"), field.label)) } } + issues += authFields.filter(isFieldVisible).compactMap { $0.rangeIssue(in: additionalFieldValues[$0.id] ?? "") } return issues } diff --git a/TablePro/Views/ConnectionForm/ViewModels/NetworkPaneViewModel.swift b/TablePro/Views/ConnectionForm/ViewModels/NetworkPaneViewModel.swift index b2c5769475..8f7ebc6bb5 100644 --- a/TablePro/Views/ConnectionForm/ViewModels/NetworkPaneViewModel.swift +++ b/TablePro/Views/ConnectionForm/ViewModels/NetworkPaneViewModel.swift @@ -106,6 +106,7 @@ final class NetworkPaneViewModel: ObservableObject { issues.append(String(format: String(localized: "%@ is required"), field.label)) } } + issues += connectionFields.filter(isFieldVisible).compactMap { $0.rangeIssue(in: additionalFieldValues[$0.id] ?? "") } return issues } diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+Explain.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+Explain.swift index 456bc84964..8f9a981092 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+Explain.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+Explain.swift @@ -21,7 +21,12 @@ extension MainContentCoordinator { } guard let statement = explainStatement(in: tab) else { return } let anchor = tab.tabType == .table ? nil : StatementAnchor(statement) - guard let request = explainRequest(variant: variant, statement: statement.sql) else { + guard let request = ExplainRequest.make( + variant: variant, + declaredVariants: connection.type.explainVariants, + databaseType: connection.type, + statement: statement.sql + ) else { tabManager.mutate(at: index) { $0.execution.errorMessage = String( localized: "EXPLAIN is not supported for this database type." @@ -32,7 +37,7 @@ extension MainContentCoordinator { let level = safeModeLevel guard level.appliesToAllQueries, level.requiresConfirmation else { - run(request, anchor: anchor) + executeExplain(request, anchor: anchor) return } @@ -49,7 +54,7 @@ extension MainContentCoordinator { ) ) guard case .authorized = decision else { return } - run(request, anchor: anchor) + executeExplain(request, anchor: anchor) } } @@ -92,37 +97,8 @@ extension MainContentCoordinator { .offset(by: sourceOffset) } - private func explainRequest(variant: ExplainVariant?, statement: String) -> ExplainRequest? { - if let request = ExplainRequest.make( - variant: variant, - declaredVariants: connection.type.explainVariants, - databaseType: connection.type, - statement: statement - ) { - return request - } - - guard let adapter = services.databaseManager.driver(for: connectionId) as? PluginDriverAdapter, - let fallbackSQL = adapter.buildExplainQuery(statement) - else { return nil } - - return ExplainRequest.driverBuilt( - sql: fallbackSQL, - databaseType: connection.type, - subjectSQL: statement - ) - } - // MARK: - Execution - private func run(_ request: ExplainRequest, anchor: StatementAnchor?) { - guard !request.isDriverBuilt else { - executeQueryInternal(request.sql, anchor: anchor) - return - } - executeExplain(request, anchor: anchor) - } - private func executeExplain(_ request: ExplainRequest, anchor: StatementAnchor?) { guard let (tab, index) = tabManager.selectedTabAndIndex else { return } guard let scope = scope(for: tab) else { diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+Navigation.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+Navigation.swift index 4debe29d50..338b73c6f3 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+Navigation.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+Navigation.swift @@ -737,6 +737,7 @@ extension MainContentCoordinator { let connId = connectionId let database = String(dbIndex) + let tabId = tabManager.selectedTabId redisDatabaseSwitchTask = Task { [weak self] in guard let self else { return } do { @@ -744,47 +745,45 @@ extension MainContentCoordinator { } catch { guard !Task.isCancelled else { return } navigationLogger.error("Failed to SELECT Redis db\(dbIndex): \(error.publicLogShape, privacy: .public)") - if let tabId = tabManager.selectedTab?.id { - declineTableLoad(for: tabId) + if let tabId { + reportRedisSelectionFailure(error, onTab: tabId) } return } guard !Task.isCancelled else { return } toolbarState.currentDatabase = database - executeTableTabQueryDirectly(viewport: .firstRow) - - let separator = connection.additionalFields["redisSeparator"] ?? ":" - if sidebarViewModel?.redisKeyTreeViewModel == nil { - let vm = RedisKeyTreeViewModel() - sidebarViewModel?.redisKeyTreeViewModel = vm - let sidebarState = SharedSidebarState.forConnection(connId) - sidebarState.redisKeyTreeViewModel = vm - } - Task { - await self.sidebarViewModel?.redisKeyTreeViewModel?.loadKeys( - connectionId: connId, - database: database, - separator: separator - ) + if let tabId, tabManager.selectedTabId != tabId { + declineTableLoad(for: tabId) + } else { + executeTableTabQueryDirectly(viewport: .firstRow) } + + loadRedisKeyTree(database: database) } } func initRedisKeyTreeIfNeeded() { guard connection.type == .redis else { return } - let sidebarState = SharedSidebarState.forConnection(connectionId) - guard sidebarState.redisKeyTreeViewModel == nil else { return } + guard SharedSidebarState.forConnection(connectionId).redisKeyTreeViewModel == nil else { return } + loadRedisKeyTree(database: toolbarState.currentDatabase) + } - let vm = RedisKeyTreeViewModel() - sidebarState.redisKeyTreeViewModel = vm - sidebarViewModel?.redisKeyTreeViewModel = vm + /// The tree belongs to the connection's shared sidebar state rather than to this window's sidebar + /// view model, which may not exist yet, so the load never depends on which window asked for it. + private func loadRedisKeyTree(database: String) { + let sidebarState = SharedSidebarState.forConnection(connectionId) + let keyTree = sidebarState.redisKeyTreeViewModel ?? makeRedisKeyTree(in: sidebarState) + keyTree.loadKeys( + connectionId: connectionId, + database: database, + separator: connection.additionalFields["redisSeparator"] ?? ":" + ) + } - let connId = connectionId - let database = toolbarState.currentDatabase - let separator = connection.additionalFields["redisSeparator"] ?? ":" - Task { - await vm.loadKeys(connectionId: connId, database: database, separator: separator) - } + private func makeRedisKeyTree(in sidebarState: SharedSidebarState) -> RedisKeyTreeViewModel { + let keyTree = RedisKeyTreeViewModel() + sidebarState.redisKeyTreeViewModel = keyTree + return keyTree } // MARK: - Redis Key Tree Navigation @@ -793,19 +792,19 @@ extension MainContentCoordinator { applyBrowseSearch(BrowseSearchState(pattern: "\(prefix)*")) } - func openRedisKey(_ keyName: String, keyType: String) { + func openRedisKey(_ keyName: String, keyType: String?) { let escapedKey = keyName.replacingOccurrences(of: "\"", with: "\\\"") let query: String - switch keyType.lowercased() { - case "hash": + switch keyType?.lowercased() { + case "hash"?: query = "HGETALL \"\(escapedKey)\"" - case "list": + case "list"?: query = "LRANGE \"\(escapedKey)\" 0 -1" - case "set": + case "set"?: query = "SMEMBERS \"\(escapedKey)\"" - case "zset": + case "zset"?: query = "ZRANGE \"\(escapedKey)\" 0 -1 WITHSCORES" - case "stream": + case "stream"?: query = "XRANGE \"\(escapedKey)\" - +" default: query = "GET \"\(escapedKey)\"" diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+Redis.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+Redis.swift index 782b51862b..545580c474 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+Redis.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+Redis.swift @@ -14,4 +14,22 @@ extension MainContentCoordinator { redisDatabaseSwitchTask?.cancel() redisDatabaseSwitchTask = nil } + + /// The click has already retargeted the tab, so a server that refuses the database (a single + /// database service answering `ERR DB index is out of range`, or an ACL without `select`) + /// has to say so there, or the tab sits empty under a database it never reached. A selection + /// that was superseded or lost its connection is not the server's answer, and the connection's + /// own state reports a disconnect. + func reportRedisSelectionFailure(_ error: Error, onTab tabId: UUID) { + if DatabaseCancellationDiagnosis.isCancellation(error) { + declineTableLoad(for: tabId) + return + } + if case DatabaseError.notConnected = error { + declineTableLoad(for: tabId) + return + } + let diagnosis = DatabaseWriteRejectionDiagnosis.formatted(error) + queryExecutionCoordinator.presentTabFailure(diagnosis, announcing: diagnosis, onTab: tabId) + } } diff --git a/TablePro/Views/Main/MainContentCommandActions.swift b/TablePro/Views/Main/MainContentCommandActions.swift index 2a899ba348..d70c207091 100644 --- a/TablePro/Views/Main/MainContentCommandActions.swift +++ b/TablePro/Views/Main/MainContentCommandActions.swift @@ -1077,6 +1077,10 @@ final class MainContentCommandActions: ObservableObject { } } + var supportsExplain: Bool { + !connection.type.explainVariants.isEmpty + } + func explainQuery() { coordinator?.runExplain() } diff --git a/TablePro/Views/Sidebar/DatabaseTreeOutlineCoordinator+Commands.swift b/TablePro/Views/Sidebar/DatabaseTreeOutlineCoordinator+Commands.swift index aaa3fd98b3..5e448718e8 100644 --- a/TablePro/Views/Sidebar/DatabaseTreeOutlineCoordinator+Commands.swift +++ b/TablePro/Views/Sidebar/DatabaseTreeOutlineCoordinator+Commands.swift @@ -160,6 +160,8 @@ extension DatabaseTreeOutlineCoordinator { ClipboardService.shared.writeText(text) case .showObjectSource(let ref): mainCoordinator?.showObjectSource(ref) + case .refreshRedisKeys: + sidebarState?.redisKeyTreeViewModel?.reload() case .copyRedisNamespacePrefix(let prefix): ClipboardService.shared.writeText(prefix) case .copyRedisKey(let key): diff --git a/TablePro/Views/Sidebar/DatabaseTreeOutlineCoordinator+Nodes.swift b/TablePro/Views/Sidebar/DatabaseTreeOutlineCoordinator+Nodes.swift index ffdabf1a95..eca161ea7c 100644 --- a/TablePro/Views/Sidebar/DatabaseTreeOutlineCoordinator+Nodes.swift +++ b/TablePro/Views/Sidebar/DatabaseTreeOutlineCoordinator+Nodes.swift @@ -340,23 +340,14 @@ extension DatabaseTreeOutlineCoordinator { guard case .namespace(_, _, let children, _) = parent else { return [] } return children.map { node(id: DatabaseTreeNode.redisNodeId($0), kind: .redisNode($0)) } } - if keyTree.isLoading { - return [statusNode(parentId: DatabaseTreeNode.redisKeysSectionId, status: .loading)] - } - let roots = keyTree.displayNodes(searchText: searchText) - guard !roots.isEmpty else { - return [statusNode(parentId: DatabaseTreeNode.redisKeysSectionId, status: .empty)] - } - var nodes = roots.map { node(id: DatabaseTreeNode.redisNodeId($0), kind: .redisNode($0)) } - if keyTree.isTruncated { - nodes.append( - statusNode( - parentId: DatabaseTreeNode.redisKeysSectionId, - status: .truncated(RedisKeyTreeTruncation.message(limit: RedisKeyTreeViewModel.maxKeys)) - ) - ) + return RedisKeyTreeRows.rows(for: keyTree.state, searchText: searchText).map { row in + switch row { + case .status(let status): + return statusNode(parentId: DatabaseTreeNode.redisKeysSectionId, status: status) + case .node(let keyNode): + return node(id: DatabaseTreeNode.redisNodeId(keyNode), kind: .redisNode(keyNode)) + } } - return nodes } private func recentTableRefs() -> [DatabaseTreeTableRef] { diff --git a/TablePro/Views/Sidebar/DatabaseTreeOutlineCoordinator.swift b/TablePro/Views/Sidebar/DatabaseTreeOutlineCoordinator.swift index 2b1c073cd4..b52be414bd 100644 --- a/TablePro/Views/Sidebar/DatabaseTreeOutlineCoordinator.swift +++ b/TablePro/Views/Sidebar/DatabaseTreeOutlineCoordinator.swift @@ -252,11 +252,7 @@ final class DatabaseTreeOutlineCoordinator: NSObject, NSTextFieldDelegate { /// One token covers every table, routine and per-schema load for this connection, which is /// the whole reactive surface the flat and hierarchical shapes read. _ = schemaService.generationToken(for: connectionId) - if let keyTree = sidebarState?.redisKeyTreeViewModel { - _ = keyTree.isLoading - _ = keyTree.isTruncated - _ = keyTree.allKeys.count - } + _ = sidebarState?.redisKeyTreeViewModel?.state for node in nodeCache.values { switch node.kind { case .database(let metadata): diff --git a/TablePro/Views/Sidebar/DatabaseTreeRowView.swift b/TablePro/Views/Sidebar/DatabaseTreeRowView.swift index 28ee07d848..9dc1662e65 100644 --- a/TablePro/Views/Sidebar/DatabaseTreeRowView.swift +++ b/TablePro/Views/Sidebar/DatabaseTreeRowView.swift @@ -239,9 +239,11 @@ struct DatabaseTreeRowView: View { Text(name) .lineLimit(1) .truncationMode(.middle) - Text(keyType) - .font(.caption) - .foregroundStyle(.secondary) + if let keyType { + Text(keyType) + .font(.caption) + .foregroundStyle(.secondary) + } } } icon: { Image(systemName: RedisKeyNode.iconName(forKeyType: keyType)) diff --git a/TablePro/Views/Sidebar/Menu/DatabaseTreeMenuSpec.swift b/TablePro/Views/Sidebar/Menu/DatabaseTreeMenuSpec.swift index 0a3b85fd16..e66d26c008 100644 --- a/TablePro/Views/Sidebar/Menu/DatabaseTreeMenuSpec.swift +++ b/TablePro/Views/Sidebar/Menu/DatabaseTreeMenuSpec.swift @@ -58,7 +58,9 @@ internal enum DatabaseTreeMenuSpec { return hierarchicalSchemaSections(schema, context: context) case .redisNode(let node): return redisSections(node) - case .status, .recentSection, .redisKeysSection: + case .redisKeysSection: + return [DatabaseTreeMenuSection([.command(String(localized: "Refresh"), .refreshRedisKeys)])] + case .status, .recentSection: return backgroundSections(context) } } diff --git a/TablePro/Views/Sidebar/Menu/SidebarMenuCommand.swift b/TablePro/Views/Sidebar/Menu/SidebarMenuCommand.swift index 8f7f54a108..a38ce431d9 100644 --- a/TablePro/Views/Sidebar/Menu/SidebarMenuCommand.swift +++ b/TablePro/Views/Sidebar/Menu/SidebarMenuCommand.swift @@ -72,9 +72,10 @@ internal enum SidebarMenuCommand: Equatable { case refreshHierarchicalSchema(String) case copyText(String) case showObjectSource(DatabaseObjectRef) + case refreshRedisKeys case copyRedisNamespacePrefix(String) case copyRedisKey(String) - case openRedisKey(key: String, keyType: String) + case openRedisKey(key: String, keyType: String?) case toggleObjectIcons case toggleObjectComments case toggleSystemContainers diff --git a/TablePro/Views/Sidebar/RedisKeyTreeRows.swift b/TablePro/Views/Sidebar/RedisKeyTreeRows.swift new file mode 100644 index 0000000000..193266e3a4 --- /dev/null +++ b/TablePro/Views/Sidebar/RedisKeyTreeRows.swift @@ -0,0 +1,35 @@ +// +// RedisKeyTreeRows.swift +// TablePro +// + +import Foundation + +/// What the Keys section lists for a load state, decided without an outline so every state can be +/// asserted on its own. A failed load is an error row: listing it as "No items" told the reader +/// the database was empty when the server had refused to say. +internal enum RedisKeyTreeRows { + internal enum Row: Equatable { + case status(DatabaseTreeNode.Status) + case node(RedisKeyNode) + } + + internal static func rows(for state: MetadataLoadState, searchText: String) -> [Row] { + switch state { + case .idle: + return [] + case .loading: + return [.status(.loading)] + case .failed(let message): + return [.status(.error(message))] + case .loaded(let content): + let roots = content.displayNodes(searchText: searchText) + guard !roots.isEmpty else { return [.status(.empty)] } + var rows = roots.map(Row.node) + if content.isTruncated { + rows.append(.status(.truncated(RedisKeyTreeTruncation.message(limit: RedisKeyTreeViewModel.maxKeys)))) + } + return rows + } + } +} diff --git a/TableProMobile/TableProMobile/Drivers/RedisDriver.swift b/TableProMobile/TableProMobile/Drivers/RedisDriver.swift index 451b104a76..522af203f7 100644 --- a/TableProMobile/TableProMobile/Drivers/RedisDriver.swift +++ b/TableProMobile/TableProMobile/Drivers/RedisDriver.swift @@ -83,43 +83,13 @@ nonisolated final class RedisDriver: DatabaseDriver, @unchecked Sendable { // MARK: - Schema (Redis key space mapped to tables) func fetchTables(schema: String?) async throws -> [TableInfo] { - var keys: [String] = [] - var cursor = "0" - - repeat { - let reply = try await actor.command(["SCAN", cursor, "MATCH", "*", "COUNT", "1000"]) - guard case .array(let parts) = reply, parts.count == 2 else { break } - - if case .string(let nextCursor) = parts[0] { - cursor = nextCursor - } else { - break - } - - if case .array(let keyReplies) = parts[1] { - for kr in keyReplies { - if case .string(let k) = kr { keys.append(k) } - } - } - - if keys.count >= 100_000 { break } - } while cursor != "0" - - return keys.sorted().map { + try await RedisKeyspaceReads.keys(sending: send).sorted().map { TableInfo(name: $0, type: .table, rowCount: nil, dataSize: nil, comment: nil) } } func fetchColumns(table: String, schema: String?) async throws -> [ColumnInfo] { - let reply = try await actor.command(["TYPE", table]) - let typeName: String - if case .status(let s) = reply { - typeName = s - } else if case .string(let s) = reply { - typeName = s - } else { - typeName = "unknown" - } + let typeName = try await RedisKeyspaceReads.typeName(ofKey: table, sending: send) return [ ColumnInfo( @@ -174,10 +144,7 @@ nonisolated final class RedisDriver: DatabaseDriver, @unchecked Sendable { throw RedisError.queryFailed("Invalid database name: \(name). Expected db0, db1, etc.") } - let reply = try await actor.command(["SELECT", dbNum]) - if case .error(let msg) = reply { - throw RedisError.queryFailed(msg) - } + try await send(["SELECT", dbNum]).throwIfError().throwIfQueued("SELECT") } func switchSchema(to name: String) async throws { @@ -200,6 +167,10 @@ nonisolated final class RedisDriver: DatabaseDriver, @unchecked Sendable { // MARK: - Private Helpers + private func send(_ arguments: [String]) async throws -> RedisReplyValue { + try await actor.command(arguments) + } + private func parseRedisCommand(_ input: String) -> [String] { var args: [String] = [] var current = "" @@ -331,33 +302,6 @@ nonisolated final class RedisDriver: DatabaseDriver, @unchecked Sendable { } } -// MARK: - Redis Reply Value - -nonisolated private enum RedisReplyValue: Sendable { - case string(String) - case integer(Int64) - case array([RedisReplyValue]) - case status(String) - case error(String) - case null - - var stringRepresentation: String? { - switch self { - case .string(let s): return s - case .integer(let i): return String(i) - case .status(let s): return s - case .error(let s): return s - case .null: return nil - case .array(let items): return "[\(items.compactMap(\.stringRepresentation).joined(separator: ", "))]" - } - } - - var errorMessage: String? { - guard case .error(let message) = self else { return nil } - return message - } -} - nonisolated private func withOptionalCString(_ string: String?, _ body: (UnsafePointer?) throws -> R) rethrows -> R { guard let string else { return try body(nil) } return try string.withCString { try body($0) } @@ -590,12 +534,13 @@ private actor RedisActor { // MARK: - Errors -nonisolated enum RedisError: Error, LocalizedError { +nonisolated enum RedisError: Error, LocalizedError, Equatable { case connectionFailed(String) case authenticationFailed(serverMessage: String, failure: RedisAuthCommand.Failure) case sessionUnverified(RedisConnectProbe.Outcome) case notConnected case queryFailed(String) + case commandQueued(String) case unsupported(String) var errorDescription: String? { @@ -616,6 +561,12 @@ nonisolated enum RedisError: Error, LocalizedError { return "\(message) \(hint)" case .notConnected: return "Not connected to Redis" case .queryFailed(let msg): return "Redis command failed: \(msg)" + case .commandQueued(let command): + let message = String(format: String(localized: "Redis queued %@ instead of running it."), command) + let hint = String( + localized: "A MULTI block is open on this connection. Run EXEC to apply it, or DISCARD to drop it." + ) + return "\(message) \(hint)" case .unsupported(let msg): return msg } } diff --git a/TableProMobile/TableProMobile/Drivers/RedisKeyspaceReads.swift b/TableProMobile/TableProMobile/Drivers/RedisKeyspaceReads.swift new file mode 100644 index 0000000000..fb7fb6c1c2 --- /dev/null +++ b/TableProMobile/TableProMobile/Drivers/RedisKeyspaceReads.swift @@ -0,0 +1,79 @@ +import Foundation + +nonisolated internal struct RedisScanPage: Equatable, Sendable { + static let startCursor = "0" + + let cursor: String + let keys: [String] + + init(cursor: String, keys: [String]) { + self.cursor = cursor + self.keys = keys + } + + init(reply: RedisReplyValue) throws { + try reply.throwIfError().throwIfQueued("SCAN") + guard case .array(let parts) = reply, parts.count == 2 else { + self.init(cursor: Self.startCursor, keys: []) + return + } + self.init(cursor: Self.cursor(from: parts[0]), keys: Self.keys(from: parts[1])) + } + + private static func cursor(from reply: RedisReplyValue) -> String { + switch reply { + case .string(let value), .status(let value): + return value + case .integer(let value): + return String(value) + default: + return startCursor + } + } + + private static func keys(from reply: RedisReplyValue) -> [String] { + guard case .array(let items) = reply else { return [] } + return items.compactMap { item in + switch item { + case .string(let key), .status(let key): + return key + default: + return nil + } + } + } +} + +nonisolated internal enum RedisKeyspaceReads { + typealias Send = ([String]) async throws -> RedisReplyValue + + static let scanPageSize = 1_000 + static let keyLimit = 100_000 + static let unknownTypeName = "unknown" + + static func scanArguments(cursor: String) -> [String] { + ["SCAN", cursor, "MATCH", "*", "COUNT", String(scanPageSize)] + } + + static func keys(sending send: Send) async throws -> [String] { + var keys: [String] = [] + var cursor = RedisScanPage.startCursor + repeat { + let reply = try await send(scanArguments(cursor: cursor)) + let page = try RedisScanPage(reply: reply) + cursor = page.cursor + keys.append(contentsOf: page.keys) + } while cursor != RedisScanPage.startCursor && keys.count < keyLimit + return keys + } + + static func typeName(ofKey key: String, sending send: Send) async throws -> String { + let reply = try await send(["TYPE", key]).throwIfError().throwIfQueued("TYPE") + switch reply { + case .status(let name), .string(let name): + return name + default: + return unknownTypeName + } + } +} diff --git a/TableProMobile/TableProMobile/Drivers/RedisReplyValue.swift b/TableProMobile/TableProMobile/Drivers/RedisReplyValue.swift new file mode 100644 index 0000000000..35c5064f61 --- /dev/null +++ b/TableProMobile/TableProMobile/Drivers/RedisReplyValue.swift @@ -0,0 +1,46 @@ +import Foundation + +nonisolated internal enum RedisReplyValue: Sendable, Equatable { + case string(String) + case integer(Int64) + case array([RedisReplyValue]) + case status(String) + case error(String) + case null + + var stringRepresentation: String? { + switch self { + case .string(let s): return s + case .integer(let i): return String(i) + case .status(let s): return s + case .error(let s): return s + case .null: return nil + case .array(let items): return "[\(items.compactMap(\.stringRepresentation).joined(separator: ", "))]" + } + } + + var errorMessage: String? { + guard case .error(let message) = self else { return nil } + return message + } + + /// Measured on Redis 8.10.1: a command held in an open `MULTI` block answers the simple string + /// `+QUEUED`, while a `GET` of a key holding that word answers the bulk string, so only the + /// status shape means the command did not run. + var isQueued: Bool { + guard case .status(let value) = self else { return false } + return value == "QUEUED" + } + + @discardableResult + func throwIfError() throws -> RedisReplyValue { + guard case .error(let message) = self else { return self } + throw RedisError.queryFailed(message) + } + + @discardableResult + func throwIfQueued(_ command: String) throws -> RedisReplyValue { + guard isQueued else { return self } + throw RedisError.commandQueued(command) + } +} diff --git a/TableProMobile/TableProMobileTests/Drivers/RedisKeyspaceReadsTests.swift b/TableProMobile/TableProMobileTests/Drivers/RedisKeyspaceReadsTests.swift new file mode 100644 index 0000000000..3a9f6ed47c --- /dev/null +++ b/TableProMobile/TableProMobileTests/Drivers/RedisKeyspaceReadsTests.swift @@ -0,0 +1,198 @@ +import Foundation +@testable import TableProMobile +import Testing + +private actor ScriptedRedisServer { + private var replies: [RedisReplyValue] + private let repeatsLastReply: Bool + private(set) var sent: [[String]] = [] + + init(replies: [RedisReplyValue], repeatsLastReply: Bool = false) { + self.replies = replies + self.repeatsLastReply = repeatsLastReply + } + + func reply(to arguments: [String]) throws -> RedisReplyValue { + sent.append(arguments) + guard let next = replies.first else { throw ScriptExhausted() } + if replies.count > 1 || !repeatsLastReply { + replies.removeFirst() + } + return next + } + + struct ScriptExhausted: Error {} +} + +private func scanReply(cursor: String, keys: [String]) -> RedisReplyValue { + .array([.string(cursor), .array(keys.map { .string($0) })]) +} + +@Suite("Redis reply guards") +struct RedisReplyValueGuardTests { + @Test("an error reply throws the server's message") + func errorReplyThrows() { + let reply = RedisReplyValue.error("NOPERM User limited has no permissions to run the 'scan' command") + #expect(throws: RedisError.queryFailed("NOPERM User limited has no permissions to run the 'scan' command")) { + try reply.throwIfError() + } + } + + @Test("a status QUEUED reply throws for the command it held") + func queuedStatusThrows() { + #expect(throws: RedisError.commandQueued("SELECT")) { + try RedisReplyValue.status("QUEUED").throwIfQueued("SELECT") + } + } + + /// Measured on Redis 8.10.1: a `GET` of a key holding the word answers the bulk string, which is + /// a value and not the block's acknowledgement. + @Test("a bulk string QUEUED is a value") + func bulkQueuedIsAValue() throws { + let reply = try RedisReplyValue.string("QUEUED").throwIfError().throwIfQueued("GET") + #expect(reply == .string("QUEUED")) + #expect(!RedisReplyValue.string("QUEUED").isQueued) + } + + @Test("a good reply passes both guards unchanged") + func goodReplyPasses() throws { + let reply = try RedisReplyValue.status("OK").throwIfError().throwIfQueued("SELECT") + #expect(reply == .status("OK")) + } + + @Test("the queued error names the command and the open block") + func queuedDescription() { + #expect( + RedisError.commandQueued("SCAN").errorDescription + == "Redis queued SCAN instead of running it. " + + "A MULTI block is open on this connection. Run EXEC to apply it, or DISCARD to drop it." + ) + } +} + +@Suite("Redis SCAN page") +struct RedisScanPageTests { + @Test("a cursor and its keys parse") + func parsesCursorAndKeys() throws { + let page = try RedisScanPage(reply: scanReply(cursor: "0", keys: ["b", "a"])) + #expect(page == RedisScanPage(cursor: "0", keys: ["b", "a"])) + } + + @Test("a status or integer cursor is accepted") + func acceptsStatusAndIntegerCursors() throws { + let status = try RedisScanPage(reply: .array([.status("17"), .array([.status("k")])])) + #expect(status == RedisScanPage(cursor: "17", keys: ["k"])) + let integer = try RedisScanPage(reply: .array([.integer(42), .array([])])) + #expect(integer == RedisScanPage(cursor: "42", keys: [])) + } + + @Test("a reply of any other shape ends the walk with no keys", arguments: [ + RedisReplyValue.null, + .string("5"), + .array([.string("5")]) + ]) + func otherShapesEndTheWalk(reply: RedisReplyValue) throws { + let page = try RedisScanPage(reply: reply) + #expect(page == RedisScanPage(cursor: RedisScanPage.startCursor, keys: [])) + } + + @Test("a refused SCAN throws the server's message", arguments: [ + "NOPERM User limited has no permissions to run the 'scan' command", + "LOADING Redis is loading the dataset in memory" + ]) + func refusedScanThrows(message: String) { + #expect(throws: RedisError.queryFailed(message)) { + try RedisScanPage(reply: .error(message)) + } + } + + @Test("a queued SCAN throws instead of reading as an empty keyspace") + func queuedScanThrows() { + #expect(throws: RedisError.commandQueued("SCAN")) { + try RedisScanPage(reply: .status("QUEUED")) + } + } + + @Test("a key named QUEUED stays a key") + func keyNamedQueuedIsAKey() throws { + let page = try RedisScanPage(reply: scanReply(cursor: "0", keys: ["QUEUED"])) + #expect(page.keys == ["QUEUED"]) + } +} + +@Suite("Redis keyspace reads") +struct RedisKeyspaceReadsTests { + @Test("the walk follows the cursor and returns every key") + func walksEveryPage() async throws { + let server = ScriptedRedisServer(replies: [ + scanReply(cursor: "17", keys: ["b", "a"]), + scanReply(cursor: "0", keys: ["c"]) + ]) + let keys = try await RedisKeyspaceReads.keys { try await server.reply(to: $0) } + #expect(keys == ["b", "a", "c"]) + #expect(await server.sent == [ + ["SCAN", "0", "MATCH", "*", "COUNT", "1000"], + ["SCAN", "17", "MATCH", "*", "COUNT", "1000"] + ]) + } + + @Test("a queued first page throws after one SCAN") + func queuedFirstPageThrows() async { + let server = ScriptedRedisServer(replies: [.status("QUEUED"), scanReply(cursor: "0", keys: ["a"])]) + await #expect(throws: RedisError.commandQueued("SCAN")) { + try await RedisKeyspaceReads.keys { try await server.reply(to: $0) } + } + #expect(await server.sent.count == 1) + } + + @Test("a refused later page throws rather than returning the keys read so far") + func refusedLaterPageThrows() async { + let loading = "LOADING Redis is loading the dataset in memory" + let server = ScriptedRedisServer(replies: [scanReply(cursor: "9", keys: ["a"]), .error(loading)]) + await #expect(throws: RedisError.queryFailed(loading)) { + try await RedisKeyspaceReads.keys { try await server.reply(to: $0) } + } + #expect(await server.sent.count == 2) + } + + @Test("the walk stops at the key limit") + func stopsAtTheKeyLimit() async throws { + let pageKeys = (0 ..< RedisKeyspaceReads.scanPageSize).map { "key:\($0)" } + let server = ScriptedRedisServer(replies: [scanReply(cursor: "7", keys: pageKeys)], repeatsLastReply: true) + let keys = try await RedisKeyspaceReads.keys { try await server.reply(to: $0) } + #expect(keys.count == RedisKeyspaceReads.keyLimit) + #expect(await server.sent.count == RedisKeyspaceReads.keyLimit / RedisKeyspaceReads.scanPageSize) + } + + @Test("a key's type is read from a status or bulk reply") + func typeName() async throws { + let cases: [(reply: RedisReplyValue, expected: String)] = [ + (.status("hash"), "hash"), + (.string("zset"), "zset"), + (.null, "unknown") + ] + for testCase in cases { + let server = ScriptedRedisServer(replies: [testCase.reply]) + let name = try await RedisKeyspaceReads.typeName(ofKey: "k1") { try await server.reply(to: $0) } + #expect(name == testCase.expected, "\(testCase.reply)") + #expect(await server.sent == [["TYPE", "k1"]]) + } + } + + @Test("a refused TYPE throws the server's message") + func refusedTypeThrows() async { + let noperm = "NOPERM User notype has no permissions to run the 'type' command" + let server = ScriptedRedisServer(replies: [.error(noperm)]) + await #expect(throws: RedisError.queryFailed(noperm)) { + try await RedisKeyspaceReads.typeName(ofKey: "k1") { try await server.reply(to: $0) } + } + } + + @Test("a queued TYPE throws instead of naming the type QUEUED") + func queuedTypeThrows() async { + let server = ScriptedRedisServer(replies: [.status("QUEUED")]) + await #expect(throws: RedisError.commandQueued("TYPE")) { + try await RedisKeyspaceReads.typeName(ofKey: "k1") { try await server.reply(to: $0) } + } + } +} diff --git a/TableProTests/Core/Menu/MainMenuBuilderTests.swift b/TableProTests/Core/Menu/MainMenuBuilderTests.swift index 0201164bac..785c113b0e 100644 --- a/TableProTests/Core/Menu/MainMenuBuilderTests.swift +++ b/TableProTests/Core/Menu/MainMenuBuilderTests.swift @@ -10,6 +10,7 @@ import AppKit @testable import TablePro +import TableProPluginKit import Testing @MainActor @@ -309,6 +310,57 @@ struct MainMenuValidationTests { #expect(enabled(#selector(MainSplitViewController.executeQuery(_:)), context)) } + /// Redis declares no plan, and the menu item used to validate on the query text alone, so + /// `Cmd+Option+E` ran a `DEBUG OBJECT` the server refuses while the bar's button was dimmed. + @Test("Explain Query needs an engine that declares a plan") + func explainNeedsADeclaredPlan() { + var context = MenuValidationContext() + context.isConnected = true + context.hasQueryText = true + #expect(!enabled(#selector(MainSplitViewController.explainQuery(_:)), context)) + context.supportsExplain = true + #expect(enabled(#selector(MainSplitViewController.explainQuery(_:)), context)) + } + + /// `runExplain` returns at its first guard while the tab runs, so a lit item did nothing. + @Test("Explain Query dims while the tab is running a query") + func explainDimsWhileExecuting() { + var context = MenuValidationContext() + context.isConnected = true + context.hasQueryText = true + context.supportsExplain = true + context.isQueryExecuting = true + #expect(!enabled(#selector(MainSplitViewController.explainQuery(_:)), context)) + } + + @Test("Explain Query answers exactly what the editor bar's Explain answers") + func explainAgreesWithTheEditorBar() { + let variant = ExplainVariant(id: "plain", label: "Explain", sqlPrefix: "EXPLAIN") + for isConnected in [true, false] { + for hasQueryText in [true, false] { + for isExecuting in [true, false] { + for supportsExplain in [true, false] { + var context = MenuValidationContext() + context.isConnected = isConnected + context.hasQueryText = hasQueryText + context.isQueryExecuting = isExecuting + context.supportsExplain = supportsExplain + let bar = QueryCommandAvailability( + isConnected: isConnected, + hasQueryText: hasQueryText, + isExecuting: isExecuting, + isStoppable: true, + hasResults: false, + explainVariants: supportsExplain ? [variant] : [], + shortcutHint: { label, _ in label } + ) + #expect(enabled(#selector(MainSplitViewController.explainQuery(_:)), context) == bar.canExplain) + } + } + } + } + } + /// #2172: `paste:` had no window-level implementation at all, so with focus anywhere that does /// not paste, AppKit disabled the item, and a disabled item still owns its key equivalent, so /// Command+V was swallowed for the whole window. Adding the handler without an explicit arm @@ -478,6 +530,7 @@ struct MainMenuValidationTests { context.isQueryTab = true context.hasResultRows = true context.hasQueryText = true + context.supportsExplain = true context.hasPendingChanges = true context.hasDataPendingChanges = true context.hasImportFormats = true @@ -516,6 +569,7 @@ struct MainMenuValidationTests { #selector(MainSplitViewController.backupDatabase(_:)), #selector(MainSplitViewController.restoreDatabase(_:)), #selector(MainSplitViewController.executeQuery(_:)), + #selector(MainSplitViewController.explainQuery(_:)), #selector(MainSplitViewController.previewSQL(_:)), #selector(MainSplitViewController.createNewTable(_:)), #selector(MainSplitViewController.openContainerSwitcher(_:)), diff --git a/TableProTests/Core/Plugins/ConnectionFieldIntegerEntryTests.swift b/TableProTests/Core/Plugins/ConnectionFieldIntegerEntryTests.swift new file mode 100644 index 0000000000..e3b84af1e4 --- /dev/null +++ b/TableProTests/Core/Plugins/ConnectionFieldIntegerEntryTests.swift @@ -0,0 +1,114 @@ +// +// ConnectionFieldIntegerEntryTests.swift +// TableProTests +// +// A stepper field in the connection form is a text field paired with a stepper, so a value too +// far from the default to reach by clicking can be typed. The text is filtered on every +// keystroke and the stepper clamps whatever the text says. +// + +import Foundation +import TableProPluginKit +import Testing + +@testable import TablePro + +@Suite("Connection field integer entry") +struct ConnectionFieldIntegerEntryTests { + private let redisIndexes = ConnectionField.IntRange(0...2_147_483_646) + private let signed = ConnectionField.IntRange(-10...10) + private let timeout = ConnectionField.IntRange(1...120) + + @Test("Clamping holds a value inside the range") + func clamping() { + #expect(redisIndexes.clamping(-1) == 0) + #expect(redisIndexes.clamping(20) == 20) + #expect(redisIndexes.clamping(Int.max) == 2_147_483_646) + #expect(timeout.clamping(0) == 1) + } + + @Test("Typed text keeps ASCII digits only") + func keepsDigits() { + let cases: [(typed: String, expected: String)] = [ + ("20", "20"), + ("2a0", "20"), + ("-3", "3"), + (" 7 ", "7"), + ("1.5", "15"), + ("db4", "4"), + ("\u{0663}", ""), + ("\u{FF13}", ""), + ("007", "7"), + ("", ""), + ] + for entry in cases { + #expect(redisIndexes.fieldText(sanitizing: entry.typed) == entry.expected, "\(entry.typed)") + } + } + + @Test("A value past the upper bound, or past Int, is capped at the upper bound") + func capsAtUpperBound() { + #expect(redisIndexes.fieldText(sanitizing: "2147483646") == "2147483646") + #expect(redisIndexes.fieldText(sanitizing: "2147483647") == "2147483646") + #expect(redisIndexes.fieldText(sanitizing: "99999999999999999999") == "2147483646") + #expect(timeout.fieldText(sanitizing: "121") == "120") + } + + @Test("A value below the lower bound is left alone while it can still grow into range") + func leavesGrowableValues() { + #expect(timeout.fieldText(sanitizing: "0") == "0") + #expect(timeout.fieldText(sanitizing: "00") == "0") + } + + @Test("A minus sign is kept only at the start of a range that allows negatives") + func signedEntry() { + #expect(signed.fieldText(sanitizing: "-") == "-") + #expect(signed.fieldText(sanitizing: "-5") == "-5") + #expect(signed.fieldText(sanitizing: "-20") == "-10") + #expect(signed.fieldText(sanitizing: "20") == "10") + #expect(signed.fieldText(sanitizing: "5-") == "5") + #expect(signed.fieldText(sanitizing: "-99999999999999999999") == "-10") + #expect(redisIndexes.fieldText(sanitizing: "-") == "") + } + + @Test("The stepper reads the typed value, clamped") + func stepperReadsTypedValue() { + #expect(redisIndexes.stepperValue(fromFieldText: "20", defaultValue: "0") == 20) + #expect(redisIndexes.stepperValue(fromFieldText: " 7 ", defaultValue: "0") == 7) + #expect(redisIndexes.stepperValue(fromFieldText: "99999999999999999999", defaultValue: "0") == 2_147_483_646) + #expect(timeout.stepperValue(fromFieldText: "0", defaultValue: "10") == 1) + #expect(signed.stepperValue(fromFieldText: "-", defaultValue: "3") == 3) + } + + @Test("An empty field steps from the default, which is what every driver reads it as") + func emptyFieldStepsFromDefault() { + #expect(redisIndexes.stepperValue(fromFieldText: "", defaultValue: "0") == 0) + #expect(timeout.stepperValue(fromFieldText: "", defaultValue: "10") == 10) + #expect(timeout.stepperValue(fromFieldText: "", defaultValue: nil) == 1) + #expect(timeout.stepperValue(fromFieldText: "", defaultValue: "500") == 120) + #expect(timeout.stepperValue(fromFieldText: "", defaultValue: "none") == 1) + } + + private func field(_ fieldType: ConnectionField.FieldType) -> ConnectionField { + ConnectionField(id: "timeout", label: "Connect Timeout", defaultValue: "10", fieldType: fieldType) + } + + /// Typing keeps a value below the range so it can be entered digit by digit, so the form, not + /// the keystroke filter, is what stops one from being saved. + @Test("A stepper value outside its range is a validation issue") + func outOfRangeIsAnIssue() { + let stepper = field(.stepper(range: timeout)) + #expect(stepper.rangeIssue(in: "0") != nil) + #expect(stepper.rangeIssue(in: "121") != nil) + #expect(stepper.rangeIssue(in: "0")?.contains("Connect Timeout") == true) + } + + @Test("An empty, in-range or non-stepper value is not an issue") + func inRangeIsNotAnIssue() { + let stepper = field(.stepper(range: timeout)) + #expect(stepper.rangeIssue(in: "") == nil) + #expect(stepper.rangeIssue(in: "1") == nil) + #expect(stepper.rangeIssue(in: " 120 ") == nil) + #expect(field(.text).rangeIssue(in: "0") == nil) + } +} diff --git a/TableProTests/Core/Redis/RedisKeyTreeCommandTests.swift b/TableProTests/Core/Redis/RedisKeyTreeCommandTests.swift index 7e782bf13a..597765f919 100644 --- a/TableProTests/Core/Redis/RedisKeyTreeCommandTests.swift +++ b/TableProTests/Core/Redis/RedisKeyTreeCommandTests.swift @@ -39,7 +39,7 @@ struct RedisKeyTreeCommandTests { @Test("KEYBROWSE still parses to a key browse operation") func keyBrowseUnaffected() throws { - guard case .keyBrowse(let pattern, let typeScope, let limit, let offset) = + guard case .keyBrowse(let pattern, let typeScope, let limit, let offset, _) = try RedisCommandParser.parse("KEYBROWSE MATCH session:* TYPE hash LIMIT 100 OFFSET 50") else { Issue.record("Expected a keyBrowse operation") return diff --git a/TableProTests/Helpers/StubRedisChannel.swift b/TableProTests/Helpers/StubRedisChannel.swift new file mode 100644 index 0000000000..fed0376d6f --- /dev/null +++ b/TableProTests/Helpers/StubRedisChannel.swift @@ -0,0 +1,132 @@ +// +// StubRedisChannel.swift +// TableProTests +// +// A Redis command channel that answers from a script, for driving the channel-level logic +// without hiredis or a server. It admits and observes through the same footprint the hiredis +// connection keeps, so a test sees a held-back command exactly as the app would. +// + +import Foundation +import TableProPluginKit + +/// Driven by one task at a time, so the outcomes are handed out in order with no synchronisation. +final class StubRedisChannel: RedisCommandChannel, @unchecked Sendable { + private var outcomes: [Result] + private(set) var sentCommands: [[String]] = [] + private(set) var sentScopes: [RedisCommandScope] = [] + private(set) var footprint = RedisSessionFootprint() + let supportsDatabaseSelection: Bool + private(set) var sessionDatabase: RedisSessionDatabase + + convenience init(_ replies: [RedisReply], supportsDatabaseSelection: Bool = true, currentDatabase: Int = 0) { + self.init( + outcomes: replies.map { .success($0) }, + supportsDatabaseSelection: supportsDatabaseSelection, + currentDatabase: currentDatabase + ) + } + + init(outcomes: [Result], supportsDatabaseSelection: Bool = true, currentDatabase: Int = 0) { + self.outcomes = outcomes + self.supportsDatabaseSelection = supportsDatabaseSelection + sessionDatabase = RedisSessionDatabase(currentDatabase) + } + + var isConnected: Bool { true } + + func connect(reportingStage report: @escaping ConnectionStageReporter) async throws {} + func disconnect() {} + func cancelCurrentQuery() {} + func serverVersion() -> String? { "8.10.1" } + func currentDatabase() -> Int { sessionDatabase.current } + func databaseForNextCommand() -> Int { footprint.pendingDatabase ?? sessionDatabase.current } + func homeDatabase() -> Int { sessionDatabase.home } + + func selectDatabase(_ index: Int, scope: RedisCommandScope) async throws { + try moveSession(to: index, scope: scope) { $0.selected(index) } + } + + func visitDatabase(_ index: Int) async throws { + try moveSession(to: index, scope: .outsideBlock) { $0.visited(index) } + } + + /// Mirrors the hiredis connection: a SELECT queued in an open block moves nothing yet and + /// answers queued, and a move records itself only once the server accepted it. + private func moveSession( + to index: Int, + scope: RedisCommandScope, + recording move: (inout RedisSessionDatabase) -> Void + ) throws { + let command = ["SELECT", String(index)] + try admit(scope, command: command) + let reply = try send(command, scope: scope) ?? .status("OK") + _ = footprint.observe(command: "SELECT", reply: reply) + if case .error(let message) = reply { + throw RedisPluginError(code: 2, message: "SELECT \(index) failed: \(message)") + } + if reply.isQueued { + footprint.queueDatabase(index) + throw RedisQueuedCommand(command: "SELECT") + } + move(&sessionDatabase) + } + + func observeOpenBlock() { + _ = footprint.observe(command: "MULTI", reply: .status("OK")) + } + + func observeWatch() { + _ = footprint.observe(command: "WATCH", reply: .status("OK")) + } + + func executeCommand(_ args: [Data], scope: RedisCommandScope) async throws -> RedisReply { + let command = decoded(args) + try admit(scope, command: command) + try moveToCommandDatabase() + guard let reply = try send(command, scope: scope) else { return .null } + observe(command: command.first, reply: reply) + return reply + } + + /// Admitted once for the whole pipeline and observed after every reply is in, as the hiredis + /// connection does, because every command is on the wire before the first reply is read. + func executePipeline(_ commands: [[Data]], scope: RedisCommandScope) async throws -> [RedisReply] { + let pipeline = commands.map(decoded) + try admit(scope, command: pipeline.first ?? []) + try moveToCommandDatabase() + let replies = try pipeline.map { try send($0, scope: scope) } + for (command, reply) in zip(pipeline, replies) { + guard let reply else { continue } + observe(command: command.first, reply: reply) + } + return replies.map { $0 ?? .null } + } + + private func moveToCommandDatabase() throws { + guard !footprint.hasOpenBlock, + let target = sessionDatabase.databaseToMoveTo(visiting: RedisDatabaseVisit.database) else { return } + try moveSession(to: target, scope: .outsideBlock) { $0.visited(target) } + } + + private func observe(command: String?, reply: RedisReply) { + guard let moved = footprint.observe(command: command, reply: reply) else { return } + sessionDatabase.selected(moved) + } + + private func decoded(_ args: [Data]) -> [String] { + args.map { String(data: $0, encoding: .utf8) ?? "" } + } + + private func admit(_ scope: RedisCommandScope, command: [String]) throws { + guard let held = footprint.heldBack(scope) else { return } + throw RedisHeldBackCommand(command: command.first ?? "", held: held) + } + + private func send(_ command: [String], scope: RedisCommandScope) throws -> RedisReply? { + sentCommands.append(command) + sentScopes.append(scope) + guard !outcomes.isEmpty else { return nil } + return try outcomes.removeFirst().get() + } +} diff --git a/TableProTests/Models/Query/ExplainRequestTests.swift b/TableProTests/Models/Query/ExplainRequestTests.swift index a96be8a04b..2078a35388 100644 --- a/TableProTests/Models/Query/ExplainRequestTests.swift +++ b/TableProTests/Models/Query/ExplainRequestTests.swift @@ -81,51 +81,6 @@ struct ExplainRequestTests { #expect(request.format == .sqliteQueryPlan) } - @Test("A driver-built statement still resolves the database default format") - func driverBuiltUsesDatabaseDefault() { - let request = ExplainRequest.driverBuilt(sql: "EXPLAIN SELECT 1", databaseType: .duckdb) - - #expect(request.sql == "EXPLAIN SELECT 1") - #expect(request.subjectSQL == "EXPLAIN SELECT 1") - #expect(request.format == .indentedText) - #expect(request.variantKey == .driverBuilt) - } - - @Test("A driver-built statement retains a separately known subject") - func driverBuiltRetainsSubject() { - let request = ExplainRequest.driverBuilt( - sql: "EXPLAIN SELECT 1", - databaseType: .duckdb, - subjectSQL: "SELECT 1" - ) - - #expect(request.subjectSQL == "SELECT 1") - } - - @Test("A driver-built statement is marked so it keeps the ordinary result grid") - func driverBuiltIsFlagged() { - #expect(ExplainRequest.driverBuilt(sql: "DEBUG OBJECT key", databaseType: .redis).isDriverBuilt) - } - - @Test("A declared variant is not driver-built") - func declaredVariantIsNotDriverBuilt() throws { - let request = try #require( - ExplainRequest.make( - variant: nil, - declaredVariants: postgresVariants, - databaseType: .postgresql, - statement: "SELECT 1" - ) - ) - #expect(!request.isDriverBuilt) - } - - @Test("A driver-built statement on an unknown engine stays plain text") - func driverBuiltOnUnknownEngineStaysPlainText() { - let request = ExplainRequest.driverBuilt(sql: "DEBUG OBJECT key", databaseType: .redis) - #expect(request.format == .plainText) - } - @Test("The result factory retains the run's plan-history provenance") @MainActor func resultFactoryRetainsPlanContext() { diff --git a/TableProTests/Models/Query/QueryCommandAvailabilityTests.swift b/TableProTests/Models/Query/QueryCommandAvailabilityTests.swift index 6b8b76f0ee..76af44deac 100644 --- a/TableProTests/Models/Query/QueryCommandAvailabilityTests.swift +++ b/TableProTests/Models/Query/QueryCommandAvailabilityTests.swift @@ -73,6 +73,62 @@ struct QueryCommandAvailabilityTests { #expect(commands.explainHint.contains("does not explain")) } + /// Redis has no planner. It used to answer Explain with `DEBUG OBJECT`, which describes a stored + /// value rather than a statement and which Redis 7 refuses by default. + @Test("Redis declares no plan, so its bar does not offer Explain") + func redisOffersNoExplain() { + #expect(DatabaseType.redis.explainVariants.isEmpty) + #expect(Self.make(explainVariants: DatabaseType.redis.explainVariants).canExplain == false) + } + + @Test("Explain needs a session, a statement, an idle tab and a declared variant") + func canExplainTruthTable() { + for isConnected in [true, false] { + for hasQueryText in [true, false] { + for isExecuting in [true, false] { + for supportsExplain in [true, false] { + let expected = isConnected && hasQueryText && !isExecuting && supportsExplain + #expect( + QueryCommandAvailability.canExplain( + isConnected: isConnected, + hasQueryText: hasQueryText, + isExecuting: isExecuting, + supportsExplain: supportsExplain + ) == expected + ) + } + } + } + } + } + + /// The bar and the Query menu read the same rule, so the bar's own answer has to be that rule. + @Test("The bar's Explain is the shared rule, with a declared variant standing for support") + func barExplainIsTheSharedRule() { + let variant = ExplainVariant(id: "plain", label: "Explain", sqlPrefix: "EXPLAIN") + for isConnected in [true, false] { + for hasQueryText in [true, false] { + for isExecuting in [true, false] { + for variants in [[variant], []] { + let commands = Self.make( + isConnected: isConnected, + hasQueryText: hasQueryText, + isExecuting: isExecuting, + explainVariants: variants + ) + let shared = QueryCommandAvailability.canExplain( + isConnected: isConnected, + hasQueryText: hasQueryText, + isExecuting: isExecuting, + supportsExplain: !variants.isEmpty + ) + #expect(commands.canExplain == shared) + } + } + } + } + } + /// A dimmed control that does not say why is the one thing a reader cannot act on. @Test("A blocked command says why in its hint") func hintsExplainWhyBlocked() { diff --git a/TableProTests/Models/RedisKeyTreeNodeTests.swift b/TableProTests/Models/RedisKeyTreeNodeTests.swift index 4655d29052..4539393168 100644 --- a/TableProTests/Models/RedisKeyTreeNodeTests.swift +++ b/TableProTests/Models/RedisKeyTreeNodeTests.swift @@ -31,7 +31,7 @@ struct RedisKeyTreeBuildTests { @Test("Keys with same prefix are grouped under namespace") func samePrefix() { - let keys: [(key: String, type: String)] = [ + let keys: [(key: String, type: String?)] = [ ("user:1", "string"), ("user:2", "string"), ("user:3", "string") @@ -50,7 +50,7 @@ struct RedisKeyTreeBuildTests { @Test("Mixed namespaced and bare keys") func mixedKeys() { - let keys: [(key: String, type: String)] = [ + let keys: [(key: String, type: String?)] = [ ("user:1", "string"), ("config", "hash"), ("user:2", "string"), @@ -74,7 +74,7 @@ struct RedisKeyTreeBuildTests { @Test("Multi-level nesting") func multiLevel() { - let keys: [(key: String, type: String)] = [ + let keys: [(key: String, type: String?)] = [ ("app:cache:session:1", "string"), ("app:cache:session:2", "string"), ("app:config", "hash") @@ -90,7 +90,7 @@ struct RedisKeyTreeBuildTests { @Test("Empty separator returns all keys as flat leaves") func emptySeparator() { - let keys: [(key: String, type: String)] = [ + let keys: [(key: String, type: String?)] = [ ("user:1", "string"), ("user:2", "string") ] @@ -102,7 +102,7 @@ struct RedisKeyTreeBuildTests { @Test("Custom separator") func customSeparator() { - let keys: [(key: String, type: String)] = [ + let keys: [(key: String, type: String?)] = [ ("user/profile/1", "string"), ("user/profile/2", "string") ] @@ -116,7 +116,7 @@ struct RedisKeyTreeBuildTests { @Test("Key count is recursive") func recursiveKeyCount() { - let keys: [(key: String, type: String)] = [ + let keys: [(key: String, type: String?)] = [ ("a:b:1", "string"), ("a:b:2", "string"), ("a:c", "string") @@ -130,7 +130,7 @@ struct RedisKeyTreeBuildTests { @Test("Consecutive separators create empty-name segments") func consecutiveSeparators() { - let keys: [(key: String, type: String)] = [ + let keys: [(key: String, type: String?)] = [ ("a::b", "string") ] let tree = RedisKeyTreeViewModel.buildTree(keys: keys, separator: ":") @@ -143,7 +143,7 @@ struct RedisKeyTreeBuildTests { @Test("Multi-character separator") func multiCharSeparator() { - let keys: [(key: String, type: String)] = [ + let keys: [(key: String, type: String?)] = [ ("user::1", "string"), ("user::2", "string") ] @@ -158,7 +158,7 @@ struct RedisKeyTreeBuildTests { @Test("Preserves key type information") func preservesKeyType() { - let keys: [(key: String, type: String)] = [ + let keys: [(key: String, type: String?)] = [ ("myhash", "hash"), ("mylist", "list") ] @@ -173,9 +173,25 @@ struct RedisKeyTreeBuildTests { } } + @Test("A key whose type the server withheld keeps no type") + func unknownTypeStaysUnknown() { + let keys: [(key: String, type: String?)] = [ + ("app:1", "STRING"), + ("other:1", nil) + ] + let tree = RedisKeyTreeViewModel.buildTree(keys: keys, separator: "") + + #expect(tree.count == 2) + if case .key(_, _, let keyType) = tree[1] { + #expect(keyType == nil) + } else { + Issue.record("Expected leaf key") + } + } + @Test("Deeply nested keys") func deeplyNested() { - let keys: [(key: String, type: String)] = [ + let keys: [(key: String, type: String?)] = [ ("a:b:c:d:e", "string") ] let tree = RedisKeyTreeViewModel.buildTree(keys: keys, separator: ":") @@ -195,7 +211,7 @@ struct RedisKeyTreeBuildTests { @Test("Keys sorted alphabetically within namespace") func sortedKeys() { - let keys: [(key: String, type: String)] = [ + let keys: [(key: String, type: String?)] = [ ("ns:zebra", "string"), ("ns:apple", "string"), ("ns:mango", "string") @@ -210,7 +226,7 @@ struct RedisKeyTreeBuildTests { @Test("Namespaces sorted before leaf keys") func namespacesBeforeLeafs() { - let keys: [(key: String, type: String)] = [ + let keys: [(key: String, type: String?)] = [ ("z-bare-key", "string"), ("a-namespace:child", "string") ] @@ -246,6 +262,13 @@ struct RedisKeyNodeTests { #expect(key.displayName == "session") } + @Test("A key of unknown type shows the plain key glyph") + func unknownTypeIcon() { + #expect(RedisKeyNode.iconName(forKeyType: nil) == "key") + #expect(RedisKeyNode.iconName(forKeyType: "HASH") == "square.grid.2x2") + #expect(RedisKeyNode.iconName(forKeyType: "ReJSON-RL") == "key") + } + @Test("Equality based on id only") func equalityById() { let a = RedisKeyNode.namespace(name: "x", fullPrefix: "x:", children: [], keyCount: 0) @@ -258,27 +281,24 @@ struct RedisKeyNodeTests { // MARK: - DisplayNodes Tests -@Suite("RedisKeyTreeViewModel displayNodes") -@MainActor +@Suite("RedisKeyTreeContent displayNodes") struct RedisKeyTreeDisplayTests { - @Test("displayNodes returns rootNodes when search is empty") + @Test("displayNodes returns the whole tree when search is empty") func emptySearch() { - let vm = RedisKeyTreeViewModel() - let nodes = [RedisKeyNode.key(name: "test", fullKey: "test", keyType: "string")] - vm.rootNodes = nodes - let result = vm.displayNodes(searchText: "") + let content = RedisKeyTreeContent(database: "0", separator: ":", keys: [(key: "test", type: "string")]) + let result = content.displayNodes(searchText: "") #expect(result.count == 1) + #expect(result == content.rootNodes) } @Test("displayNodes filters by search text") func searchFilters() { - let vm = RedisKeyTreeViewModel() - vm.allKeysForTesting = [ - (key: "user:1", type: "string"), - (key: "session:abc", type: "string") - ] - vm.separator = ":" - let result = vm.displayNodes(searchText: "user") + let content = RedisKeyTreeContent( + database: "0", + separator: ":", + keys: [(key: "user:1", type: "string"), (key: "session:abc", type: "string")] + ) + let result = content.displayNodes(searchText: "user") #expect(result.count == 1) if case .namespace(let name, _, _, _) = result[0] { #expect(name == "user") @@ -287,10 +307,86 @@ struct RedisKeyTreeDisplayTests { @Test("displayNodes returns empty for no match") func noMatch() { - let vm = RedisKeyTreeViewModel() - vm.allKeysForTesting = [(key: "user:1", type: "string")] - vm.separator = ":" - let result = vm.displayNodes(searchText: "xyz") - #expect(result.isEmpty) + let content = RedisKeyTreeContent(database: "0", separator: ":", keys: [(key: "user:1", type: "string")]) + #expect(content.displayNodes(searchText: "xyz").isEmpty) + } + + @Test("A load that reaches the key limit is marked truncated, and one below it is not") + func truncationFollowsTheKeyLimit() { + let full = (0.. [Int64?]? { + reply.arrayValue?.map(intValue) + } + + /// `SCRIPT EXISTS` answers one flag per script from every shard; reading the array as a + /// number reported `0` for a script every shard had loaded. + @Test("agg_logical_and over SCRIPT EXISTS folds each script's flag across shards") + func scriptExistsFoldsPerPosition() { + let combined = RedisClusterAggregator.combine( + [ + .array([.integer(1), .integer(0), .integer(1)]), + .array([.integer(1), .integer(1), .integer(0)]), + ], + policy: .aggLogicalAnd + ) + #expect(Self.integers(combined) == [1, 0, 0]) + } + + @Test("agg_min over WAITAOF takes the smallest local and replica counts") + func waitAofFoldsPerPosition() { + let combined = RedisClusterAggregator.combine( + [.array([.integer(1), .integer(2)]), .array([.integer(0), .integer(3)])], + policy: .aggMin + ) + #expect(Self.integers(combined) == [0, 2]) + } + + @Test("Replies the policy cannot count come back whole instead of as a made-up number") + func uncountableRepliesComeBackWhole() { + let mixed = RedisClusterAggregator.combine([.integer(1), .null], policy: .aggSum) + #expect(intValue(mixed) == nil) + #expect(mixed.arrayValue?.count == 2) + + let ragged = RedisClusterAggregator.combine( + [.array([.integer(1), .integer(2)]), .array([.integer(1), .integer(2), .integer(3)])], + policy: .aggSum + ) + #expect(ragged.arrayValue?.map { $0.arrayValue?.count } == [2, 3]) + } + + @Test("A count sent as a string still sums") + func numericStringsSum() { + let combined = RedisClusterAggregator.combine([.integer(2), .string("3")], policy: .aggSum) + #expect(intValue(combined) == 5) + } +} diff --git a/TableProTests/Plugins/RedisConnectionFieldsTests.swift b/TableProTests/Plugins/RedisConnectionFieldsTests.swift index 679a7f0955..08a9a0a0d7 100644 --- a/TableProTests/Plugins/RedisConnectionFieldsTests.swift +++ b/TableProTests/Plugins/RedisConnectionFieldsTests.swift @@ -64,6 +64,16 @@ struct RedisConnectionFieldsTests { } } + /// The app cannot see plugin code, so the curated copy spells the range out. The plugin's + /// fields replace it once the plugin loads, and until then the form shows this one. + @Test("The curated Database Index offers the same range as the plugin") + func databaseIndexMatchesPlugin() throws { + let database = try #require(try redisFields().first { $0.id == RedisDatabaseIndex.fieldName }) + #expect(database.fieldType == .stepper(range: ConnectionField.IntRange(RedisDatabaseIndex.selectable))) + #expect(database.defaultValue == "0") + #expect(database.section == .advanced) + } + @Test("The Sentinel password is secure, so it is stored in the Keychain") func sentinelPasswordIsSecure() throws { let password = try #require(try redisFields().first { $0.id == "redisSentinelPassword" }) diff --git a/TableProTests/Plugins/RedisConnectionModeTests.swift b/TableProTests/Plugins/RedisConnectionModeTests.swift index 8061ee8001..721aeeec59 100644 --- a/TableProTests/Plugins/RedisConnectionModeTests.swift +++ b/TableProTests/Plugins/RedisConnectionModeTests.swift @@ -110,18 +110,42 @@ struct RedisServerInfoTests { #expect(RedisServerInfo.mode(from: "redis_mode:standalone") == .standalone) } + /// Valkey 8 and later name the line `server_mode` unless `extended-redis-compatibility` is on. + @Test("Reads Valkey's server_mode when redis_mode is absent") + func readsValkeyServerMode() { + #expect(RedisServerInfo.mode(from: "# Server\r\nserver_mode:sentinel\r\n") == .sentinel) + #expect(RedisServerInfo.mode(from: "# Server\r\nserver_mode:cluster\r\n") == .cluster) + #expect(RedisServerInfo.mode(from: "# Server\r\nserver_mode:standalone\r\n") == .standalone) + } + + @Test("redis_mode wins when a server reports both") + func redisModeWinsOverServerMode() { + let info = "# Server\r\nredis_mode:cluster\r\nserver_mode:standalone\r\n" + #expect(RedisServerInfo.mode(from: info) == .cluster) + } + @Test("An absent key is absent, not a wrong answer") func missingKeyIsNil() { #expect(RedisServerInfo.mode(from: "# Server\r\n") == nil) #expect(RedisServerInfo.version(from: "") == nil) } - @Test("Reads a database's key count out of INFO keyspace") - func readsKeyCount() { - let info = "# Keyspace\r\ndb0:keys=100,expires=0,avg_ttl=0\r\ndb3:keys=7,expires=1\r\n" - #expect(RedisServerInfo.keyCount(forDatabase: "db0", in: info) == 100) - #expect(RedisServerInfo.keyCount(forDatabase: "db3", in: info) == 7) - #expect(RedisServerInfo.keyCount(forDatabase: "db9", in: info) == nil) + @Test("Reads every database's key count out of INFO keyspace") + func readsKeyspace() { + let info = "# Keyspace\r\ndb0:keys=100,expires=0,avg_ttl=0\r\ndb3:keys=7,expires=1\r\ndb20:keys=1\r\n" + #expect(RedisServerInfo.keyspace(from: info) == [0: 100, 3: 7, 20: 1]) + } + + @Test("An empty keyspace section names no database") + func emptyKeyspace() { + #expect(RedisServerInfo.keyspace(from: "# Keyspace\r\n").isEmpty) + #expect(RedisServerInfo.keyspace(from: "").isEmpty) + } + + @Test("A line that is not a database, or has no key count, is skipped") + func skipsMalformedKeyspaceLines() { + let info = "# Keyspace\r\ndbx:keys=1\r\ndb-1:keys=4\r\ndb2:expires=1\r\nfoo:keys=9\r\ndb5:keys=2\r\n" + #expect(RedisServerInfo.keyspace(from: info) == [5: 2]) } } diff --git a/TableProTests/Plugins/RedisDatabaseIndexTests.swift b/TableProTests/Plugins/RedisDatabaseIndexTests.swift index 15981882b4..5a80f06941 100644 --- a/TableProTests/Plugins/RedisDatabaseIndexTests.swift +++ b/TableProTests/Plugins/RedisDatabaseIndexTests.swift @@ -29,6 +29,16 @@ struct RedisDatabaseIndexTests { #expect(RedisDatabaseIndex.resolve(additionalFields: ["redisDatabase": ""], database: "db0") == 0) } + /// `databases` accepts 1 to 2147483647 on redis-server 8.10.1 and `SELECT` parses a C int, so + /// a server can hold any index up to one below Int32.max. Sixteen is only the default. + @Test("every index a server can be configured to hold is selectable") + func selectableSpansEveryConfigurableDatabase() { + #expect(RedisDatabaseIndex.selectable.lowerBound == 0) + #expect(RedisDatabaseIndex.selectable.upperBound == 2_147_483_646) + #expect(RedisDatabaseIndex.selectable.count == Int(Int32.max)) + #expect(RedisDatabaseCount.limit == RedisDatabaseIndex.selectable.count) + } + @Test("parse rejects what is not an index so the switch can report it") func parseRejectsNonIndexes() { #expect(RedisDatabaseIndex.parse("db4") == 4) diff --git a/TableProTests/Plugins/RedisDatabaseListingTests.swift b/TableProTests/Plugins/RedisDatabaseListingTests.swift new file mode 100644 index 0000000000..b5a1855bc9 --- /dev/null +++ b/TableProTests/Plugins/RedisDatabaseListingTests.swift @@ -0,0 +1,324 @@ +// +// RedisDatabaseListingTests.swift +// TableProTests +// +// ElastiCache and Azure remove CONFIG and Memorystore denies it, so `CONFIG GET databases` was +// answered with an error that the sidebar reported instead of listing the databases (#3036). +// The replies here are the ones redis-server 8.10.1 sends, measured with `--rename-command +// CONFIG ''`, an ACL user without `config|get` or `info`, and `--databases 32`. +// + +import Foundation +import TableProPluginKit +import Testing + +private struct TransportFailure: Error, Equatable {} + +@Suite("Redis metadata read - what counts as the server declining") +struct RedisMetadataReadTests { + static let declined: [String] = [ + "ERR unknown command 'CONFIG', with args beginning with: 'GET' 'databases' ", + "NOPERM User app has no permissions to run the 'config|get' command", + "NOPERM User app has no permissions to run the 'info' command", + "ERR Can't execute 'config|get': only (P|S)SUBSCRIBE / (P|S)UNSUBSCRIBE / PING / QUIT / RESET are allowed in this context", + "err lowercase class", + ] + + @Test("Removed, unknown and ACL-denied commands are declined", arguments: declined) + func declinedClasses(message: String) { + #expect(RedisMetadataRead.declinedClass(of: .error(message)) != nil) + } + + static let surfaced: [String] = [ + "BUSY Redis is busy running a script. You can only call SCRIPT KILL or FUNCTION KILL.", + "NOAUTH Authentication required.", + "LOADING Redis is loading the dataset in memory", + "MASTERDOWN Link with MASTER is down and replica-serve-stale-data is set to 'no'.", + "NOPERMX not the NOPERM class", + "ERRX not the ERR class", + "", + ] + + @Test("Transient states and other classes are not declined", arguments: surfaced) + func surfacedClasses(message: String) { + #expect(RedisMetadataRead.declinedClass(of: .error(message)) == nil) + } + + @Test("A reply that is not an error is never declined") + func nonErrorsAreNotDeclined() { + #expect(RedisMetadataRead.declinedClass(of: .status("QUEUED")) == nil) + #expect(RedisMetadataRead.declinedClass(of: .array([])) == nil) + #expect(RedisMetadataRead.declinedClass(of: .null) == nil) + } +} + +@Suite("Redis command channel - metadata reads") +struct RedisCommandChannelMetadataReadTests { + @Test("A declined read answers nil instead of throwing") + func declinedReadIsNil() async throws { + let channel = StubRedisChannel([.error("ERR unknown command 'CONFIG'")]) + #expect(try await channel.runMetadataRead(["CONFIG", "GET", "databases"]) == nil) + } + + @Test("A busy server throws, labelled with the command") + func busyThrows() async throws { + let channel = StubRedisChannel([.error("BUSY Redis is busy running a script.")]) + do { + _ = try await channel.runMetadataRead(["CONFIG", "GET", "databases"]) + Issue.record("expected a throw") + } catch let error as RedisPluginError { + #expect(error.message == "CONFIG: BUSY Redis is busy running a script.") + } + } + + /// Declining a read sent into the user's open block would report a healthy list over a + /// transaction the read has just joined. + @Test("A queued acknowledgement throws rather than reading as declined") + func queuedThrows() async throws { + let channel = StubRedisChannel([.status("QUEUED")]) + await #expect(throws: RedisQueuedCommand(command: "INFO")) { + try await channel.runMetadataRead(["INFO", "keyspace"]) + } + } + + @Test("A transport failure propagates untouched") + func transportFailurePropagates() async throws { + let channel = StubRedisChannel(outcomes: [.failure(TransportFailure())]) + await #expect(throws: TransportFailure()) { + try await channel.runMetadataRead(["CONFIG", "GET", "databases"]) + } + } + + @Test("An answer passes through unchanged") + func answerPassesThrough() async throws { + let channel = StubRedisChannel([.string("# Keyspace\r\ndb0:keys=1\r\n")]) + #expect(try await channel.runMetadataRead(["INFO", "keyspace"])?.stringValue == "# Keyspace\r\ndb0:keys=1\r\n") + } +} + +@Suite("Redis database count") +struct RedisDatabaseCountTests { + @Test("Reads the count out of CONFIG GET databases") + func readsReportedCount() { + #expect(RedisDatabaseCount.reported(by: .array([.string("databases"), .string("16")])) == 16) + #expect(RedisDatabaseCount.reported(by: .array([.string("databases"), .string("40")])) == 40) + #expect(RedisDatabaseCount.reported(by: .array([.string("databases"), .string("1")])) == 1) + } + + static let unusable: [RedisReply] = [ + .array([.string("databases"), .string("0")]), + .array([.string("databases"), .string("-1")]), + .array([.string("databases"), .string("many")]), + .array([.string("databases"), .string("4294967296")]), + .array([.string("databases")]), + .array([]), + .null, + .string("16"), + ] + + @Test("A reply that names no usable count reports none", arguments: unusable) + func unusableReplies(reply: RedisReply) { + #expect(RedisDatabaseCount.reported(by: reply) == nil) + } + + @Test("The server's own count wins over the keyspace and the session") + func reportedWins() { + #expect(RedisDatabaseCount.resolve(reported: 16, keyspace: [39: 1], currentDatabase: 20) == 16) + } + + @Test("Without a count or a keyspace the default of 16 stands") + func assumesSixteen() { + #expect(RedisDatabaseCount.resolve(reported: nil, keyspace: nil, currentDatabase: 0) == 16) + #expect(RedisDatabaseCount.resolve(reported: nil, keyspace: [0: 5, 3: 1], currentDatabase: 0) == 16) + } + + @Test("A populated database past 15 widens the count to include it") + func keyspaceWidens() { + #expect(RedisDatabaseCount.resolve(reported: nil, keyspace: [0: 1, 20: 1], currentDatabase: 0) == 21) + #expect(RedisDatabaseCount.resolve(reported: nil, keyspace: [16: 1], currentDatabase: 0) == 17) + } + + @Test("The database the session is on is always listed") + func currentDatabaseWidens() { + #expect(RedisDatabaseCount.resolve(reported: nil, keyspace: nil, currentDatabase: 20) == 21) + #expect(RedisDatabaseCount.resolve(reported: nil, keyspace: [:], currentDatabase: 40) == 41) + } + + @Test("An index past Int32 cannot overflow the count") + func boundsHugeIndices() { + let huge = Int(Int32.max) + #expect(RedisDatabaseCount.resolve(reported: nil, keyspace: [huge: 1, Int.max: 1], currentDatabase: 0) == 16) + #expect(RedisDatabaseCount.resolve(reported: nil, keyspace: [huge - 1: 1], currentDatabase: 0) == huge) + } +} + +@Suite("Redis command channel - database listing") +struct RedisDatabaseListingTests { + private static let removedConfig = RedisReply.error( + "ERR unknown command 'CONFIG', with args beginning with: 'GET' 'databases' " + ) + private static let deniedInfo = RedisReply.error("NOPERM User app has no permissions to run the 'info' command") + + @Test("A removed CONFIG lists 16 databases with their key counts") + func removedConfigFallsBack() async throws { + let channel = StubRedisChannel([Self.removedConfig, .string("# Keyspace\r\ndb0:keys=3,expires=0\r\n")]) + let listing = try await channel.databaseListing(includingKeyCounts: true) + #expect(listing.databaseCount == 16) + #expect(listing.keyCount(forDatabase: 0) == 3) + #expect(listing.keyCount(forDatabase: 5) == 0) + #expect(channel.sentCommands == [["CONFIG", "GET", "databases"], ["INFO", "keyspace"]]) + } + + @Test("A removed CONFIG on a server with keys in db20 lists through db20") + func removedConfigWidensToKeyspace() async throws { + let channel = StubRedisChannel([Self.removedConfig, .string("# Keyspace\r\ndb0:keys=1\r\ndb20:keys=1\r\n")]) + let listing = try await channel.databaseListing(includingKeyCounts: true) + #expect(listing.databaseCount == 21) + #expect(listing.keyCount(forDatabase: 20) == 1) + } + + @Test("An ACL user refused both CONFIG and INFO gets 16 databases with unknown counts") + func bothDeclined() async throws { + let channel = StubRedisChannel([ + .error("NOPERM User app has no permissions to run the 'config|get' command"), + Self.deniedInfo, + ]) + let listing = try await channel.databaseListing(includingKeyCounts: true) + #expect(listing.databaseCount == 16) + #expect(listing.keyCounts == nil) + #expect(listing.keyCount(forDatabase: 0) == nil) + } + + @Test("A refused INFO leaves the reported count and unknown key counts") + func infoDeclinedKeepsReportedCount() async throws { + let channel = StubRedisChannel([.array([.string("databases"), .string("40")]), Self.deniedInfo]) + let listing = try await channel.databaseListing(includingKeyCounts: true) + #expect(listing.databaseCount == 40) + #expect(listing.keyCount(forDatabase: 0) == nil) + } + + @Test("A reported count needs no keyspace when key counts are not wanted") + func reportedCountSkipsInfo() async throws { + let channel = StubRedisChannel([.array([.string("databases"), .string("16")])]) + let listing = try await channel.databaseListing(includingKeyCounts: false) + #expect(listing.databaseCount == 16) + #expect(listing.keyCounts == nil) + #expect(channel.sentCommands == [["CONFIG", "GET", "databases"]]) + } + + /// The database list and the sidebar have to agree, so the list reads the keyspace whenever + /// the count depends on it. + @Test("A removed CONFIG reads the keyspace for the count even without key counts") + func removedConfigReadsKeyspaceForTheCount() async throws { + let channel = StubRedisChannel([Self.removedConfig, .string("# Keyspace\r\ndb31:keys=2\r\n")]) + let listing = try await channel.databaseListing(includingKeyCounts: false) + #expect(listing.databaseCount == 32) + #expect(listing.keyCounts == nil) + } + + @Test("The session's own database is listed when nothing else names it") + func currentDatabaseIsListed() async throws { + let channel = StubRedisChannel([Self.removedConfig, Self.deniedInfo], currentDatabase: 24) + let listing = try await channel.databaseListing(includingKeyCounts: true) + #expect(listing.databaseCount == 25) + } + + @Test("A busy server fails the listing instead of guessing") + func busyFails() async throws { + let channel = StubRedisChannel([.error("BUSY Redis is busy running a script.")]) + await #expect(throws: RedisPluginError.self) { + try await channel.databaseListing(includingKeyCounts: true) + } + } + + @Test("An open MULTI block fails the listing with the queued error") + func queuedFails() async throws { + let channel = StubRedisChannel([.status("QUEUED")]) + await #expect(throws: RedisQueuedCommand(command: "CONFIG")) { + try await channel.databaseListing(includingKeyCounts: true) + } + } + + @Test("A dropped connection fails the listing") + func transportFailureFails() async throws { + let channel = StubRedisChannel(outcomes: [.success(Self.removedConfig), .failure(TransportFailure())]) + await #expect(throws: TransportFailure()) { + try await channel.databaseListing(includingKeyCounts: true) + } + } + + @Test("A declined keyspace is unknown, an answered one is a map") + func keyCountsByDatabase() async throws { + let declined = StubRedisChannel([Self.deniedInfo]) + #expect(try await declined.keyCountsByDatabase() == nil) + + let answered = StubRedisChannel([.string("# Keyspace\r\ndb3:keys=7\r\n")]) + #expect(try await answered.keyCountsByDatabase() == [3: 7]) + } +} + +/// A cluster answers `INFO` from one master, so its one keyspace is counted with `DBSIZE`, which +/// every master answers and the channel sums. Measured on a two-master redis-server 8.10.1 +/// cluster with `-dbsize` on one master, and with that master busy running a script: both used +/// to report the other master's count as the whole keyspace. +@Suite("Redis command channel - cluster database listing") +struct RedisClusterDatabaseListingTests { + @Test("A cluster lists one database and counts its keys with DBSIZE") + func countsWithDbsize() async throws { + let channel = StubRedisChannel([.integer(21)], supportsDatabaseSelection: false) + let listing = try await channel.databaseListing(includingKeyCounts: true) + #expect(listing.databaseCount == 1) + #expect(listing.keyCount(forDatabase: 0) == 21) + #expect(channel.sentCommands == [["DBSIZE"]]) + #expect(channel.sentScopes == [.outsideBlock]) + } + + @Test("A cluster listing without key counts asks nothing") + func withoutCountsAsksNothing() async throws { + let channel = StubRedisChannel([], supportsDatabaseSelection: false) + let listing = try await channel.databaseListing(includingKeyCounts: false) + #expect(listing.databaseCount == 1) + #expect(listing.keyCounts == nil) + #expect(channel.sentCommands.isEmpty) + } + + @Test("A master that declines DBSIZE leaves the count unknown rather than short") + func declinedIsUnknown() async throws { + let channel = StubRedisChannel( + [.error("NOPERM User counter has no permissions to run the 'dbsize' command")], + supportsDatabaseSelection: false + ) + let listing = try await channel.databaseListing(includingKeyCounts: true) + #expect(listing.databaseCount == 1) + #expect(listing.keyCount(forDatabase: 0) == nil) + } + + @Test("A master still loading fails the listing instead of reporting a short count") + func loadingThrows() async throws { + let channel = StubRedisChannel( + [.error("LOADING Redis is loading the dataset in memory")], + supportsDatabaseSelection: false + ) + do { + _ = try await channel.databaseListing(includingKeyCounts: true) + Issue.record("expected a throw") + } catch let error as RedisPluginError { + #expect(error.message == "DBSIZE: LOADING Redis is loading the dataset in memory") + } + } + + @Test("A queued DBSIZE throws instead of counting the acknowledgement") + func queuedThrows() async throws { + let channel = StubRedisChannel([.status("QUEUED")], supportsDatabaseSelection: false) + await #expect(throws: RedisQueuedCommand(command: "DBSIZE")) { + try await channel.databaseListing(includingKeyCounts: true) + } + } + + @Test("The key counts of a cluster are database 0's") + func keyCountsByDatabase() async throws { + let channel = StubRedisChannel([.integer(9)], supportsDatabaseSelection: false) + #expect(try await channel.keyCountsByDatabase() == [0: 9]) + #expect(channel.sentCommands == [["DBSIZE"]]) + } +} diff --git a/TableProTests/Plugins/RedisDatabaseTargetTests.swift b/TableProTests/Plugins/RedisDatabaseTargetTests.swift new file mode 100644 index 0000000000..7efbc5a7dd --- /dev/null +++ b/TableProTests/Plugins/RedisDatabaseTargetTests.swift @@ -0,0 +1,327 @@ +// +// RedisDatabaseTargetTests.swift +// TableProTests +// +// A Redis connection reads whichever database its session last selected, and the app names each +// database as a table. Row counts, statistics and the DDL preview read the session's database +// whatever row they were asked about, and a tab whose SELECT the server refused went on to show +// the session's keys under another database's name once it was refreshed. +// + +import Foundation +import TableProPluginKit +import Testing + +private struct Refused: Error, Equatable {} + +@Suite("Redis KEYBROWSE - the database it reads") +struct RedisKeyBrowseDatabaseTests { + private func database(of command: String) throws -> Int? { + guard case .keyBrowse(_, _, _, _, let database) = try RedisCommandParser.parse(command) else { + Issue.record("Expected a keyBrowse operation for \(command)") + return nil + } + return database + } + + @Test("DB names the database, as an index or as the sidebar spells it") + func parsesDatabase() throws { + #expect(try database(of: "KEYBROWSE DB 3 LIMIT 10 OFFSET 0") == 3) + #expect(try database(of: "KEYBROWSE MATCH a* DB db12") == 12) + #expect(try database(of: "KEYBROWSE LIMIT 10 OFFSET 0") == nil) + } + + static let invalid = ["KEYBROWSE DB", "KEYBROWSE DB x", "KEYBROWSE DB -1", "KEYBROWSE DB dbx"] + + @Test("A DB that names no database is refused rather than read as the current one", arguments: invalid) + func rejectsInvalidDatabase(command: String) { + #expect(throws: RedisParseError.self) { + try RedisCommandParser.parse(command) + } + } + + @Test("A table's browse and filter queries carry its database through a round trip") + func builtQueriesRoundTrip() throws { + let builder = RedisQueryBuilder() + #expect(try database(of: builder.buildBaseQuery(namespace: "", database: 5)) == 5) + let filtered = builder.buildFilteredQuery( + namespace: "", database: 7, filters: [(column: "Key", op: "MATCH", value: "user:*")] + ) + #expect(try database(of: filtered) == 7) + guard case .keyBrowse(let pattern, _, _, _, _) = try RedisCommandParser.parse(filtered) else { + Issue.record("Expected a keyBrowse operation") + return + } + #expect(pattern == "user:*") + #expect(try database(of: builder.buildBaseQuery(namespace: "")) == nil) + } + + @Test("An export reads the whole database the row names") + func exportQuery() { + let builder = RedisQueryBuilder() + #expect(builder.buildExportQuery(database: 3) == "KEYBROWSE DB 3") + #expect(builder.buildExportQuery(database: nil) == "KEYBROWSE") + } +} + +@Suite("Redis command channel - moving to a database") +struct RedisMoveToDatabaseTests { + /// A read-only ACL user is refused SELECT even for the database it is already on, which made + /// the first sidebar click on a Redis connection fail for that user. + @Test("A move to the database the session is on sends nothing") + func sameDatabaseSendsNothing() async throws { + let channel = StubRedisChannel([], currentDatabase: 3) + try await channel.moveToDatabase(3) + #expect(channel.sentCommands.isEmpty) + } + + @Test("A move to another database sends SELECT as the app's own command") + func otherDatabaseSelects() async throws { + let channel = StubRedisChannel([.status("OK")]) + try await channel.moveToDatabase(4) + #expect(channel.sentCommands == [["SELECT", "4"]]) + #expect(channel.sentScopes == [.outsideBlock]) + #expect(channel.currentDatabase() == 4) + } + + /// A SELECT typed into an open block is queued like any other command, so the block's `EXEC` + /// reply pairs with it, and it moves nothing until `EXEC` runs it. + @Test("A SELECT queued in a block answers queued and moves nothing yet") + func queuedSelectMovesNothing() async throws { + let channel = StubRedisChannel([.status("QUEUED")]) + channel.observeOpenBlock() + await #expect(throws: RedisQueuedCommand(command: "SELECT")) { + try await channel.selectDatabase(6) + } + #expect(channel.databaseForNextCommand() == 6) + #expect(channel.currentDatabase() == 0) + #expect(channel.homeDatabase() == 0) + } + + @Test("A move sets where the session belongs, not only where it is") + func moveSetsHome() async throws { + let channel = StubRedisChannel([.status("OK")]) + try await channel.moveToDatabase(4) + #expect(channel.homeDatabase() == 4) + } + + @Test("A refused SELECT is reported") + func refusalPropagates() async throws { + let channel = StubRedisChannel([.error("ERR DB index is out of range")]) + await #expect(throws: RedisPluginError.self) { + try await channel.moveToDatabase(9) + } + #expect(channel.currentDatabase() == 0) + } +} + +@Suite("Redis command channel - a read on another database") +struct RedisWithDatabaseTests { + @Test("On the session's own database only the read runs") + func sameDatabaseRunsBodyOnly() async throws { + let channel = StubRedisChannel([.integer(4)], currentDatabase: 2) + let count = try await channel.withDatabase(2) { try await channel.executeCommand(["DBSIZE"]).intValue } + #expect(count == 4) + #expect(channel.sentCommands == [["DBSIZE"]]) + } + + @Test("No database named reads the session's own") + func noDatabaseRunsBodyOnly() async throws { + let channel = StubRedisChannel([.integer(1)]) + _ = try await channel.withDatabase(nil) { try await channel.executeCommand(["DBSIZE"]) } + #expect(channel.sentCommands == [["DBSIZE"]]) + } + + @Test("Another database is selected for the read and the session is put back") + func otherDatabaseRoundTrips() async throws { + let channel = StubRedisChannel([.status("OK"), .integer(9), .status("OK")], currentDatabase: 1) + let count = try await channel.withDatabase(5) { try await channel.executeCommand(["DBSIZE"]).intValue } + #expect(count == 9) + #expect(channel.sentCommands == [["SELECT", "5"], ["DBSIZE"], ["SELECT", "1"]]) + #expect(channel.currentDatabase() == 1) + } + + @Test("A read that fails still puts the session back") + func failedReadRestores() async throws { + let channel = StubRedisChannel(outcomes: [.success(.status("OK")), .failure(Refused()), .success(.status("OK"))]) + await #expect(throws: Refused()) { + try await channel.withDatabase(5) { try await channel.executeCommand(["DBSIZE"]) } + } + #expect(channel.sentCommands.last == ["SELECT", "0"]) + #expect(channel.currentDatabase() == 0) + } + + @Test("A database the server refuses stops the read before it runs") + func refusedSelectRunsNothing() async throws { + let channel = StubRedisChannel([.error("ERR DB index is out of range")]) + await #expect(throws: RedisPluginError.self) { + try await channel.withDatabase(20) { try await channel.executeCommand(["DBSIZE"]) } + } + #expect(channel.sentCommands == [["SELECT", "20"]]) + } +} + +@Suite("Redis command channel - one database's key count") +struct RedisKeyCountTests { + @Test("The session's own database is counted exactly") + func currentDatabaseUsesDbsize() async throws { + let channel = StubRedisChannel([.integer(42)], currentDatabase: 3) + #expect(try await channel.keyCount(inDatabase: 3) == 42) + #expect(channel.sentCommands == [["DBSIZE"]]) + } + + @Test("Another database is read from INFO keyspace without moving the session") + func otherDatabaseUsesKeyspace() async throws { + let channel = StubRedisChannel([.string("# Keyspace\r\ndb0:keys=5\r\ndb3:keys=12\r\n")]) + #expect(try await channel.keyCount(inDatabase: 3) == 12) + #expect(channel.sentCommands == [["INFO", "keyspace"]]) + } + + @Test("A database the keyspace does not list holds no keys") + func unlistedDatabaseIsEmpty() async throws { + let channel = StubRedisChannel([.string("# Keyspace\r\ndb0:keys=5\r\n")]) + #expect(try await channel.keyCount(inDatabase: 8) == 0) + } + + @Test("A declined count is unknown rather than zero") + func declinedIsUnknown() async throws { + let info = StubRedisChannel([.error("NOPERM User u has no permissions to run the 'info' command")]) + #expect(try await info.keyCount(inDatabase: 3) == nil) + + let dbsize = StubRedisChannel([.error("NOPERM User u has no permissions to run the 'dbsize' command")]) + #expect(try await dbsize.keyCount(inDatabase: 0) == nil) + } + + @Test("A busy server fails the count") + func busyFails() async throws { + let channel = StubRedisChannel([.error("BUSY Redis is busy running a script.")]) + await #expect(throws: RedisPluginError.self) { + try await channel.keyCount(inDatabase: 3) + } + } + + @Test("A cluster counts its one database and knows no other") + func cluster() async throws { + let only = StubRedisChannel([.integer(7)], supportsDatabaseSelection: false) + #expect(try await only.keyCount(inDatabase: 0) == 7) + + let other = StubRedisChannel([], supportsDatabaseSelection: false) + #expect(try await other.keyCount(inDatabase: 3) == nil) + #expect(other.sentCommands.isEmpty) + } +} + +@Suite("Redis session database - where the session is and where it belongs") +struct RedisSessionDatabaseTests { + @Test("A selection moves both, a visit moves only where the session is") + func selectedAndVisited() { + var database = RedisSessionDatabase(0) + database.visited(7) + #expect(database.current == 7) + #expect(database.home == 0) + #expect(database.databaseToMoveTo(visiting: nil) == 0) + #expect(database.databaseToMoveTo(visiting: 7) == nil) + #expect(database.databaseToMoveTo(visiting: 4) == 4) + + database.selected(3) + #expect(database.current == 3) + #expect(database.home == 3) + #expect(database.databaseToMoveTo(visiting: nil) == nil) + } +} + +@Suite("Redis command channel - a visit the app abandoned") +struct RedisAbandonedVisitTests { + /// A cancelled stream lets go of the driver before its return SELECT reaches the server, so + /// the next command could have run on the database the stream was reading. + @Test("A command outside a visit goes home before it runs") + func returnsHomeFirst() async throws { + let channel = StubRedisChannel([.status("OK"), .status("OK"), .string("v")]) + try await channel.visitDatabase(7) + let reply = try await channel.executeCommand(["GET", "k"]) + #expect(reply.stringValue == "v") + #expect(channel.sentCommands == [["SELECT", "7"], ["SELECT", "0"], ["GET", "k"]]) + #expect(channel.currentDatabase() == 0) + } + + @Test("A command that is part of the visit stays on the visited database") + func visitStaysAway() async throws { + let channel = StubRedisChannel([.status("OK"), .integer(3)]) + try await channel.visitDatabase(7) + let count = try await RedisDatabaseVisit.$database.withValue(7) { + try await channel.executeCommand(["DBSIZE"]).intValue + } + #expect(count == 3) + #expect(channel.sentCommands == [["SELECT", "7"], ["DBSIZE"]]) + } + + /// The health monitor's PING does not wait for the session gate, so it can go home in the + /// middle of a visit; the visit's next command has to go back before it reads. + @Test("A visit's command returns to the visited database after an outside command went home") + func visitReturnsAfterInterruption() async throws { + let channel = StubRedisChannel([.status("OK"), .status("OK"), .status("PONG"), .status("OK"), .integer(4)]) + try await channel.visitDatabase(7) + _ = try await channel.executeCommand(["PING"]) + let count = try await RedisDatabaseVisit.$database.withValue(7) { + try await channel.executeCommand(["DBSIZE"]).intValue + } + #expect(count == 4) + #expect(channel.sentCommands == [["SELECT", "7"], ["SELECT", "0"], ["PING"], ["SELECT", "7"], ["DBSIZE"]]) + } + + @Test("A read on the database a stale visit left the session on sends no SELECT") + func staleVisitMatchingIndex() async throws { + let channel = StubRedisChannel([.status("OK"), .integer(5)]) + try await channel.visitDatabase(7) + let count = try await channel.withDatabase(7) { try await channel.executeCommand(["DBSIZE"]).intValue } + #expect(count == 5) + #expect(channel.sentCommands == [["SELECT", "7"], ["DBSIZE"]]) + } + + @Test("A count for the database the session belongs on is exact even after a stale visit") + func keyCountAtHome() async throws { + let channel = StubRedisChannel([.status("OK"), .status("OK"), .integer(11)]) + try await channel.visitDatabase(7) + #expect(try await channel.keyCount(inDatabase: 0) == 11) + #expect(channel.sentCommands == [["SELECT", "7"], ["SELECT", "0"], ["DBSIZE"]]) + } +} + +@Suite("Redis grid writes - the database they belong to") +struct RedisWriteAddressingTests { + private static let writes: [RedisDatabaseTarget.Statement] = [ + (statement: "SET \"k\" \"v\"", parameters: []), + (statement: "DEL \"old\"", parameters: []), + ] + + @Test("Writes for the database the session belongs on are unchanged") + func sameDatabase() { + let addressed = RedisDatabaseTarget.addressing(Self.writes, toDatabase: 3, from: 3) + #expect(addressed.map(\.statement) == Self.writes.map(\.statement)) + } + + @Test("Writes for another database select it first and return afterwards") + func otherDatabase() { + let addressed = RedisDatabaseTarget.addressing(Self.writes, toDatabase: 3, from: 5) + #expect(addressed.map(\.statement) == ["SELECT 3", "SET \"k\" \"v\"", "DEL \"old\"", "SELECT 5"]) + } + + @Test("A table that names no database, or no writes, is left alone") + func nothingToAddress() { + #expect(RedisDatabaseTarget.addressing(Self.writes, toDatabase: nil, from: 5).count == 2) + #expect(RedisDatabaseTarget.addressing([], toDatabase: 3, from: 5).isEmpty) + } + + /// The SELECTs go through the parser a save runs every statement through. + @Test("Every addressing statement parses as a SELECT") + func selectsParse() throws { + let addressed = RedisDatabaseTarget.addressing(Self.writes, toDatabase: 3, from: 5) + guard case .select(let first) = try RedisCommandParser.parse(addressed[0].statement), + case .select(let last) = try RedisCommandParser.parse(addressed[3].statement) else { + Issue.record("Expected SELECT operations") + return + } + #expect(first == 3) + #expect(last == 5) + } +} diff --git a/TableProTests/Plugins/RedisKeyMetadataReadTests.swift b/TableProTests/Plugins/RedisKeyMetadataReadTests.swift new file mode 100644 index 0000000000..bf515f18b6 --- /dev/null +++ b/TableProTests/Plugins/RedisKeyMetadataReadTests.swift @@ -0,0 +1,258 @@ +// +// RedisKeyMetadataReadTests.swift +// TableProTests +// +// The key grid and the key tree read TYPE, TTL and a length and preview probe for every key +// they list, and read each reply without asking whether the server answered. A refusal came back +// as an ordinary error reply, so a key an ACL user may not read showed as type UNKNOWN, TTL -1 +// (no expiry) and an empty "{}" collection. The replies here are the ones redis-server 8.10.1 +// sends to users restricted to `~app:*`, without `+type`, without `@hash`, and write-only `%W~*`. +// + +import Foundation +import TableProPluginKit +import Testing + +private struct TransportFailure: Error, Equatable {} + +private let keyDenied = RedisReply.error("NOPERM No permissions to access a key") +private let typeDenied = RedisReply.error("NOPERM User notype has no permissions to run the 'type' command") + +@Suite("Redis metadata read - classifying one reply") +struct RedisMetadataReadAnswerTests { + static let declined: [RedisReply] = [ + keyDenied, + typeDenied, + .error("ERR unknown command 'TYPE', with args beginning with: 'k' "), + ] + + @Test("A key-level or command-level refusal is no answer", arguments: declined) + func declinedIsNil(reply: RedisReply) throws { + #expect(try RedisMetadataRead.answer(reply, to: "TYPE") == nil) + } + + static let surfaced: [String] = [ + "BUSY Redis is busy running a script. You can only call SCRIPT KILL or FUNCTION KILL.", + "LOADING Redis is loading the dataset in memory", + "WRONGTYPE Operation against a key holding the wrong kind of value", + ] + + @Test("Every other error throws, labelled with the command", arguments: surfaced) + func otherErrorsThrow(message: String) { + do { + _ = try RedisMetadataRead.answer(.error(message), to: "TYPE") + Issue.record("expected a throw") + } catch let error as RedisPluginError { + #expect(error.message == "TYPE: \(message)") + } catch { + Issue.record("unexpected \(error)") + } + } + + @Test("A queued acknowledgement throws rather than reading as a type") + func queuedThrows() { + #expect(throws: RedisQueuedCommand(command: "TYPE")) { + try RedisMetadataRead.answer(.status("QUEUED"), to: "TYPE") + } + } + + @Test("An answer passes through unchanged") + func answerPassesThrough() throws { + #expect(try RedisMetadataRead.answer(.status("hash"), to: "TYPE")?.stringValue == "hash") + #expect(try RedisMetadataRead.answer(.integer(-1), to: "TTL")?.intValue == -1) + } +} + +@Suite("Redis metadata reads - one pipeline") +struct RedisMetadataReadsPipelineTests { + @Test("A refusal stays in its own place and the keys around it answer") + func refusalStaysInPlace() async throws { + let channel = StubRedisChannel([.status("string"), keyDenied, .status("hash")]) + let answers = try await channel.runMetadataReads([["TYPE", "app:1"], ["TYPE", "other:1"], ["TYPE", "app:h"]]) + #expect(answers.map { $0?.stringValue } == ["string", nil, "hash"]) + #expect(channel.sentScopes == [.outsideBlock, .outsideBlock, .outsideBlock]) + } + + @Test("Nothing is sent for no commands") + func emptySendsNothing() async throws { + let channel = StubRedisChannel([]) + #expect(try await channel.runMetadataReads([]).isEmpty) + #expect(channel.sentCommands.isEmpty) + } + + @Test("An open MULTI block holds the reads back instead of queueing them") + func openBlockHoldsBack() async throws { + let channel = StubRedisChannel([]) + channel.observeOpenBlock() + await #expect(throws: RedisHeldBackCommand(command: "TYPE", held: .openBlock)) { + try await channel.runMetadataReads([["TYPE", "k"]]) + } + #expect(channel.sentCommands.isEmpty) + } +} + +@Suite("Redis key descriptions - TYPE and TTL") +struct RedisKeyDescriptionReadTests { + @Test("A key the user may not read has no type and no TTL, not UNKNOWN and -1") + func unreadableKeyIsUnknown() async throws { + let channel = StubRedisChannel([.status("hash"), .integer(500), keyDenied, keyDenied]) + let descriptions = try await channel.describeKeys(["app:h", "other:1"]) + + #expect(descriptions == [ + RedisKeyDescription(typeName: "hash", ttlSeconds: 500), + RedisKeyDescription(typeName: nil, ttlSeconds: nil), + ]) + #expect(descriptions[0].typeCell == .text("HASH")) + #expect(descriptions[0].ttlCell == .text("500")) + #expect(descriptions[1].typeCell == .null) + #expect(descriptions[1].ttlCell == .null) + #expect(channel.sentCommands == [["TYPE", "app:h"], ["TTL", "app:h"], ["TYPE", "other:1"], ["TTL", "other:1"]]) + } + + /// Measured: a user without `+type` is refused TYPE for every key and still answered TTL. + @Test("A refused TYPE leaves the TTL the server did give") + func typeAndTtlAreIndependent() async throws { + let channel = StubRedisChannel([typeDenied, .integer(1_000)]) + let descriptions = try await channel.describeKeys(["other:1"]) + #expect(descriptions == [RedisKeyDescription(typeName: nil, ttlSeconds: 1_000)]) + #expect(descriptions[0].kind == nil) + } + + @Test("A key with no expiry still reads -1") + func noExpiryIsMinusOne() async throws { + let channel = StubRedisChannel([.status("string"), .integer(-1)]) + let descriptions = try await channel.describeKeys(["app:1"]) + #expect(descriptions[0].ttlCell == .text("-1")) + #expect(descriptions[0].kind == .string) + } + + @Test("A busy server fails the page") + func busyThrows() async throws { + let channel = StubRedisChannel([.error("BUSY Redis is busy running a script."), .integer(-1)]) + do { + _ = try await channel.describeKeys(["app:1"]) + Issue.record("expected a throw") + } catch let error as RedisPluginError { + #expect(error.message == "TYPE: BUSY Redis is busy running a script.") + } + } + + @Test("A queued TYPE throws instead of reading as type QUEUED") + func queuedThrows() async throws { + let channel = StubRedisChannel([.status("QUEUED"), .status("QUEUED")]) + await #expect(throws: RedisQueuedCommand(command: "TYPE")) { + try await channel.describeKeys(["app:1"]) + } + } + + @Test("A dropped connection propagates untouched") + func transportFailurePropagates() async throws { + let channel = StubRedisChannel(outcomes: [.failure(TransportFailure())]) + await #expect(throws: TransportFailure()) { + try await channel.describeKeys(["app:1"]) + } + } + + @Test("No keys sends nothing") + func emptySendsNothing() async throws { + let channel = StubRedisChannel([]) + #expect(try await channel.describeKeys([]).isEmpty) + #expect(channel.sentCommands.isEmpty) + } +} + +@Suite("Redis key contents - length and preview") +struct RedisKeyContentsReadTests { + @Test("A key of unknown type gets no probe at all") + func unknownKindSendsNoProbe() async throws { + let channel = StubRedisChannel([]) + let contents = try await channel.readContents( + of: ["other:1"], + describedAs: [RedisKeyDescription(typeName: nil, ttlSeconds: nil)] + ) + #expect(contents.count == 1) + #expect(contents[0] == nil) + #expect(channel.sentCommands.isEmpty) + } + + /// Measured: a user without `@hash` reads TYPE `hash` and is refused both HLEN and HSCAN; a + /// write-only `%W~*` user is answered HLEN and refused HSCAN. The refused scan used to render + /// as `{}`, an empty hash. + @Test("A refused preview is no preview, not an empty collection") + func refusedPreviewIsNil() async throws { + let channel = StubRedisChannel([.integer(1), keyDenied]) + let contents = try await channel.readContents( + of: ["app:h"], + describedAs: [RedisKeyDescription(typeName: "hash", ttlSeconds: -1)] + ) + let hash = try #require(contents[0]) + #expect(hash.kind == .hash) + #expect(hash.length == 1) + #expect(hash.lengthCell == .text("1")) + #expect(hash.preview == nil) + #expect(channel.sentCommands == [["HLEN", "app:h"], ["HSCAN", "app:h", "0", "COUNT", "100"]]) + } + + @Test("A refused GET leaves the string length the server gave") + func refusedGetKeepsLength() async throws { + let channel = StubRedisChannel([.integer(1), keyDenied]) + let contents = try await channel.readContents( + of: ["app:1"], + describedAs: [RedisKeyDescription(typeName: "string", ttlSeconds: nil)] + ) + let string = try #require(contents[0]) + #expect(string.length == 1) + #expect(string.preview == nil) + } + + @Test("Probes line up with their keys when unknown keys sit between them") + func probesLineUpAroundUnknownKeys() async throws { + let channel = StubRedisChannel([.integer(3), .string("abc"), .integer(2), .array([.string("x"), .string("y")])]) + let contents = try await channel.readContents( + of: ["a", "b", "c"], + describedAs: [ + RedisKeyDescription(typeName: "string", ttlSeconds: -1), + RedisKeyDescription(typeName: nil, ttlSeconds: nil), + RedisKeyDescription(typeName: "list", ttlSeconds: -1), + ] + ) + #expect(contents.count == 3) + #expect(contents[0]?.length == 3) + #expect(contents[0]?.preview?.stringValue == "abc") + #expect(contents[1] == nil) + #expect(contents[2]?.kind == .list) + #expect(contents[2]?.length == 2) + #expect(contents[2]?.preview?.stringArrayValue == ["x", "y"]) + #expect(channel.sentCommands.map(\.first) == ["STRLEN", "GET", "LLEN", "LRANGE"]) + } + + @Test("A key whose type changed under the probe fails the page rather than showing wrong contents") + func wrongTypeThrows() async throws { + let channel = StubRedisChannel([ + .error("WRONGTYPE Operation against a key holding the wrong kind of value"), + .error("WRONGTYPE Operation against a key holding the wrong kind of value"), + ]) + await #expect(throws: RedisPluginError.self) { + try await channel.readContents( + of: ["app:h"], + describedAs: [RedisKeyDescription(typeName: "hash", ttlSeconds: -1)] + ) + } + } +} + +@Suite("Redis key type names") +struct RedisKeyTypeNamesTests { + @Test("A declined TYPE is nil and an answered one is its name") + func declinedIsNil() async throws { + let channel = StubRedisChannel([.status("string"), keyDenied]) + #expect(try await channel.keyTypeNames(["app:1", "other:1"]) == ["string", nil]) + } + + @Test("No keys sends nothing") + func emptySendsNothing() async throws { + let channel = StubRedisChannel([]) + #expect(try await channel.keyTypeNames([]).isEmpty) + #expect(channel.sentCommands.isEmpty) + } +} diff --git a/TableProTests/Plugins/RedisMultiShardPlannerTests.swift b/TableProTests/Plugins/RedisMultiShardPlannerTests.swift index 4a5de36a03..3bccf76cf2 100644 --- a/TableProTests/Plugins/RedisMultiShardPlannerTests.swift +++ b/TableProTests/Plugins/RedisMultiShardPlannerTests.swift @@ -162,4 +162,19 @@ struct RedisMultiShardPlannerScatterTests { ) #expect(combined.errorMessage == "NOPERM") } + + @Test("A slot group queued by an open block is reported as queued, not scattered as nils") + func surfacesQueuedGroup() throws { + let arguments = args("MGET", "a1", "b1") + let commandSpec = spec(first: 1, last: -1, step: 1) + let groups = try #require( + RedisMultiShardPlanner.split(arguments: arguments, spec: commandSpec, slotOf: slotOf) + ) + let combined = RedisMultiShardPlanner.scatterInKeyOrder( + groups: groups, + replies: [.array([.string("valueA1")]), .status("QUEUED")], + keyIndices: commandSpec.keyIndices(forArgumentCount: arguments.count) + ) + #expect(combined.isQueued) + } } diff --git a/TableProTests/Plugins/RedisQueuedReplyTests.swift b/TableProTests/Plugins/RedisQueuedReplyTests.swift index f900e79705..632a31df3c 100644 --- a/TableProTests/Plugins/RedisQueuedReplyTests.swift +++ b/TableProTests/Plugins/RedisQueuedReplyTests.swift @@ -12,39 +12,6 @@ import Foundation import TableProPluginKit import Testing -/// Driven by one task at a time, so the replies are handed out in order with no synchronisation. -private final class StubRedisChannel: RedisCommandChannel, @unchecked Sendable { - private var queuedReplies: [RedisReply] - private(set) var sentCommands: [[String]] = [] - - init(_ replies: [RedisReply]) { - queuedReplies = replies - } - - var isConnected: Bool { true } - - func connect(reportingStage report: @escaping ConnectionStageReporter) async throws {} - func disconnect() {} - func cancelCurrentQuery() {} - func serverVersion() -> String? { "8.10.1" } - func currentDatabase() -> Int { 0 } - func selectDatabase(_ index: Int) async throws {} - - func executeCommand(_ args: [Data]) async throws -> RedisReply { - sentCommands.append(args.map { String(data: $0, encoding: .utf8) ?? "" }) - guard !queuedReplies.isEmpty else { return .null } - return queuedReplies.removeFirst() - } - - func executePipeline(_ commands: [[Data]]) async throws -> [RedisReply] { - var replies: [RedisReply] = [] - for command in commands { - replies.append(try await executeCommand(command)) - } - return replies - } -} - @Suite("Redis reply - a queued acknowledgement is not an answer") struct RedisQueuedReplyShapeTests { @Test("A +QUEUED simple string is the acknowledgement") diff --git a/TableProTests/Plugins/RedisSessionFootprintTests.swift b/TableProTests/Plugins/RedisSessionFootprintTests.swift new file mode 100644 index 0000000000..945131b165 --- /dev/null +++ b/TableProTests/Plugins/RedisSessionFootprintTests.swift @@ -0,0 +1,258 @@ +// +// RedisSessionFootprintTests.swift +// TableProTests +// +// Every command on a Redis connection shares one server session, so the sidebar's reads, the +// key tree's scans and the health monitor's PING used to join a MULTI block the user left open: +// an allowed one added its reply to EXEC, a refused one aborted the block with EXECABORT, and a +// reconnect replayed into a new session that no longer had the block at all. Each transition +// here is a reply measured on Redis 8.10.1. +// + +import Foundation +import TableProPluginKit +import Testing + +private struct Step: Sendable { + let command: String + let reply: RedisReply +} + +private func footprint(after steps: [Step]) -> RedisSessionFootprint { + var footprint = RedisSessionFootprint() + for step in steps { + _ = footprint.observe(command: step.command, reply: step.reply) + } + return footprint +} + +private let multi = Step(command: "MULTI", reply: .status("OK")) +private let queuedSet = Step(command: "SET", reply: .status("QUEUED")) +private let watch = Step(command: "WATCH", reply: .status("OK")) + +@Suite("Redis session footprint - what a reply leaves on the session") +struct RedisSessionFootprintTests { + @Test("MULTI opens a block, and a queued command confirms one") + func multiOpensBlock() { + #expect(footprint(after: [multi]).hasOpenBlock) + #expect(footprint(after: [queuedSet]).hasOpenBlock) + } + + @Test("A refused or nested MULTI leaves the block as it was") + func refusedMultiChangesNothing() { + let refused = Step(command: "MULTI", reply: .error("NOPERM User u has no permissions to run the 'multi' command")) + #expect(!footprint(after: [refused]).hasOpenBlock) + + let nested = Step(command: "multi", reply: .error("ERR MULTI calls can not be nested")) + #expect(footprint(after: [multi, nested]).hasOpenBlock) + } + + static let execReplies: [RedisReply] = [ + .array([.status("OK")]), + .array([]), + .null, + .error("EXECABORT Transaction discarded because of previous errors."), + ] + + @Test("EXEC ends the block and every WATCH whatever it answers", arguments: execReplies) + func execEndsBlock(reply: RedisReply) { + let ended = footprint(after: [watch, multi, queuedSet, Step(command: "EXEC", reply: reply)]) + #expect(!ended.hasOpenBlock) + #expect(!ended.isWatching) + } + + @Test("EXEC with no block open is refused and keeps the WATCH") + func execWithoutBlockKeepsWatch() { + let refused = Step(command: "EXEC", reply: .error("ERR EXEC without MULTI")) + let after = footprint(after: [watch, refused]) + #expect(after.isWatching) + #expect(after.heldState == .watchedKeys) + } + + @Test("DISCARD ends the block and every WATCH, a refused one changes nothing") + func discard() { + let ended = footprint(after: [watch, multi, Step(command: "DISCARD", reply: .status("OK"))]) + #expect(!ended.hasOpenBlock) + #expect(!ended.isWatching) + + let refused = Step(command: "DISCARD", reply: .error("NOPERM User u has no permissions to run the 'discard' command")) + #expect(footprint(after: [multi, refused]).hasOpenBlock) + } + + @Test("RESET ends both and moves the session to database 0") + func reset() { + var session = footprint(after: [watch, multi]) + let movedTo = session.observe(command: "RESET", reply: .status("RESET")) + #expect(movedTo == 0) + #expect(session.heldState == nil) + } + + @Test("WATCH and UNWATCH follow the server's answer") + func watchAndUnwatch() { + #expect(footprint(after: [watch]).isWatching) + #expect(!footprint(after: [watch, Step(command: "UNWATCH", reply: .status("OK"))]).isWatching) + + let insideBlock = Step(command: "WATCH", reply: .error("ERR WATCH inside MULTI is not allowed")) + let after = footprint(after: [multi, insideBlock]) + #expect(after.hasOpenBlock) + #expect(!after.isWatching) + } + + /// Inside a block every other command answers `QUEUED` or an error, so an ordinary answer + /// means there is no block, whatever the footprint believed. + @Test("An ordinary answer means no block is open") + func ordinaryAnswerClosesBlock() { + #expect(!footprint(after: [multi, Step(command: "GET", reply: .string("v"))]).hasOpenBlock) + #expect(footprint(after: [multi, Step(command: "FOO", reply: .error("ERR unknown command 'FOO'"))]).hasOpenBlock) + } + + @Test("A SELECT queued in a block moves the session only when EXEC runs it") + func queuedSelect() { + var session = footprint(after: [multi]) + session.queueDatabase(3) + #expect(session.observe(command: "EXEC", reply: .array([.status("OK")])) == 3) + + var discarded = footprint(after: [multi]) + discarded.queueDatabase(3) + #expect(discarded.observe(command: "DISCARD", reply: .status("OK")) == nil) + } +} + +@Suite("Redis session footprint - which commands may be sent") +struct RedisSessionFootprintAdmissionTests { + @Test("A clean session holds nothing back") + func cleanSession() { + let session = RedisSessionFootprint() + #expect(session.heldBack(.session) == nil) + #expect(session.heldBack(.outsideBlock) == nil) + #expect(session.heldBack(.cleanSession) == nil) + } + + @Test("An open block holds back the app's reads and its transaction, never the user") + func openBlock() { + let session = footprint(after: [multi]) + #expect(session.heldBack(.session) == nil) + #expect(session.heldBack(.outsideBlock) == .openBlock) + #expect(session.heldBack(.cleanSession) == .openBlock) + } + + /// A read cannot disturb a WATCH, but the app's own MULTI and EXEC would run under the user's + /// watched keys and report a discarded save as a success. + @Test("Watched keys hold back only the app's transaction") + func watchedKeys() { + let session = footprint(after: [watch]) + #expect(session.heldBack(.session) == nil) + #expect(session.heldBack(.outsideBlock) == nil) + #expect(session.heldBack(.cleanSession) == .watchedKeys) + } + + @Test("A lost block is latched once and reported once") + func lossIsReportedOnce() { + var session = footprint(after: [multi, queuedSet]) + session.sessionEnded() + #expect(session.heldState == nil) + #expect(session.takePendingLoss() == .openBlock) + #expect(session.takePendingLoss() == nil) + } + + @Test("An open block outranks a WATCH when the session ends") + func blockOutranksWatch() { + var session = footprint(after: [watch, multi]) + session.sessionEnded() + #expect(session.pendingLoss == .openBlock) + } + + @Test("A session holding nothing latches nothing") + func cleanSessionLatchesNothing() { + var session = RedisSessionFootprint() + session.sessionEnded() + #expect(session.pendingLoss == nil) + } + + @Test("A loss handed over from a replaced connection is kept, and not overwritten") + func adoptLoss() { + var session = RedisSessionFootprint() + session.adoptLoss(.watchedKeys) + session.adoptLoss(.openBlock) + #expect(session.pendingLoss == .watchedKeys) + session.adoptLoss(nil) + #expect(session.pendingLoss == .watchedKeys) + } +} + +@Suite("Redis session footprint - the errors the user reads") +struct RedisSessionFootprintErrorTests { + @Test("A held-back command names itself and what held it back") + func heldBackMessages() { + let block = RedisHeldBackCommand(command: "config", held: .openBlock) + let watched = RedisHeldBackCommand(command: "MULTI", held: .watchedKeys) + #expect(block.pluginErrorMessage.contains("CONFIG")) + #expect(watched.pluginErrorMessage.contains("MULTI")) + #expect(block.pluginErrorMessage != watched.pluginErrorMessage) + #expect(block.pluginErrorDetail != watched.pluginErrorDetail) + #expect(!RedisHeldBackCommand(command: "", held: .openBlock).pluginErrorMessage.hasPrefix(" ")) + } + + @Test("A lost block, a lost EXEC and a lost WATCH each say something different") + func lossMessages() { + let messages = [ + RedisSessionStateLost(held: .openBlock, outcomeUnknown: false), + RedisSessionStateLost(held: .openBlock, outcomeUnknown: true), + RedisSessionStateLost(held: .watchedKeys, outcomeUnknown: false), + ].map(\.pluginErrorMessage) + #expect(Set(messages).count == 3) + } +} + +@Suite("Redis command channel - an open block and the app's own commands") +struct RedisCommandChannelOpenBlockTests { + @Test("The database listing is held back from an open block and sends nothing") + func listingHeldBack() async throws { + let channel = StubRedisChannel([]) + channel.observeOpenBlock() + await #expect(throws: RedisHeldBackCommand(command: "CONFIG", held: .openBlock)) { + try await channel.databaseListing(includingKeyCounts: true) + } + #expect(channel.sentCommands.isEmpty) + } + + @Test("The database listing's reads go out as the app's own") + func listingScopes() async throws { + let channel = StubRedisChannel([.array([.string("databases"), .string("16")]), .string("# Keyspace\r\n")]) + _ = try await channel.databaseListing(includingKeyCounts: true) + #expect(channel.sentScopes == [.outsideBlock, .outsideBlock]) + } + + @Test("A command the user types still goes into their block") + func userCommandJoinsBlock() async throws { + let channel = StubRedisChannel([.status("QUEUED")]) + channel.observeOpenBlock() + let reply = try await channel.executeCommand(["SET", "k", "v"]) + #expect(reply.isQueued) + #expect(channel.sentScopes == [.session]) + } + + @Test("The health probe sends nothing while a block is open and reports healthy") + func probeHeldBack() async throws { + let channel = StubRedisChannel([]) + channel.observeOpenBlock() + try await channel.probeHealth() + #expect(channel.sentCommands.isEmpty) + } + + @Test("The health probe still runs while keys are watched") + func probeRunsWhileWatching() async throws { + let channel = StubRedisChannel([.status("PONG")]) + channel.observeWatch() + try await channel.probeHealth() + #expect(channel.sentCommands == [["PING"]]) + } + + @Test("The health probe fails only a session with no identity") + func probeOutcomes() async throws { + try await StubRedisChannel([.error("NOPERM User u has no permissions to run the 'ping' command")]).probeHealth() + await #expect(throws: RedisPluginError.self) { + try await StubRedisChannel([.error("NOAUTH Authentication required.")]).probeHealth() + } + } +} diff --git a/TableProTests/Plugins/RedisStatementGeneratorTests.swift b/TableProTests/Plugins/RedisStatementGeneratorTests.swift index 6a24538fb7..b6c4fa3411 100644 --- a/TableProTests/Plugins/RedisStatementGeneratorTests.swift +++ b/TableProTests/Plugins/RedisStatementGeneratorTests.swift @@ -800,6 +800,54 @@ struct RedisStatementGeneratorBrowseColumnTests { #expect(results.isEmpty) } + /// The Type cell is NULL when the server would not say, and `SET` over a hash the user cannot + /// see replaces the hash. + @Test("A value update on a key of unknown type is skipped") + func unknownTypeValueUpdateSkipped() { + let gen = RedisStatementGenerator(namespaceName: "", columns: Self.browseColumns) + + let change = PluginRowChange( + rowIndex: 0, + type: .update, + cellChanges: [ + (columnIndex: 4, columnName: "Value", oldValue: nil, newValue: "new") + ], + originalRow: ["other:h", nil, nil, nil, nil] + ) + + let results = gen.generateStatements( + from: [change], + insertedRowData: [:], + deletedRowIndices: [], + insertedRowIndices: [] + ) + + #expect(results.isEmpty) + } + + @Test("A key of unknown type still takes a TTL change") + func unknownTypeTtlUpdateApplies() { + let gen = RedisStatementGenerator(namespaceName: "", columns: Self.browseColumns) + + let change = PluginRowChange( + rowIndex: 0, + type: .update, + cellChanges: [ + (columnIndex: 2, columnName: "TTL", oldValue: nil, newValue: "60") + ], + originalRow: ["other:h", nil, nil, nil, nil] + ) + + let results = gen.generateStatements( + from: [change], + insertedRowData: [:], + deletedRowIndices: [], + insertedRowIndices: [] + ) + + #expect(results.map(\.statement) == ["EXPIRE other:h 60"]) + } + @Test("An insert reads its cells by name, not by position") func insertResolvesColumnsByName() { let gen = RedisStatementGenerator(namespaceName: "", columns: Self.browseColumns) diff --git a/TableProTests/ViewModels/RedisKeyTreeViewModelLoadTests.swift b/TableProTests/ViewModels/RedisKeyTreeViewModelLoadTests.swift new file mode 100644 index 0000000000..a7af3b2fa3 --- /dev/null +++ b/TableProTests/ViewModels/RedisKeyTreeViewModelLoadTests.swift @@ -0,0 +1,312 @@ +// +// RedisKeyTreeViewModelLoadTests.swift +// TableProTests +// +// The key tree used to catch every load failure, log it and clear itself, so a refused `SCAN` or a +// `MULTI` block left open on the session read as a database with no keys. It also ran each load as +// an unowned task, so a load for a database the user had already left could land over the one they +// had moved to. +// + +import Foundation +@testable import TablePro +import TableProPluginKit +import Testing + +private actor KeyTreeLatch { + private var waiters: [CheckedContinuation] = [] + private var isOpen = false + + func open() { + guard !isOpen else { return } + isOpen = true + let pending = waiters + waiters = [] + for waiter in pending { + waiter.resume() + } + } + + func wait() async { + guard !isOpen else { return } + await withCheckedContinuation { waiters.append($0) } + } +} + +private enum KeyTreeReply: Sendable { + case keys([String]) + case failure(any Error) +} + +private struct UnscriptedDatabase: Error {} + +/// The statements the stub driver was asked to run. The driver runs off the main actor, so the log +/// is locked rather than isolated. +private final class ExecutedQueryLog: @unchecked Sendable { + private let lock = NSLock() + private var queries: [String] = [] + + var all: [String] { + lock.withLock { queries } + } + + func record(_ query: String) { + lock.withLock { queries.append(query) } + } +} + +/// Answers each database with whatever the test scripted for it at the moment the read runs, and can +/// hold a database's read until the test releases it, the way a driver blocked on the wire holds one. +@MainActor +private final class ScriptedKeyTreeProvider: ScopedMetadataProviding { + private struct Hold { + let reached: KeyTreeLatch + let release: KeyTreeLatch + } + + private let connection: DatabaseConnection + private let log = ExecutedQueryLog() + private var replies: [String: KeyTreeReply] = [:] + private var holds: [String: Hold] = [:] + private(set) var requestedDatabases: [String] = [] + + init(connection: DatabaseConnection) { + self.connection = connection + } + + var executedQueries: [String] { + log.all + } + + func answer(_ database: String, with reply: KeyTreeReply) { + replies[database] = reply + } + + /// The next read of `database` waits for `release`, after opening `reached`. + func hold(_ database: String) -> (reached: KeyTreeLatch, release: KeyTreeLatch) { + let hold = Hold(reached: KeyTreeLatch(), release: KeyTreeLatch()) + holds[database] = hold + return (hold.reached, hold.release) + } + + func withMetadataDriver( + scope: DatabaseScope, + workload: MetadataConnectionPool.Workload, + _ body: @Sendable @escaping (DatabaseDriver) async throws -> T + ) async throws -> T { + requestedDatabases.append(scope.database) + if let hold = holds.removeValue(forKey: scope.database) { + await hold.reached.open() + await hold.release.wait() + } + switch replies[scope.database] { + case .keys(let keys): + let driver = KeyTreePluginDriver(keys: keys, log: log) + return try await body(PluginDriverAdapter(connection: connection, pluginDriver: driver)) + case .failure(let error): + throw error + case nil: + throw UnscriptedDatabase() + } + } + + func browseScope(for connectionId: UUID) -> DatabaseScope? { + nil + } +} + +@Suite("Redis key tree load state", .serialized) +@MainActor +struct RedisKeyTreeViewModelLoadTests { + private let connection = TestFixtures.makeConnection(database: "0", type: .redis) + + private func makeViewModel() -> (RedisKeyTreeViewModel, ScriptedKeyTreeProvider) { + let provider = ScriptedKeyTreeProvider(connection: connection) + return (RedisKeyTreeViewModel(metadataProvider: provider), provider) + } + + private func load(_ viewModel: RedisKeyTreeViewModel, database: String) async { + await viewModel.loadKeys(connectionId: connection.id, database: database, separator: ":").value + } + + @Test("A load commits the keys the server listed, for the database it asked about") + func loadCommitsTheKeys() async throws { + let (viewModel, provider) = makeViewModel() + provider.answer("3", with: .keys(["user:1", "user:2", "counter"])) + + await load(viewModel, database: "3") + + let content = try #require(viewModel.state.value) + #expect(content.database == "3") + #expect(content.keys.map(\.key) == ["user:1", "user:2", "counter"]) + #expect(content.rootNodes.count == 2) + #expect(!content.isTruncated) + #expect(provider.requestedDatabases == ["3"]) + #expect(provider.executedQueries == ["KEYTREE LIMIT \(RedisKeyTreeViewModel.maxKeys)"]) + } + + /// Each of these used to become an empty tree, which the section drew as "No items". + @Test("A refused scan, an open MULTI block and a lost session each commit their failure") + func failuresCommitTheirMessage() async { + let failures: [any Error] = [ + RedisPluginError(code: 0, message: "NOPERM User noscan has no permissions to run the 'scan' command"), + RedisQueuedCommand(command: "SCAN"), + DatabaseError.notConnected + ] + for error in failures { + let (viewModel, provider) = makeViewModel() + provider.answer("0", with: .failure(error)) + + await load(viewModel, database: "0") + + #expect(viewModel.state.erased == .failed(error.localizedDescription), "\(error)") + } + } + + @Test("Moving to another database shows the loading row at once") + func movingShowsTheLoadingRow() async { + let (viewModel, provider) = makeViewModel() + provider.answer("0", with: .keys(["a"])) + provider.answer("2", with: .keys(["b"])) + await load(viewModel, database: "0") + + let move = viewModel.loadKeys(connectionId: connection.id, database: "2", separator: ":") + #expect(viewModel.state.erased == .loading) + await move.value + + #expect(viewModel.state.value?.database == "2") + } + + /// A driver blocked on the wire finishes after the user has moved on, and cancelling its task + /// cannot stop it. Whatever it comes back with belongs to a database nobody is looking at. + @Test("A load superseded by another database commits nothing, whatever it returns") + func supersededLoadCommitsNothing() async { + let outcomes: [KeyTreeReply] = [ + .keys(["stale:1"]), + .failure(RedisPluginError(code: 0, message: "ERR stale")), + .failure(CancellationError()) + ] + for outcome in outcomes { + let (viewModel, provider) = makeViewModel() + let (reached, release) = provider.hold("1") + provider.answer("2", with: .keys(["fresh:1"])) + + let stale = viewModel.loadKeys(connectionId: connection.id, database: "1", separator: ":") + await reached.wait() + await load(viewModel, database: "2") + #expect(viewModel.state.value?.database == "2") + + provider.answer("1", with: outcome) + await release.open() + await stale.value + + #expect(viewModel.state.value?.database == "2", "\(outcome)") + #expect(viewModel.state.value?.keys.map(\.key) == ["fresh:1"], "\(outcome)") + } + } + + /// A drained driver gate cancels the read it was holding. A spinner left behind would have + /// nothing coming to replace it. + @Test("A cancelled load on another database settles to nothing rather than a spinner") + func cancelledMoveSettlesToIdle() async { + let (viewModel, provider) = makeViewModel() + provider.answer("0", with: .keys(["a"])) + provider.answer("1", with: .failure(CancellationError())) + await load(viewModel, database: "0") + + await load(viewModel, database: "1") + + #expect(viewModel.state.erased == .idle) + } + + @Test("A cancelled refresh keeps the rows it was refreshing") + func cancelledRefreshKeepsTheRows() async { + let (viewModel, provider) = makeViewModel() + provider.answer("0", with: .keys(["a"])) + await load(viewModel, database: "0") + + provider.answer("0", with: .failure(CancellationError())) + await viewModel.reload()?.value + + #expect(viewModel.state.value?.keys.map(\.key) == ["a"]) + } + + /// A refresh never clears the cache it is refreshing: the rows stay while it runs and survive + /// a refresh that fails. + @Test("Refresh re-reads the same database and keeps its rows while it runs and when it fails") + func refreshKeepsItsRows() async throws { + let (viewModel, provider) = makeViewModel() + provider.answer("0", with: .keys(["a"])) + await load(viewModel, database: "0") + + provider.answer("0", with: .failure(RedisPluginError(code: 0, message: "ERR refused"))) + let refresh = try #require(viewModel.reload()) + #expect(viewModel.state.value?.database == "0") + await refresh.value + + #expect(viewModel.state.value?.keys.map(\.key) == ["a"]) + #expect(provider.requestedDatabases == ["0", "0"]) + } + + @Test("Refresh after a failed load retries it and shows the keys it gets") + func refreshRecoversAFailedLoad() async throws { + let (viewModel, provider) = makeViewModel() + provider.answer("0", with: .failure(RedisQueuedCommand(command: "SCAN"))) + await load(viewModel, database: "0") + #expect(viewModel.state.value == nil) + + provider.answer("0", with: .keys(["a", "b"])) + let refresh = try #require(viewModel.reload()) + #expect(viewModel.state.erased == .loading) + await refresh.value + + #expect(viewModel.state.value?.keys.map(\.key) == ["a", "b"]) + } + + @Test("Refresh before anything was loaded has nothing to reload") + func refreshBeforeAnyLoadDoesNothing() { + let (viewModel, provider) = makeViewModel() + + #expect(viewModel.reload() == nil) + #expect(viewModel.state.erased == .idle) + #expect(provider.requestedDatabases.isEmpty) + } +} + +private final class KeyTreePluginDriver: PluginDatabaseDriver, @unchecked Sendable { + private let keys: [String] + private let log: ExecutedQueryLog + + init(keys: [String], log: ExecutedQueryLog) { + self.keys = keys + self.log = log + } + + func execute(query: String) async throws -> PluginQueryResult { + log.record(query) + return PluginQueryResult( + columns: ["Key", "Type"], + columnTypeNames: ["TEXT", "TEXT"], + rows: keys.map { [.text($0), .text("string")] }, + rowsAffected: 0, + executionTime: 0 + ) + } + + func connect() async throws {} + func disconnect() {} + func ping() async throws {} + func fetchTables(schema: String?) async throws -> [PluginTableInfo] { [] } + func fetchColumns(table: String, schema: String?) async throws -> [PluginColumnInfo] { [] } + func fetchIndexes(table: String, schema: String?) async throws -> [PluginIndexInfo] { [] } + func fetchForeignKeys(table: String, schema: String?) async throws -> [PluginForeignKeyInfo] { [] } + func fetchTableDDL(table: String, schema: String?) async throws -> String { "" } + func fetchViewDefinition(view: String, schema: String?) async throws -> String { "" } + func fetchTableMetadata(table: String, schema: String?) async throws -> PluginTableMetadata { + PluginTableMetadata(tableName: table) + } + func fetchDatabases() async throws -> [String] { [] } + func fetchDatabaseMetadata(_ database: String) async throws -> PluginDatabaseMetadata { + PluginDatabaseMetadata(name: database) + } +} diff --git a/TableProTests/Views/Main/RedisDatabaseSelectionGateTests.swift b/TableProTests/Views/Main/RedisDatabaseSelectionGateTests.swift index b3165eaa08..c2b94e0804 100644 --- a/TableProTests/Views/Main/RedisDatabaseSelectionGateTests.swift +++ b/TableProTests/Views/Main/RedisDatabaseSelectionGateTests.swift @@ -188,6 +188,81 @@ struct RedisDatabaseSelectionGateTests { #expect(original.switchedDatabases.isEmpty) } + @Test("A database the server refuses is reported on the tab the click retargeted") + func refusedSelectionIsReportedOnTheTab() async throws { + let (connection, recorder) = makeSession() + defer { cleanUp(connection.id) } + recorder.refuseSelections(with: RefusedSelection()) + let coordinator = makeCoordinator(for: connection) + defer { coordinator.teardown() } + + coordinator.openTableTab("db3") + await coordinator.redisDatabaseSwitchTask?.value + + let tab = try #require(coordinator.tabManager.selectedTab) + #expect(tab.execution.errorMessage == RefusedSelection.message) + #expect(tab.execution.errorQuery == nil) + #expect(tab.execution.lastExecutedAt == nil) + #expect(tab.pagination.isLoading == false) + #expect(recorder.executedQueries.isEmpty) + #expect(DatabaseManager.shared.session(for: connection.id)?.browseDatabase == "0") + } + + /// Changing tab does not cancel the selection, so the answer has to find the tab it was for + /// rather than whichever one is in front when the server replies. + @Test("A refusal that arrives after a tab change lands on the retargeted tab") + func refusalFindsItsOwnTab() async throws { + let (connection, recorder) = makeSession() + defer { cleanUp(connection.id) } + recorder.refuseSelections(with: RefusedSelection()) + let coordinator = makeCoordinator(for: connection) + defer { coordinator.teardown() } + let release = Latch() + let holder = await holdDriver(connection.id, until: release) + + coordinator.openTableTab("db3") + await waitForQueuedCallers(1, on: connection.id) + let retargetedTabId = try #require(coordinator.tabManager.selectedTabId) + coordinator.tabManager.addTab(initialQuery: "PING") + let frontTabId = try #require(coordinator.tabManager.selectedTabId) + #expect(frontTabId != retargetedTabId) + + release.open() + try await holder.value + await coordinator.redisDatabaseSwitchTask?.value + + let retargeted = try #require(coordinator.tabManager.tabs.first { $0.id == retargetedTabId }) + let front = try #require(coordinator.tabManager.tabs.first { $0.id == frontTabId }) + #expect(retargeted.execution.errorMessage == RefusedSelection.message) + #expect(front.execution.errorMessage == nil) + } + + /// The session moved, but the query belongs to the retargeted tab, which loads when it is + /// shown again. Running it on the tab in front would put another tab's query on this database. + @Test("A selection that lands after a tab change does not run the front tab's query") + func landedSelectionLeavesTheFrontTabAlone() async throws { + let (connection, recorder) = makeSession() + defer { cleanUp(connection.id) } + let coordinator = makeCoordinator(for: connection) + defer { coordinator.teardown() } + let release = Latch() + let holder = await holdDriver(connection.id, until: release) + + coordinator.openTableTab("db3") + await waitForQueuedCallers(1, on: connection.id) + let retargetedTabId = try #require(coordinator.tabManager.selectedTabId) + coordinator.tabManager.addTab(initialQuery: "PING") + + release.open() + try await holder.value + await coordinator.redisDatabaseSwitchTask?.value + + let retargeted = try #require(coordinator.tabManager.tabs.first { $0.id == retargetedTabId }) + #expect(recorder.switchedDatabases == ["3"]) + #expect(recorder.executedQueries.allSatisfy { $0.hasPrefix("KEYTREE") }) + #expect(retargeted.pagination.isLoading == false) + } + @Test("Loading the key tree waits for the driver") func keyTreeLoadWaitsForTheDriver() async throws { let (connection, recorder) = makeSession() @@ -196,28 +271,39 @@ struct RedisDatabaseSelectionGateTests { let holder = await holdDriver(connection.id, until: release) let viewModel = RedisKeyTreeViewModel() - let load = Task { @MainActor in - await viewModel.loadKeys(connectionId: connection.id, database: "2", separator: ":") - } + let load = viewModel.loadKeys(connectionId: connection.id, database: "2", separator: ":") await waitForQueuedCallers(1, on: connection.id) #expect(DatabaseManager.shared.sessionDriverGate.waiterCount(for: connection.id) == 1) #expect(recorder.executedQueries.isEmpty) + #expect(viewModel.state.erased == .loading) release.open() try await holder.value await load.value #expect(recorder.executedQueries == ["KEYTREE LIMIT \(RedisKeyTreeViewModel.maxKeys)"]) + #expect(viewModel.state.value?.database == "2") } } +private struct RefusedSelection: LocalizedError { + static let message = "ERR DB index is out of range" + + var errorDescription: String? { Self.message } +} + /// Records the calls that move or read the connection. The ping answers, because Redis declares a /// health monitor and a check before use would otherwise fail the session. private final class RecordingRedisPluginDriver: PluginDatabaseDriver, @unchecked Sendable { private let lock = NSLock() private var switched: [String] = [] private var executed: [String] = [] + private var selectionRefusal: Error? + + func refuseSelections(with error: Error) { + lock.withLock { selectionRefusal = error } + } var switchedDatabases: [String] { lock.withLock { switched } @@ -230,6 +316,8 @@ private final class RecordingRedisPluginDriver: PluginDatabaseDriver, @unchecked func ping() async throws {} func switchDatabase(to database: String) async throws { + let refusal = lock.withLock { selectionRefusal } + if let refusal { throw refusal } lock.withLock { switched.append(database) } } diff --git a/TableProTests/Views/Sidebar/DatabaseTreeMenuSpecTests.swift b/TableProTests/Views/Sidebar/DatabaseTreeMenuSpecTests.swift index 6c35f9884f..f937467bb9 100644 --- a/TableProTests/Views/Sidebar/DatabaseTreeMenuSpecTests.swift +++ b/TableProTests/Views/Sidebar/DatabaseTreeMenuSpecTests.swift @@ -842,6 +842,25 @@ struct DatabaseTreeMenuSpecTests { .status(.loading) ] + /// The Keys section's error row said what went wrong but left nothing to do about it, since the + /// tree only loaded again on a database switch. + @Test("The Keys section offers Refresh and nothing scoped to the connection") + func redisKeysSectionOffersRefresh() { + let issued = commands(DatabaseTreeMenuSpec.sections(for: context(clicked: .redisKeysSection))) + + #expect(issued == [.refreshRedisKeys]) + #expect(SidebarMenuCommand.refreshRedisKeys.shortcutAction == nil) + } + + @Test("A status row keeps the background menu") + func statusRowKeepsTheBackgroundMenu() { + let status = commands(DatabaseTreeMenuSpec.sections(for: context(clicked: .status(.error("NOPERM"))))) + let background = commands(DatabaseTreeMenuSpec.sections(for: context(clicked: nil))) + + #expect(status == background) + #expect(!status.contains(.refreshRedisKeys)) + } + @Test("Every menu produces at least one item, so none opens as an empty frame") func everyMenuHasContent() { let kinds: [DatabaseTreeNode.Kind?] = [ diff --git a/TableProUITests/ConnectionFormTransportUITests.swift b/TableProUITests/ConnectionFormTransportUITests.swift index 52eaf86f14..5fc0e5fe2b 100644 --- a/TableProUITests/ConnectionFormTransportUITests.swift +++ b/TableProUITests/ConnectionFormTransportUITests.swift @@ -13,7 +13,7 @@ final class ConnectionFormTransportUITests: UITestCase { XCTAssertTrue(app.windows.firstMatch.waitToExist(timeout: 10)) let form = try openConnectionForm(for: "PostgreSQL", in: app) - selectTab("network", in: form) + selectConnectionFormTab("network", in: form) let picker = form.popUpButtons[transportPicker] XCTAssertTrue(picker.waitToExist(timeout: 10), "The Network tab should offer a Connect via picker") @@ -71,24 +71,6 @@ final class ConnectionFormTransportUITests: UITestCase { // MARK: - Helpers - /// The sections are a `NavigationSplitView` sidebar, so each row publishes as an outline row - /// rather than the radio button an `NSSegmentedControl` gave. Reached by the row's own - /// identifier, because a sidebar row's label is nested and does not answer a subscript by title. - /// - /// Not finding the row fails the test rather than skipping it: a section list the accessibility - /// tree cannot see is a section list VoiceOver cannot drive. - private func selectTab(_ tab: String, in form: XCUIElement) { - let row = form.descendants(matching: .any) - .matching(identifier: "connection-form-section-\(tab)") - .firstMatch - XCTAssertTrue( - row.waitToExist(timeout: 10), - "No sidebar row identified connection-form-section-\(tab)" - ) - XCTAssertTrue(waitUntilHittable(row, timeout: 10)) - row.click() - } - private func openConnectionForm(for type: String, in app: XCUIApplication) throws -> XCUIElement { let newConnection = app.menuBars.menuItems["New Connection…"] XCTAssertTrue(newConnection.waitToExist(timeout: 10)) diff --git a/TableProUITests/ConnectionTagEditorUITests.swift b/TableProUITests/ConnectionTagEditorUITests.swift index 3d311e1424..6d028d19eb 100644 --- a/TableProUITests/ConnectionTagEditorUITests.swift +++ b/TableProUITests/ConnectionTagEditorUITests.swift @@ -18,7 +18,7 @@ final class ConnectionTagEditorUITests: UITestCase { XCTAssertTrue(app.windows.firstMatch.waitToExist(timeout: 10)) let form = try openConnectionForm(for: "SQLite", in: app) - selectTab("appearance", in: form) + selectConnectionFormTab("appearance", in: form) let menu = form.descendants(matching: .any).matching(identifier: tagMenu).firstMatch XCTAssertTrue(menu.waitToExist(timeout: 10), "The Appearance tab should offer a Tags control") @@ -48,7 +48,7 @@ final class ConnectionTagEditorUITests: UITestCase { XCTAssertTrue(app.windows.firstMatch.waitToExist(timeout: 10)) let form = try openConnectionForm(for: "SQLite", in: app) - selectTab("appearance", in: form) + selectConnectionFormTab("appearance", in: form) let menu = form.descendants(matching: .any).matching(identifier: tagMenu).firstMatch XCTAssertTrue(menu.waitToExist(timeout: 10)) @@ -62,18 +62,6 @@ final class ConnectionTagEditorUITests: UITestCase { app.typeKey(.escape, modifierFlags: []) } - private func selectTab(_ tab: String, in form: XCUIElement) { - let row = form.descendants(matching: .any) - .matching(identifier: "connection-form-section-\(tab)") - .firstMatch - XCTAssertTrue( - row.waitToExist(timeout: 10), - "No sidebar row identified connection-form-section-\(tab)" - ) - XCTAssertTrue(waitUntilHittable(row, timeout: 10)) - row.click() - } - /// Scoped to the sheet, not the app: the welcome window behind it owns a `sidebar-filter` /// search field that `app.searchFields.firstMatch` reaches first. private func openConnectionForm(for type: String, in app: XCUIApplication) throws -> XCUIElement { diff --git a/TableProUITests/RedisConnectionModeUITests.swift b/TableProUITests/RedisConnectionModeUITests.swift index a52cc3bad2..f5af5ac035 100644 --- a/TableProUITests/RedisConnectionModeUITests.swift +++ b/TableProUITests/RedisConnectionModeUITests.swift @@ -12,6 +12,7 @@ final class RedisConnectionModeUITests: UITestCase { private let sentinelNodes = "connection-field-redisSentinelHosts" private let sentinelGroupName = "connection-field-redisSentinelMasterName" private let clusterNodes = "connection-field-redisClusterHosts" + private let databaseIndex = "connection-field-redisDatabase" func testSwitchingConnectionModeShowsOnlyThatModesFields() throws { let app = try launchApp() @@ -54,6 +55,51 @@ final class RedisConnectionModeUITests: UITestCase { XCTAssertFalse(hasField(clusterNodes, in: window)) } + /// A server can hold far more than the 16 databases Redis starts with, so the index is typed + /// as well as stepped, and nothing typed can leave the range a server can be configured for. + func testDatabaseIndexTakesATypedIndexAndStepsFromIt() throws { + let app = try launchApp() + XCTAssertTrue(app.windows.firstMatch.waitToExist(timeout: 10)) + + let window = try openRedisConnectionForm(in: app) + selectConnectionFormTab("options", in: window) + + let control = window.descendants(matching: .any).matching(identifier: databaseIndex).firstMatch + XCTAssertTrue(control.waitToExist(timeout: 10), "The Options tab should offer a Database Index field") + let field = control.textFields.firstMatch + XCTAssertTrue(field.waitToExist(timeout: 5), "The index should be typeable") + XCTAssertTrue(waitUntilHittable(field, timeout: 10)) + XCTAssertEqual(field.value as? String, "0") + + replaceText(in: field, with: "20a") + XCTAssertTrue( + waitForPredicate(timeout: 5) { (field.value as? String) == "20" }, + "A typed index past 15 should stay, with anything that is not a digit dropped" + ) + + let stepper = control.steppers.firstMatch + XCTAssertTrue(stepper.waitToExist(timeout: 5), "The field should be paired with a stepper") + let increment = stepper.incrementArrows.firstMatch + XCTAssertTrue(waitUntilHittable(increment, timeout: 5)) + increment.click() + XCTAssertTrue( + waitForPredicate(timeout: 5) { (field.value as? String) == "21" }, + "The stepper should step from the typed index" + ) + + replaceText(in: field, with: "99999999999") + XCTAssertTrue( + waitForPredicate(timeout: 5) { (field.value as? String) == "2147483646" }, + "An index no server can hold should be capped at the highest one a server can" + ) + } + + private func replaceText(in field: XCUIElement, with text: String) { + field.click() + field.typeKey("a", modifierFlags: .command) + field.typeText(text) + } + /// A host list is a whole subtree rather than one control, so its identifier lands on every /// element inside it and any one of them proves the list is on screen. private func hasField(_ identifier: String, in window: XCUIElement) -> Bool { diff --git a/TableProUITests/Support/UITestCase.swift b/TableProUITests/Support/UITestCase.swift index 6b860e3b12..d6c768f53b 100644 --- a/TableProUITests/Support/UITestCase.swift +++ b/TableProUITests/Support/UITestCase.swift @@ -333,6 +333,23 @@ internal class UITestCase: XCTestCase { return matches.allElementsBoundByIndex.first { $0.isHittable } ?? matches.firstMatch } + /// Reached by the row's own identifier, because a section row's label is nested and does not + /// answer a subscript by title. + /// + /// Not finding the row fails the test rather than skipping it: a section list the accessibility + /// tree cannot see is a section list VoiceOver cannot drive. + internal func selectConnectionFormTab(_ tab: String, in form: XCUIElement) { + let row = form.descendants(matching: .any) + .matching(identifier: "connection-form-section-\(tab)") + .firstMatch + XCTAssertTrue( + row.waitToExist(timeout: 10), + "No section row identified connection-form-section-\(tab)" + ) + XCTAssertTrue(waitUntilHittable(row, timeout: 10)) + row.click() + } + /// The app removes its own defaults domain as it terminates, which is the only point that /// reliably comes after `cfprefsd` has written it. This sweep is the backstop for a run that /// crashed or was killed before it got there, and it runs before the class's tests so a diff --git a/docs/connections/urls.mdx b/docs/connections/urls.mdx index 8ec370e575..c14c1f3652 100644 --- a/docs/connections/urls.mdx +++ b/docs/connections/urls.mdx @@ -192,7 +192,7 @@ The part after the host is the database name on most schemes. Seven read it diff | Scheme | The path is | |---|---| -| `redis://`, `rediss://` | The database index, 0 to 15 | +| `redis://`, `rediss://` | The database index, 0 when omitted | | `cassandra://`, `scylladb://` | The default keyspace. Omit it to connect with none | | `sqlite://`, `duckdb://`, `beancount://` | An absolute file path, so the URL carries three slashes | | `quack://` | The alias a remote DuckDB server attaches the database under. See [DuckDB](/databases/duckdb) | diff --git a/docs/databases/redis.mdx b/docs/databases/redis.mdx index 9bb17e5f81..1aa8c2946f 100644 --- a/docs/databases/redis.mdx +++ b/docs/databases/redis.mdx @@ -25,12 +25,12 @@ The sidebar splits keys into folders at every `:`, and each key gets a grid row | **Port** | `6379` | Standalone only | | **Username** | - | Redis 6 ACL user, sent as `AUTH username password`. Empty signs in as `default` | | **Password** | - | Empty for a server with no `requirepass`, and for a `nopass` ACL user | -| **Database Index** | `0` | 0-15 stepper, in the Advanced section | -| **Key Separator** | `:` | What the sidebar splits key names on (Advanced) | +| **Database Index** | `0` | Options tab. Type the index or use the stepper; connecting fails with the server's error if it has no such database | +| **Key Separator** | `:` | Options tab. What the sidebar splits key names on | No minimum server version. ACL users need Redis 6, a Sentinel ACL user needs 6.2, and Cluster mode reads routing tips from Redis 7 where they exist. -The sidebar then lists one entry per database, `db0` upward, counted from the server's own `CONFIG GET databases` (16 if it does not answer). Click one to browse it in place, which is also how the database index changes after connecting. +The sidebar then lists one entry per database, `db0` upward, counted from the server's own `CONFIG GET databases`. ElastiCache, Azure Cache for Redis and Memorystore refuse `CONFIG`, and so do ACL users without `config|get`; those servers are listed as 16 databases, or up to the highest one `INFO keyspace` names as holding keys. A key count left blank means the server would not answer `INFO` for your user. Click a database to browse it in place, which is also how the database index changes after connecting. ## Connection URL @@ -76,9 +76,9 @@ For a sharded [Redis Cluster](https://redis.io/docs/latest/operate/oss_and_stack |-------|-------| | **Cluster Seed Nodes** | One or more `host:port` entries. Any reachable member is enough, the rest are discovered. Port defaults to `6379` | -`DBSIZE` is summed across shards, browsing merges their keys into one tree, and `MGET`, `MSET`, `DEL`, `EXISTS`, `TOUCH` and `UNLINK` are split per shard and recombined. A `MOVED` re-points the slot and retries, an `ASK` retries against the importing node, and one command follows at most five redirects. What Cluster mode cannot do is under [Limitations](#limitations). +`DBSIZE` is summed across shards, browsing merges their keys into one tree, and `MGET`, `MSET`, `DEL`, `EXISTS`, `TOUCH` and `UNLINK` are split per shard and recombined. When one shard refuses its part, or cannot answer because it is loading or busy running a script, the command reports that shard's error instead of a partial total; a split `DEL` has already run on the shards that accepted it. A shard that denies `DBSIZE` leaves the sidebar key count blank. A `MOVED` re-points the slot and retries, an `ASK` retries against the importing node, and one command follows at most five redirects. What Cluster mode cannot do is under [Limitations](#limitations). -The wrong mode is caught at connect: Standalone against a cluster member, or any data mode against a Sentinel port, names the field to change. +The wrong mode is caught at connect, on Valkey as on Redis: Standalone against a cluster member, or any data mode against a Sentinel port, names the field to change. ## Amazon ElastiCache (IAM) @@ -86,7 +86,7 @@ Set **Authentication** to an AWS IAM mode (Access Key, Profile, or SSO). A short ## Browsing keys -`user:1` and `user:2` sit under a `user` folder, nested as deep as the key goes (`app:cache:session:1`). Change the separator in Advanced settings. +`user:1` and `user:2` sit under a `user` folder, nested as deep as the key goes (`app:cache:session:1`). Change the separator on the Options tab. When the tree cannot load, the server's error takes its place; right-click **Keys** and choose **Refresh** to try again. Redis keys grouped by namespace in the sidebar with values in the data grid @@ -106,6 +106,8 @@ The grid columns are **Key**, **Type**, **TTL**, **Length**, and **Value**. Valu A value that is not valid UTF-8, such as a gzip or MessagePack payload, opens in the hex editor instead of as text. +A **Type**, **TTL**, **Length** or **Value** cell is NULL when the server will not say for your user. ACL key patterns do not filter `SCAN`, so a user limited to `~app:*` still sees every key name, and editing **Value** is skipped for a key whose **Type** is NULL. + ### Editing Editing a **Key** cell runs `RENAME`. Editing a **TTL** cell runs `EXPIRE`, or `PERSIST` when you set it to `-1`; in that column `-1` means no expiry and `-2` means the key is gone. Editing a **Value** cell runs `SET`, and only on a string, since a preview of a hash or list is not the whole structure. @@ -130,7 +132,9 @@ SCAN 0 MATCH user:* COUNT 100 A command sent after `MULTI` answers `QUEUED` instead of its own reply, and nothing runs until `EXEC`. `EXEC` then returns every reply in order, errors included; an error element reads `(error) WRONGTYPE …`, the way `redis-cli` prints one. -The rest of the app reads the same session, so while the block is open the sidebar, the key browser and the structure reads report that their command was queued rather than an empty keyspace. Run `EXEC` or `DISCARD` to get them back. +The rest of the app reads the same session, so while the block is open it sends nothing of its own on it. The sidebar, the key browser, the key tree and a database click say the block is open instead, and the connection check skips its `PING`. Run `EXEC` or `DISCARD` to get them back. Saving grid edits is refused while keys are watched; run `EXEC`, `DISCARD` or `UNWATCH` first. + +If the connection drops while a block is open or keys are watched, the server discards both. The next command reports the lost block or `WATCH` instead of running on the new session, and an `EXEC` the connection dropped under reports that whether the block ran is unknown. ## SSL/TLS @@ -150,7 +154,8 @@ New connections default to **Disabled**. SNI is sent in every TLS mode. - A command that fails while `EXEC` is running cannot be taken back. Standalone and Sentinel wrap a grid save in `MULTI`/`EXEC`, so the rest of the block stays applied and the error names the one that failed; check the keys it touched. A command refused before it runs, for a missing ACL permission, a full `maxmemory` or wrong arity, aborts the block and writes nothing. - No transactions in Cluster mode. Grid saves run their commands one at a time, so a failure leaves the earlier ones applied. Group keys under one hash tag if they must move together. -- A database switch made inside a `MULTI` block waits for `EXEC`, and never happens at all after a `DISCARD`. Close the block before browsing the database you picked. +- A `SELECT` typed inside a `MULTI` block waits for `EXEC`, and never happens at all after a `DISCARD`. Clicking a database in the sidebar while a block is open is refused; close the block first. +- A service with one database, such as Upstash or Redis Cloud, refuses `CONFIG`, so the sidebar lists 16. Every database but `db0` shows the server's error when you click it. - Cluster mode serves database 0 only. The Database Index field is hidden and the sidebar shows a single `db0`. - A command whose keys span hash slots, such as `RENAME`, `SMOVE`, or the `*STORE` commands, is refused in Cluster mode before it is sent. Give the keys a shared hash tag, like `{user}:1` and `{user}:2`. - A key that is not valid UTF-8 never appears in the grid or the tree. Reach it from the CLI; values have no such limit. @@ -161,6 +166,14 @@ New connections default to **Disabled**. SNI is sent in every TLS mode. ## Troubleshooting +### NOPERM … has no permissions to run the 'scan' command + +The ACL user cannot run `SCAN`, which the key tree and the key browser read keys with, and key types need `TYPE`. Grant both with `ACL SETUSER +scan +type`, then right-click **Keys** and choose **Refresh**. + +### SELECT … failed: ERR DB index is out of range + +The server has no database with that index. A server that refuses `CONFIG` is listed as 16 databases whatever it holds, so check `databases` in `redis.conf` or the service's own limit, and browse a database the server has. + ### Connection refused The server is not listening where the connection points. Check Redis is running (`brew services start redis`), the port matches `redis.conf`, and the `bind` directive covers the address you are using. diff --git a/docs/features/explain-visualization.mdx b/docs/features/explain-visualization.mdx index b7e50be30c..87e4ecfb92 100644 --- a/docs/features/explain-visualization.mdx +++ b/docs/features/explain-visualization.mdx @@ -148,8 +148,8 @@ Properties the driver reported as false or zero are left out of **Details**. | Cloudflare R2 SQL | Explain, Explain (JSON) | Raw text only | | BigQuery | Dry Run (Cost) | A dry run cost estimate, no plan | | Spanner | Plan | From the indented text | -| MongoDB | Explain | The `explain` runCommand with execution stats | -| Redis | Explain | `DEBUG OBJECT` for the command's key | + +An engine missing from the table has no plan to show, and **Query > Explain Query** is dimmed for it. On MongoDB, call `.explain()` on the query in the editor instead. `EXPLAIN` does not run the query. `EXPLAIN ANALYZE` does, so on a production server it costs whatever the query costs. Safe Mode asks before an EXPLAIN too, whichever way you started it, and the Stop button cancels one that is taking too long. diff --git a/project.yml b/project.yml index 75f25b0011..1df369005e 100644 --- a/project.yml +++ b/project.yml @@ -637,14 +637,18 @@ targets: - Plugins/RedisDriverPlugin/RedisConnectProbe.swift - Plugins/RedisDriverPlugin/RedisConnectionMode.swift - Plugins/RedisDriverPlugin/RedisDatabaseIndex.swift + - Plugins/RedisDriverPlugin/RedisDatabaseListing.swift + - Plugins/RedisDriverPlugin/RedisDatabaseTarget.swift - Plugins/RedisDriverPlugin/RedisKeySlot.swift - Plugins/RedisDriverPlugin/RedisKeySummary.swift + - Plugins/RedisDriverPlugin/RedisMetadataRead.swift - Plugins/RedisDriverPlugin/RedisMultiShardPlanner.swift - Plugins/RedisDriverPlugin/RedisQueryBuilder.swift - Plugins/RedisDriverPlugin/RedisQueuedCommandPolicy.swift - Plugins/RedisDriverPlugin/RedisQueuedDatabase.swift - Plugins/RedisDriverPlugin/RedisReply.swift - Plugins/RedisDriverPlugin/RedisSentinelResolver.swift + - Plugins/RedisDriverPlugin/RedisSessionFootprint.swift - Plugins/RedisDriverPlugin/RedisStatementGenerator.swift - Plugins/RedisDriverPlugin/RedisTopologyDiagnostics.swift - Plugins/RedisDriverPlugin/RedisTransactionOutcome.swift