Skip to content

fix: Redis hash/list/set/zset/stream views drop non-UTF8 binary values#447

Merged
datlechin merged 3 commits intomainfrom
fix/redis-hash-binary-data
Mar 24, 2026
Merged

fix: Redis hash/list/set/zset/stream views drop non-UTF8 binary values#447
datlechin merged 3 commits intomainfrom
fix/redis-hash-binary-data

Conversation

@datlechin
Copy link
Collaborator

@datlechin datlechin commented Mar 24, 2026

Summary

  • All build*Result and formatValuePreview methods used stringArrayValue which calls compactMap(\.stringValue) — silently dropping .data (non-UTF8), .null, and .integer entries from Redis replies
  • For hashes/sorted sets, this corrupts alternating field/value pairs, resulting in empty or misaligned rows (reported: BullMQ hash keys showing empty Field/Value columns)
  • Fixed 11 call sites to use arrayValue + redisReplyToString() which handles all reply types (binary → base64 fallback, null → "(nil)", integer → string)

Test plan

  • 33 new unit tests in RedisResultBuildingTests.swift (all passing)
  • Manual: connect to Redis, click hash key test:binary:hash1 (has non-UTF8 value) → should show 3 rows with base64 for binary value
  • Manual: click hash key test:bullmq:job:1 (JSON values) → should show all 9 fields
  • Manual: verify list, set, sorted set, stream keys also display correctly

Summary by CodeRabbit

  • Bug Fixes

    • Fixed Redis result parsing and display for hashes, lists, sets, sorted sets, streams, previews, key browsing and CONFIG results to ensure accurate rows and columns, correct pairing, and proper handling of binary, null and integer values.
  • Tests

    • Added comprehensive test suite for Redis result building to prevent regressions and ensure data integrity.

@coderabbitai
Copy link

coderabbitai bot commented Mar 24, 2026

Caution

Review failed

The pull request is closed.

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 5c8fb047-7bc3-4d9e-ae3e-72b7a0c5ff58

📥 Commits

Reviewing files that changed from the base of the PR and between 462d50c and be3f6cc.

📒 Files selected for processing (3)
  • CHANGELOG.md
  • Plugins/RedisDriverPlugin/RedisPluginDriver.swift
  • TableProTests/Core/Redis/RedisResultBuildingTests.swift

📝 Walkthrough

Walkthrough

Redis reply parsing and result construction were changed to treat hash, list, set, zset, stream, and config replies as generic .arrayValue elements and consistently convert elements to strings via redisReplyToString(...). A new test suite validates correct row/column generation and binary/null/integer handling.

Changes

Cohort / File(s) Summary
Redis Driver Implementation
Plugins/RedisDriverPlugin/RedisPluginDriver.swift
Replaced uses of .stringArrayValue with .arrayValue for multi-element replies; unified element stringification via redisReplyToString(...); updated preview/result builders for hash, list, set, zset, stream, and config results to iterate .arrayValue and handle paired/scalar elements correctly.
Redis Result Building Tests
TableProTests/Core/Redis/RedisResultBuildingTests.swift
Added comprehensive tests and local TestRedisReply/builder helpers to validate redisReplyToString behavior, paired-pair handling, index offsets, binary/base64 fallback, null rendering, and regression cases covering prior misalignment/loss of non-string elements.
Changelog
CHANGELOG.md
Updated Unreleased notes: added UI/data-management items and a Redis driver fix entry describing corrected rendering for hash/list/set/zset/stream when values include binary/null/integer types.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Possibly related PRs

Poem

🐰 I hopped through replies both large and small,
I stitched array pieces so none would fall,
Fields, scores, and streams now sing as one,
No lost bytes, no rows undone — hop, done! 🥕✨

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 1.96% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately describes the main fix: correcting Redis hash/list/set/zset/stream views to properly handle non-UTF8 binary values instead of dropping them.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/redis-hash-binary-data

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
Plugins/RedisDriverPlugin/RedisPluginDriver.swift (1)

1234-1266: ⚠️ Potential issue | 🟡 Minor

STRING previews still blank out non-UTF8 payloads.

While the collection branches now preserve raw replies, the "string" path still goes through reply.stringValue, so a binary GET result will keep showing an empty preview. Routing this branch through redisReplyToString(...) makes string previews consistent with the rest of the fix.

Suggested fix
         case "string":
-            return truncatePreview(reply.stringValue)
+            return truncatePreview(redisReplyToString(reply))
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Plugins/RedisDriverPlugin/RedisPluginDriver.swift` around lines 1234 - 1266,
In formatPreviewReply(_ reply:type:) the "string" case uses reply.stringValue
which blanks non-UTF8/binary data; replace that use with
redisReplyToString(reply) so string previews preserve raw/binary payloads
consistently with the other branches (update the "string" case inside
formatPreviewReply to call redisReplyToString instead of reply.stringValue and
pass the result into truncatePreview).
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@TableProTests/Core/Redis/RedisResultBuildingTests.swift`:
- Around line 12-147: The tests are exercising local replicas
(testRedisReplyToString, buildTestHashResult, buildTestListResult,
buildTestSetResult, buildTestSortedSetResult, buildTestConfigResult) instead of
the real builders (and skip the changed formatPreviewReply and buildStreamResult
paths); extract the reply-stringification/result-building logic into a shared,
testable seam (e.g., move implementations into RedisPluginDriver as
internal/static helpers or a small SharedRedisFormatting type) and update the
tests to call those real functions instead of the local copies; ensure the new
helpers expose the same APIs used here (including formatPreviewReply and
buildStreamResult) and make them `@testable-importable` or placed in a common
module so the test file can reference them directly.
- Around line 163-166: The test dataValidUtf8 uses force unwraps which must be
removed; change the UTF-8 setup to safely unwrap the Data using guard let / if
let (e.g., guard let data = "some text".data(using: .utf8) else { XCTFail(...) ;
return }) and update the assertion to compare the reply's optional properties
directly (e.g., compare reply.stringArrayValue or reply.stringValue instead of
force-unwrapping), and apply the same pattern to the other failing tests
referenced; locate the Data creation and the assertion call to
testRedisReplyToString in dataValidUtf8 (and the similar tests at the other
lines) and replace `!` usage with safe optional binding and proper test failure
handling.

---

Outside diff comments:
In `@Plugins/RedisDriverPlugin/RedisPluginDriver.swift`:
- Around line 1234-1266: In formatPreviewReply(_ reply:type:) the "string" case
uses reply.stringValue which blanks non-UTF8/binary data; replace that use with
redisReplyToString(reply) so string previews preserve raw/binary payloads
consistently with the other branches (update the "string" case inside
formatPreviewReply to call redisReplyToString instead of reply.stringValue and
pass the result into truncatePreview).

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: e9bcb27b-c449-4b8d-bf87-9dc0d34aba56

📥 Commits

Reviewing files that changed from the base of the PR and between 96bcd12 and 462d50c.

📒 Files selected for processing (2)
  • Plugins/RedisDriverPlugin/RedisPluginDriver.swift
  • TableProTests/Core/Redis/RedisResultBuildingTests.swift

Comment on lines +12 to +147
// Because RedisPluginDriver lives in a plugin bundle and cannot be @testable
// imported, we replicate the fixed logic here as private helpers.
//

import Foundation
import Testing

// MARK: - Private Local Helpers (copied from RedisDriverPlugin)

private enum TestRedisReply {
case string(String)
case integer(Int64)
case array([TestRedisReply])
case data(Data)
case status(String)
case error(String)
case null

var stringValue: String? {
switch self {
case .string(let s), .status(let s): return s
case .data(let d): return String(data: d, encoding: .utf8)
default: return nil
}
}

var intValue: Int? {
switch self {
case .integer(let i): return Int(i)
case .string(let s): return Int(s)
default: return nil
}
}

var stringArrayValue: [String]? {
guard case .array(let items) = self else { return nil }
return items.compactMap(\.stringValue)
}

var arrayValue: [TestRedisReply]? {
guard case .array(let items) = self else { return nil }
return items
}
}

// MARK: - Fixed Logic Replicas

/// Matches the fixed `redisReplyToString` in RedisPluginDriver.
private func testRedisReplyToString(_ reply: TestRedisReply) -> String {
switch reply {
case .string(let s), .status(let s), .error(let s): return s
case .integer(let i): return String(i)
case .data(let d): return String(data: d, encoding: .utf8) ?? d.base64EncodedString()
case .array(let items): return "[\(items.map { testRedisReplyToString($0) }.joined(separator: ", "))]"
case .null: return "(nil)"
}
}

/// Result type mirroring the relevant fields of PluginQueryResult.
private struct TestResult {
let columns: [String]
let rows: [[String?]]
}

private func buildTestHashResult(_ result: TestRedisReply) -> TestResult {
guard let items = result.arrayValue, !items.isEmpty else {
return TestResult(columns: ["Field", "Value"], rows: [])
}

var rows: [[String?]] = []
var i = 0
while i + 1 < items.count {
rows.append([testRedisReplyToString(items[i]), testRedisReplyToString(items[i + 1])])
i += 2
}

return TestResult(columns: ["Field", "Value"], rows: rows)
}

private func buildTestListResult(_ result: TestRedisReply, startOffset: Int = 0) -> TestResult {
guard let items = result.arrayValue else {
return TestResult(columns: ["Index", "Value"], rows: [])
}

let rows = items.enumerated().map { index, item -> [String?] in
[String(startOffset + index), testRedisReplyToString(item)]
}

return TestResult(columns: ["Index", "Value"], rows: rows)
}

private func buildTestSetResult(_ result: TestRedisReply) -> TestResult {
guard let items = result.arrayValue else {
return TestResult(columns: ["Member"], rows: [])
}

let rows = items.map { [testRedisReplyToString($0)] as [String?] }
return TestResult(columns: ["Member"], rows: rows)
}

private func buildTestSortedSetResult(_ result: TestRedisReply, withScores: Bool) -> TestResult {
guard let items = result.arrayValue else {
return TestResult(
columns: withScores ? ["Member", "Score"] : ["Member"],
rows: []
)
}

if withScores {
var rows: [[String?]] = []
var i = 0
while i + 1 < items.count {
rows.append([testRedisReplyToString(items[i]), testRedisReplyToString(items[i + 1])])
i += 2
}
return TestResult(columns: ["Member", "Score"], rows: rows)
} else {
let rows = items.map { [testRedisReplyToString($0)] as [String?] }
return TestResult(columns: ["Member"], rows: rows)
}
}

private func buildTestConfigResult(_ result: TestRedisReply) -> TestResult {
guard let items = result.arrayValue, !items.isEmpty else {
return TestResult(columns: ["Parameter", "Value"], rows: [])
}

var rows: [[String?]] = []
var i = 0
while i + 1 < items.count {
rows.append([testRedisReplyToString(items[i]), testRedisReplyToString(items[i + 1])])
i += 2
}

return TestResult(columns: ["Parameter", "Value"], rows: rows)
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

These suites validate a copy, not RedisPluginDriver.

All assertions in this file run against local replicas of the production builders, so the tests can stay green even if Plugins/RedisDriverPlugin/RedisPluginDriver.swift diverges. That also leaves the changed formatPreviewReply(...) and buildStreamResult(...) paths untested here. Please move the reply-stringification/result-building logic behind a shared testable seam and point these cases at the real implementation.

Also applies to: 151-516

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@TableProTests/Core/Redis/RedisResultBuildingTests.swift` around lines 12 -
147, The tests are exercising local replicas (testRedisReplyToString,
buildTestHashResult, buildTestListResult, buildTestSetResult,
buildTestSortedSetResult, buildTestConfigResult) instead of the real builders
(and skip the changed formatPreviewReply and buildStreamResult paths); extract
the reply-stringification/result-building logic into a shared, testable seam
(e.g., move implementations into RedisPluginDriver as internal/static helpers or
a small SharedRedisFormatting type) and update the tests to call those real
functions instead of the local copies; ensure the new helpers expose the same
APIs used here (including formatPreviewReply and buildStreamResult) and make
them `@testable-importable` or placed in a common module so the test file can
reference them directly.

@datlechin datlechin merged commit 1a933f4 into main Mar 24, 2026
1 of 2 checks passed
@datlechin datlechin deleted the fix/redis-hash-binary-data branch March 24, 2026 15:54
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant