diff --git a/CHANGELOG.md b/CHANGELOG.md index 6537bb4e5..2a89016cb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,8 +12,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - The table structure view has a Triggers tab for MySQL, MariaDB, PostgreSQL, SQLite, SQL Server, Oracle, libSQL, and Cloudflare D1. It lists each trigger with its timing and event (plus enabled state where the engine reports it), with a filter field and sortable columns. Selecting a trigger shows its full definition in a read-only syntax-highlighted viewer. (#1695) - Traditional Chinese (繁體中文) language in Settings > General with full UI translation +### Changed + +- Selecting a Redis namespace in the sidebar key tree now filters the open database view to that prefix, with paging, instead of opening a separate tab limited to one batch of keys. (#1701) + ### Fixed +- Redis entries no longer disappear after the connection sits idle. The health check was running `SELECT 1`, which on Redis switches the active database, so a later refresh scanned the wrong database. (#1701) +- Redis key browsing now lists every key in a database or namespace and pages through them correctly. It was reading only the first SCAN batch, so large keyspaces showed a partial, fixed set of keys. (#1701) +- A dropped Redis connection now reconnects on the next command and replays auth and the selected database, instead of failing until the next health check. (#1701) - DuckDB VARIANT columns now show their value as text instead of an empty cell. ## [0.51.1] - 2026-06-16 diff --git a/Plugins/RedisDriverPlugin/RedisPluginConnection.swift b/Plugins/RedisDriverPlugin/RedisPluginConnection.swift index 526dab30f..e2ca02c80 100644 --- a/Plugins/RedisDriverPlugin/RedisPluginConnection.swift +++ b/Plugins/RedisDriverPlugin/RedisPluginConnection.swift @@ -171,68 +171,15 @@ final class RedisPluginConnection: @unchecked Sendable { try await pluginDispatchAsync(on: queue) { [self] in logger.debug("Connecting to Redis at \(self.host):\(self.port)") - let connectTimeout = timeval(tv_sec: 10, tv_usec: 0) - guard let ctx = redisConnectWithTimeout(host, Int32(port), connectTimeout) else { - logger.error("Failed to create Redis context") - throw RedisPluginError.connectionFailed - } - - if ctx.pointee.err != 0 { - let errMsg = withUnsafePointer(to: &ctx.pointee.errstr) { ptr in - ptr.withMemoryRebound(to: CChar.self, capacity: 128) { String(cString: $0) } - } - logger.error("Redis connection error: \(errMsg)") - let errCode = Int(ctx.pointee.err) - redisFree(ctx) - throw RedisPluginError(code: errCode, message: errMsg) - } - - let commandTimeout = timeval(tv_sec: 30, tv_usec: 0) - redisSetTimeout(ctx, commandTimeout) - redisEnableKeepAliveWithInterval(ctx, 60) - - stateLock.lock() - self.context = ctx - stateLock.unlock() + try openContextSync(selectDatabase: database) do { - if sslConfig.isEnabled { - try connectSSL(ctx) - } - - if let password = password, !password.isEmpty { - let authArgs: [String] - if let username = username, !username.isEmpty { - authArgs = ["AUTH", username, password] - } else { - authArgs = ["AUTH", password] - } - let reply = try executeCommandSync(authArgs) - if case .error(let msg) = reply { - throw RedisPluginError(code: 1, message: "AUTH failed: \(msg)") - } - } - - if database != 0 { - let reply = try executeCommandSync(["SELECT", String(database)]) - if case .error(let msg) = reply { - throw RedisPluginError(code: 2, message: "SELECT \(database) failed: \(msg)") - } - } - let pingReply = try executeCommandSync(["PING"]) if case .error(let msg) = pingReply { throw RedisPluginError(code: 3, message: "PING failed: \(msg)") } } catch { - stateLock.lock() - let handle = self.context - self.context = nil - let ssl = self.sslContext - self.sslContext = nil - stateLock.unlock() - if let handle { redisFree(handle) } - if let ssl { redisFreeSSLContext(ssl) } + freeContextSync() throw error } @@ -335,7 +282,7 @@ final class RedisPluginConnection: @unchecked Sendable { } stateLock.unlock() try checkCancelled() - let result = try executeCommandSync(args) + let result = try executeCommandSyncRetrying(args) try checkCancelled() return result } @@ -357,7 +304,7 @@ final class RedisPluginConnection: @unchecked Sendable { } stateLock.unlock() try checkCancelled() - let results = try executePipelineSync(commands) + let results = try executePipelineSyncRetrying(commands) try checkCancelled() return results } @@ -381,7 +328,7 @@ final class RedisPluginConnection: @unchecked Sendable { } stateLock.unlock() try checkCancelled() - let reply = try executeCommandSync(["SELECT", String(index)]) + let reply = try executeCommandSyncRetrying(["SELECT", String(index)]) if case .error(let msg) = reply { throw RedisPluginError(code: 2, message: "SELECT \(index) failed: \(msg)") } @@ -457,9 +404,7 @@ private extension RedisPluginConnection { let result = redisInitiateSSLWithContext(ctx, ssl) if result != REDIS_OK { redisFreeSSLContext(ssl) - let errMsg = withUnsafePointer(to: &ctx.pointee.errstr) { ptr in - ptr.withMemoryRebound(to: CChar.self, capacity: 128) { String(cString: $0) } - } + let errMsg = Self.contextErrorMessage(ctx) if let sslError = Self.classifySSLError(errMsg) { throw sslError } @@ -470,6 +415,110 @@ private extension RedisPluginConnection { logger.debug("SSL connection established") } + static func contextErrorMessage(_ ctx: UnsafeMutablePointer) -> String { + withUnsafePointer(to: &ctx.pointee.errstr) { ptr in + ptr.withMemoryRebound(to: CChar.self, capacity: 128) { String(cString: $0) } + } + } + + func openContextSync(selectDatabase dbIndex: Int) throws { + let connectTimeout = timeval(tv_sec: 10, tv_usec: 0) + guard let ctx = redisConnectWithTimeout(host, Int32(port), connectTimeout) else { + logger.error("Failed to create Redis context") + throw RedisPluginError.connectionFailed + } + + if ctx.pointee.err != 0 { + let errMsg = Self.contextErrorMessage(ctx) + logger.error("Redis connection error: \(errMsg)") + let errCode = Int(ctx.pointee.err) + redisFree(ctx) + throw RedisPluginError(code: errCode, message: errMsg) + } + + let commandTimeout = timeval(tv_sec: 30, tv_usec: 0) + redisSetTimeout(ctx, commandTimeout) + redisEnableKeepAliveWithInterval(ctx, 60) + + stateLock.lock() + self.context = ctx + stateLock.unlock() + + do { + if sslConfig.isEnabled { + try connectSSL(ctx) + } + try authenticateSync() + if dbIndex != 0 { + let reply = try executeCommandSync(["SELECT", String(dbIndex)]) + if case .error(let msg) = reply { + throw RedisPluginError(code: 2, message: "SELECT \(dbIndex) failed: \(msg)") + } + } + } catch { + freeContextSync() + throw error + } + } + + func authenticateSync() throws { + guard let password, !password.isEmpty else { return } + let authArgs: [String] + if let username, !username.isEmpty { + authArgs = ["AUTH", username, password] + } else { + authArgs = ["AUTH", password] + } + let reply = try executeCommandSync(authArgs) + if case .error(let msg) = reply { + throw RedisPluginError(code: 1, message: "AUTH failed: \(msg)") + } + } + + func freeContextSync() { + stateLock.lock() + let handle = context + let ssl = sslContext + context = nil + sslContext = nil + stateLock.unlock() + if let handle { redisFree(handle) } + if let ssl { redisFreeSSLContext(ssl) } + } + + func reconnectSync() throws { + guard !isShuttingDown else { throw RedisPluginError.notConnected } + let targetDatabase = currentDatabase() + logger.warning("Redis connection lost; reconnecting to \(self.host):\(self.port), database \(targetDatabase)") + freeContextSync() + try openContextSync(selectDatabase: targetDatabase) + stateLock.lock() + _isConnected = true + stateLock.unlock() + } + + func isConnectionError(_ error: RedisPluginError) -> Bool { + error.code == Int(REDIS_ERR_EOF) || error.code == Int(REDIS_ERR_IO) + } + + func executeCommandSyncRetrying(_ args: [String]) throws -> RedisReply { + do { + return try executeCommandSync(args) + } catch let error as RedisPluginError where isConnectionError(error) && !isShuttingDown { + try reconnectSync() + return try executeCommandSync(args) + } + } + + func executePipelineSyncRetrying(_ commands: [[String]]) throws -> [RedisReply] { + do { + return try executePipelineSync(commands) + } catch let error as RedisPluginError where isConnectionError(error) && !isShuttingDown { + try reconnectSync() + return try executePipelineSync(commands) + } + } + func executeCommandSync(_ args: [String]) throws -> RedisReply { stateLock.lock() guard let ctx = context else { @@ -484,10 +533,7 @@ private extension RedisPluginConnection { return try withArgvPointers(args: args, lengths: lengths) { argv, argvlen in guard let rawReply = redisCommandArgv(ctx, argc, argv, argvlen) else { if ctx.pointee.err != 0 { - let errMsg = withUnsafePointer(to: &ctx.pointee.errstr) { ptr in - ptr.withMemoryRebound(to: CChar.self, capacity: 128) { String(cString: $0) } - } - throw RedisPluginError(code: Int(ctx.pointee.err), message: errMsg) + throw RedisPluginError(code: Int(ctx.pointee.err), message: Self.contextErrorMessage(ctx)) } throw RedisPluginError(code: -1, message: "No reply from Redis") } @@ -521,9 +567,7 @@ private extension RedisPluginConnection { if let d = discard { freeReplyObject(d) } } let errCode = Int(ctx.pointee.err) - let errMsg = withUnsafePointer(to: &ctx.pointee.errstr) { ptr in - ptr.withMemoryRebound(to: CChar.self, capacity: 128) { String(cString: $0) } - } + let errMsg = Self.contextErrorMessage(ctx) markDisconnected() throw RedisPluginError(code: errCode, message: errMsg) } @@ -538,9 +582,7 @@ private extension RedisPluginConnection { let status = redisGetReply(ctx, &rawReply) guard status == REDIS_OK, let reply = rawReply else { let errCode = Int(ctx.pointee.err) - let errMsg = withUnsafePointer(to: &ctx.pointee.errstr) { ptr in - ptr.withMemoryRebound(to: CChar.self, capacity: 128) { String(cString: $0) } - } + let errMsg = Self.contextErrorMessage(ctx) for _ in (i + 1) ..< commands.count { var discard: UnsafeMutableRawPointer? if redisGetReply(ctx, &discard) == REDIS_OK, let d = discard { diff --git a/Plugins/RedisDriverPlugin/RedisPluginDriver+Operations.swift b/Plugins/RedisDriverPlugin/RedisPluginDriver+Operations.swift index 63434b189..03384318d 100644 --- a/Plugins/RedisDriverPlugin/RedisPluginDriver+Operations.swift +++ b/Plugins/RedisDriverPlugin/RedisPluginDriver+Operations.swift @@ -481,8 +481,6 @@ extension RedisPluginDriver { case .select(let database): try await conn.selectDatabase(database) - cachedScanPattern = nil - cachedScanKeys = nil return buildStatusResult("OK", startTime: startTime) case .configGet(let parameter): diff --git a/Plugins/RedisDriverPlugin/RedisPluginDriver.swift b/Plugins/RedisDriverPlugin/RedisPluginDriver.swift index 7320f7155..b3d902a53 100644 --- a/Plugins/RedisDriverPlugin/RedisPluginDriver.swift +++ b/Plugins/RedisDriverPlugin/RedisPluginDriver.swift @@ -33,12 +33,8 @@ final class RedisPluginDriver: PluginDatabaseDriver, @unchecked Sendable { private static let logger = Logger(subsystem: "com.TablePro.RedisDriver", category: "RedisPluginDriver") - private static let maxScanKeys = PluginRowLimits.emergencyMax static let maxKeyBrowseScan = 10_000 - var cachedScanPattern: String? - var cachedScanKeys: [String]? - var serverVersion: String? { redisConnection?.serverVersion() } @@ -83,8 +79,6 @@ final class RedisPluginDriver: PluginDatabaseDriver, @unchecked Sendable { func disconnect() { redisConnection?.disconnect() redisConnection = nil - cachedScanPattern = nil - cachedScanKeys = nil } func ping() async throws { @@ -101,8 +95,6 @@ final class RedisPluginDriver: PluginDatabaseDriver, @unchecked Sendable { func execute(query: String) async throws -> PluginQueryResult { let startTime = Date() - cachedScanPattern = nil - cachedScanKeys = nil redisConnection?.resetCancellation() guard let conn = redisConnection else { @@ -125,9 +117,7 @@ final class RedisPluginDriver: PluginDatabaseDriver, @unchecked Sendable { redisConnection?.cancelCurrentQuery() } - func applyQueryTimeout(_ seconds: Int) async throws { - // Redis does not support session-level query timeouts - } + func applyQueryTimeout(_ seconds: Int) async throws {} // MARK: - Schema Operations @@ -415,7 +405,7 @@ final class RedisPluginDriver: PluginDatabaseDriver, @unchecked Sendable { // MARK: - Streaming func streamRows(query: String) -> AsyncThrowingStream { - return AsyncThrowingStream(bufferingPolicy: .unbounded) { continuation in + AsyncThrowingStream(bufferingPolicy: .unbounded) { continuation in let streamTask = Task { do { try await self.performStreamRows(query: query, continuation: continuation) @@ -582,7 +572,6 @@ final class RedisPluginDriver: PluginDatabaseDriver, @unchecked Sendable { batchStart = batchEnd } - } while cursor != "0" continuation.finish() @@ -604,7 +593,6 @@ final class RedisPluginDriver: PluginDatabaseDriver, @unchecked Sendable { ) } - // Redis SCAN only supports key pattern matching; sortColumns, columns, and offset are unused func buildFilteredQuery( table: String, filters: [(column: String, op: String, value: String)], diff --git a/Plugins/RedisDriverPlugin/RedisQueryBuilder.swift b/Plugins/RedisDriverPlugin/RedisQueryBuilder.swift index a114ea4b9..d42da7428 100644 --- a/Plugins/RedisDriverPlugin/RedisQueryBuilder.swift +++ b/Plugins/RedisDriverPlugin/RedisQueryBuilder.swift @@ -12,8 +12,8 @@ import TableProPluginKit struct RedisQueryBuilder { // MARK: - Base Query - /// Build a SCAN command for browsing keys in a namespace. - /// Returns: SCAN 0 MATCH namespace:* COUNT limit + /// Build a key-browse command for a namespace. KEYBROWSE iterates the SCAN cursor + /// to completion (bounded) and honors LIMIT/OFFSET, so paging returns every key. func buildBaseQuery( namespace: String, sortColumns: [(columnIndex: Int, ascending: Bool)] = [], @@ -21,8 +21,8 @@ struct RedisQueryBuilder { limit: Int = 200, offset: Int = 0 ) -> String { - let pattern = namespace.isEmpty ? "*" : "\(namespace)*" - return "SCAN 0 MATCH \"\(pattern)\" COUNT \(limit)" + let pattern = namespace.isEmpty ? nil : "\(namespace)*" + return buildKeyBrowseQuery(pattern: pattern, typeScope: nil, limit: limit, offset: offset) } /// Build a key-browse command from filter tuples. @@ -40,7 +40,7 @@ struct RedisQueryBuilder { let typeScope = extractTypeScope(from: filters) guard pattern != nil || typeScope != nil else { - return buildBaseQuery(namespace: namespace, limit: limit) + return buildBaseQuery(namespace: namespace, limit: limit, offset: offset) } return buildKeyBrowseQuery(pattern: pattern, typeScope: typeScope, limit: limit, offset: offset) diff --git a/TablePro/Core/Database/DatabaseDriver.swift b/TablePro/Core/Database/DatabaseDriver.swift index 4af66c5e2..ef3386b5a 100644 --- a/TablePro/Core/Database/DatabaseDriver.swift +++ b/TablePro/Core/Database/DatabaseDriver.swift @@ -34,6 +34,9 @@ protocol DatabaseDriver: AnyObject { /// Test the connection (connect and immediately disconnect) func testConnection() async throws -> Bool + /// Check the connection is alive without mutating session state + func ping() async throws + // MARK: - Configuration /// Apply query execution timeout (seconds, 0 = no limit) @@ -249,6 +252,10 @@ extension DatabaseDriver { func fetchTriggers(table: String) async throws -> [TriggerInfo] { [] } + func ping() async throws { + _ = try await execute(query: "SELECT 1") + } + func testConnection() async throws -> Bool { try await connect() disconnect() diff --git a/TablePro/Core/Database/DatabaseManager+Health.swift b/TablePro/Core/Database/DatabaseManager+Health.swift index 4f82cc72b..7fd1178fe 100644 --- a/TablePro/Core/Database/DatabaseManager+Health.swift +++ b/TablePro/Core/Database/DatabaseManager+Health.swift @@ -42,7 +42,7 @@ extension DatabaseManager { return false } do { - _ = try await mainDriver.execute(query: "SELECT 1") + try await mainDriver.ping() return true } catch { Self.logger.debug("Ping failed for \(connectionId): \(error.localizedDescription)") diff --git a/TablePro/Core/Plugins/PluginDriverAdapter.swift b/TablePro/Core/Plugins/PluginDriverAdapter.swift index c21c37aba..0febd756f 100644 --- a/TablePro/Core/Plugins/PluginDriverAdapter.swift +++ b/TablePro/Core/Plugins/PluginDriverAdapter.swift @@ -116,6 +116,10 @@ final class PluginDriverAdapter: DatabaseDriver, SchemaSwitchable { status = .disconnected } + func ping() async throws { + try await pluginDriver.ping() + } + func applyQueryTimeout(_ seconds: Int) async throws { try await pluginDriver.applyQueryTimeout(seconds) } diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+Navigation.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+Navigation.swift index d132173a3..4c85b0dd3 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+Navigation.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+Navigation.swift @@ -552,12 +552,7 @@ extension MainContentCoordinator { // MARK: - Redis Key Tree Navigation func browseRedisNamespace(_ prefix: String) { - let separator = connection.additionalFields["redisSeparator"] ?? ":" - let escapedPrefix = prefix.replacingOccurrences(of: "\"", with: "\\\"") - let query = "SCAN 0 MATCH \"\(escapedPrefix)*\" COUNT 200" - let title = prefix.hasSuffix(separator) ? String(prefix.dropLast(separator.count)) : prefix - tabManager.addTab(initialQuery: query, title: title) - runQuery() + applyBrowseSearch(BrowseSearchState(pattern: "\(prefix)*")) } func openRedisKey(_ keyName: String, keyType: String) { diff --git a/TableProTests/Core/Plugins/PluginDriverAdapterPingTests.swift b/TableProTests/Core/Plugins/PluginDriverAdapterPingTests.swift new file mode 100644 index 000000000..d4bf04396 --- /dev/null +++ b/TableProTests/Core/Plugins/PluginDriverAdapterPingTests.swift @@ -0,0 +1,80 @@ +// +// PluginDriverAdapterPingTests.swift +// TableProTests +// + +import Foundation +@testable import TablePro +import TableProPluginKit +import Testing + +private class BasePingDriver { + var supportsSchemas: Bool { false } + var supportsTransactions: Bool { false } + var currentSchema: String? { nil } + var serverVersion: String? { nil } + + private(set) var executedQueries: [String] = [] + + func connect() async throws {} + func disconnect() {} + + func execute(query: String) async throws -> PluginQueryResult { + executedQueries.append(query) + return PluginQueryResult(columns: [], columnTypeNames: [], rows: [], rowsAffected: 0, executionTime: 0) + } + + 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) + } +} + +private final class DefaultPingDriver: BasePingDriver, PluginDatabaseDriver {} + +private final class PingOverrideDriver: BasePingDriver, PluginDatabaseDriver { + private(set) var pingCallCount = 0 + + func ping() async throws { + pingCallCount += 1 + } +} + +@Suite("PluginDriverAdapter ping") +struct PluginDriverAdapterPingTests { + private func makeAdapter(driver: any PluginDatabaseDriver) -> PluginDriverAdapter { + let connection = DatabaseConnection(name: "Test", type: .redis) + return PluginDriverAdapter(connection: connection, pluginDriver: driver) + } + + @Test("Adapter ping routes to the plugin ping override, never to execute") + func pingRoutesToPluginPing() async throws { + let driver = PingOverrideDriver() + let adapter = makeAdapter(driver: driver) + + try await adapter.ping() + + #expect(driver.pingCallCount == 1) + #expect(driver.executedQueries.isEmpty) + } + + @Test("SQL drivers without a ping override fall back to SELECT 1") + func defaultPingFallsBackToSelectOne() async throws { + let driver = DefaultPingDriver() + let adapter = makeAdapter(driver: driver) + + try await adapter.ping() + + #expect(driver.executedQueries == ["SELECT 1"]) + } +} diff --git a/TableProTests/Plugins/RedisQueryBuilderTests.swift b/TableProTests/Plugins/RedisQueryBuilderTests.swift index f3d575d00..d62a6fdf7 100644 --- a/TableProTests/Plugins/RedisQueryBuilderTests.swift +++ b/TableProTests/Plugins/RedisQueryBuilderTests.swift @@ -15,26 +15,26 @@ struct RedisQueryBuilderTests { // MARK: - Base Query - @Test("Empty namespace produces wildcard SCAN") + @Test("Empty namespace produces a bare key-browse command") func emptyNamespaceWildcard() { let query = builder.buildBaseQuery(namespace: "") - #expect(query == "SCAN 0 MATCH \"*\" COUNT 200") + #expect(query == "KEYBROWSE LIMIT 200 OFFSET 0") } - @Test("Namespace appends wildcard") + @Test("Namespace appends wildcard to the MATCH pattern") func namespaceAppendsWildcard() { let query = builder.buildBaseQuery(namespace: "cache:") - #expect(query == "SCAN 0 MATCH \"cache:*\" COUNT 200") + #expect(query == "KEYBROWSE MATCH \"cache:*\" LIMIT 200 OFFSET 0") } @Test("Custom limit") func customLimit() { let query = builder.buildBaseQuery(namespace: "user:", limit: 500) - #expect(query == "SCAN 0 MATCH \"user:*\" COUNT 500") + #expect(query == "KEYBROWSE MATCH \"user:*\" LIMIT 500 OFFSET 0") } - @Test("Sort columns and offset are accepted but do not change SCAN command") - func sortAndOffsetIgnored() { + @Test("Offset pages through the namespace") + func offsetPagesThrough() { let query = builder.buildBaseQuery( namespace: "test:", sortColumns: [(columnIndex: 0, ascending: true)], @@ -42,7 +42,7 @@ struct RedisQueryBuilderTests { limit: 100, offset: 50 ) - #expect(query == "SCAN 0 MATCH \"test:*\" COUNT 100") + #expect(query == "KEYBROWSE MATCH \"test:*\" LIMIT 100 OFFSET 50") } // MARK: - Key Browse Query @@ -109,16 +109,16 @@ struct RedisQueryBuilderTests { #expect(query == "KEYBROWSE MATCH \"*session*\" LIMIT 200 OFFSET 0") } - @Test("Non-Key, non-Type filter falls back to base SCAN") + @Test("Non-Key, non-Type filter falls back to the base browse command") func nonKeyColumnFallsBack() { let query = builder.buildFilteredQuery( namespace: "test:", filters: [(column: "Value", op: "CONTAINS", value: "hello")] ) - #expect(query == "SCAN 0 MATCH \"test:*\" COUNT 200") + #expect(query == "KEYBROWSE MATCH \"test:*\" LIMIT 200 OFFSET 0") } - @Test("Multiple Key filters fall back to base SCAN") + @Test("Multiple Key filters fall back to the base browse command") func multipleKeyFiltersFallBack() { let query = builder.buildFilteredQuery( namespace: "", @@ -127,7 +127,7 @@ struct RedisQueryBuilderTests { (column: "Key", op: "MATCH", value: "b*") ] ) - #expect(query == "SCAN 0 MATCH \"*\" COUNT 200") + #expect(query == "KEYBROWSE LIMIT 200 OFFSET 0") } // MARK: - Count Query