From 3b58269ca5ef6bb085370a6b70e11e03a366c11d Mon Sep 17 00:00:00 2001 From: thanhthai3010 Date: Mon, 21 Sep 2026 15:39:32 +0700 Subject: [PATCH] fix(cassandra): render collection values as CQL literals --- agents/drivers/cassandra-go/query.go | 2 +- agents/drivers/cassandra-go/values.go | 55 +++++-- agents/drivers/cassandra-go/values_test.go | 13 +- .../dataGrid/__tests__/cqlLiteralJson.spec.ts | 35 +++++ .../dataGrid/__tests__/dataGridDetail.spec.ts | 28 ++++ .../src/lib/dataGrid/cqlLiteralJson.ts | 135 ++++++++++++++++++ .../src/lib/dataGrid/dataGridDetail.ts | 14 +- 7 files changed, 268 insertions(+), 14 deletions(-) create mode 100644 apps/desktop/src/lib/dataGrid/__tests__/cqlLiteralJson.spec.ts create mode 100644 apps/desktop/src/lib/dataGrid/cqlLiteralJson.ts diff --git a/agents/drivers/cassandra-go/query.go b/agents/drivers/cassandra-go/query.go index ddc288898f..442edc32f3 100644 --- a/agents/drivers/cassandra-go/query.go +++ b/agents/drivers/cassandra-go/query.go @@ -255,7 +255,7 @@ func scanCQLRow(iter *gocql.Iter, columns []gocql.ColumnInfo) ([]any, bool, erro if allNull { return nil } - return normalizeCQLValue(values) + return normalizeCQLValue(cqlTuple(values)) }) continue } diff --git a/agents/drivers/cassandra-go/values.go b/agents/drivers/cassandra-go/values.go index 14afb36b5f..29ef4e2ae9 100644 --- a/agents/drivers/cassandra-go/values.go +++ b/agents/drivers/cassandra-go/values.go @@ -13,6 +13,9 @@ import ( gocql "github.com/apache/cassandra-gocql-driver/v2" ) +// cqlTuple marks a decoded tuple so it renders as a CQL tuple literal. +type cqlTuple []any + func normalizeCQLValue(value any) any { if value == nil { return nil @@ -20,25 +23,44 @@ func normalizeCQLValue(value any) any { return cqlString(value) } +// cqlString renders a column value for the grid. Scalars keep their plain +// text form; collections are rendered as CQL literals, the same text cqlsh +// prints, so a copied cell can be pasted back into a CQL statement. func cqlString(value any) string { + return renderCQLValue(value, false) +} + +// cqlLiteral renders a value nested inside a collection: text-like values +// are single-quoted so the literal stays unambiguous and valid CQL. +func cqlLiteral(value any) string { + return renderCQLValue(value, true) +} + +func renderCQLValue(value any, nested bool) string { if value == nil { return "null" } + quote := func(text string) string { + if nested { + return quoteCQLString(text) + } + return text + } switch typed := value.(type) { case string: - return typed + return quote(typed) case []byte: return "0x" + hex.EncodeToString(typed) case time.Time: - return typed.Format(time.RFC3339Nano) + return quote(typed.Format(time.RFC3339Nano)) case time.Duration: - return typed.String() + return quote(typed.String()) case gocql.Duration: return fmt.Sprintf("%dmo%dd%dns", typed.Months, typed.Days, typed.Nanoseconds) case gocql.UUID: return typed.String() case net.IP: - return typed.String() + return quote(typed.String()) case *big.Int: if typed == nil { return "" @@ -46,6 +68,12 @@ func cqlString(value any) string { return typed.String() case big.Int: return typed.String() + case cqlTuple: + values := make([]string, len(typed)) + for index, element := range typed { + values[index] = cqlLiteral(element) + } + return "(" + strings.Join(values, ", ") + ")" case fmt.Stringer: return typed.String() } @@ -58,17 +86,22 @@ func cqlString(value any) string { } switch valueOf.Kind() { case reflect.Map: - entries := make([]string, 0, valueOf.Len()) + type entry struct{ key, value string } + entries := make([]entry, 0, valueOf.Len()) iterator := valueOf.MapRange() for iterator.Next() { - entries = append(entries, cqlString(iterator.Key().Interface())+"="+cqlString(iterator.Value().Interface())) + entries = append(entries, entry{cqlLiteral(iterator.Key().Interface()), cqlLiteral(iterator.Value().Interface())}) } - sort.Strings(entries) - return "{" + strings.Join(entries, ", ") + "}" + sort.Slice(entries, func(i, j int) bool { return entries[i].key < entries[j].key }) + parts := make([]string, len(entries)) + for index, item := range entries { + parts[index] = item.key + ": " + item.value + } + return "{" + strings.Join(parts, ", ") + "}" case reflect.Slice, reflect.Array: values := make([]string, valueOf.Len()) for index := range values { - values[index] = cqlString(valueOf.Index(index).Interface()) + values[index] = cqlLiteral(valueOf.Index(index).Interface()) } return "[" + strings.Join(values, ", ") + "]" default: @@ -76,6 +109,10 @@ func cqlString(value any) string { } } +func quoteCQLString(value string) string { + return "'" + strings.ReplaceAll(value, "'", "''") + "'" +} + func cqlTypeName(typeInfo gocql.TypeInfo) string { if typeInfo == nil { return "unknown" diff --git a/agents/drivers/cassandra-go/values_test.go b/agents/drivers/cassandra-go/values_test.go index 226121f623..4d20db5bbf 100644 --- a/agents/drivers/cassandra-go/values_test.go +++ b/agents/drivers/cassandra-go/values_test.go @@ -9,7 +9,7 @@ import ( gocql "github.com/apache/cassandra-gocql-driver/v2" ) -func TestNormalizeCQLValuePreservesLegacyStringContract(t *testing.T) { +func TestNormalizeCQLValueRendersScalarsAndCQLLiterals(t *testing.T) { uuid, err := gocql.ParseUUID("00112233-4455-6677-8899-aabbccddeeff") if err != nil { t.Fatal(err) @@ -27,8 +27,15 @@ func TestNormalizeCQLValuePreservesLegacyStringContract(t *testing.T) { {time.Date(2026, 8, 3, 12, 34, 56, 7, time.UTC), "2026-08-03T12:34:56.000000007Z"}, {gocql.Duration{Months: 1, Days: 2, Nanoseconds: 3}, "1mo2d3ns"}, {[]int{1, 2}, "[1, 2]"}, - {[]any{1, nil, "three"}, "[1, null, three]"}, - {map[string]int{"b": 2, "a": 1}, "{a=1, b=2}"}, + {[]any{1, nil, "three"}, "[1, null, 'three']"}, + {[]string{"it's", "b"}, "['it''s', 'b']"}, + {map[string]int{"b": 2, "a": 1}, "{'a': 1, 'b': 2}"}, + {map[string]string{"tier": "gold", "color": "blue", "region": "eu-west"}, "{'color': 'blue', 'region': 'eu-west', 'tier': 'gold'}"}, + {map[string]any{"tags": []string{"x,y", "z=1"}, "count": int64(3)}, "{'count': 3, 'tags': ['x,y', 'z=1']}"}, + {map[int]bool{2: true, 10: false}, "{10: false, 2: true}"}, + {[]any{uuid, net.ParseIP("10.0.0.1"), time.Date(2026, 8, 3, 0, 0, 0, 0, time.UTC), []byte{0x01}}, "[00112233-4455-6677-8899-aabbccddeeff, '10.0.0.1', '2026-08-03T00:00:00Z', 0x01]"}, + {cqlTuple{1, "a", nil}, "(1, 'a', null)"}, + {map[string]any{"pos": cqlTuple{1, 2}}, "{'pos': (1, 2)}"}, } for _, test := range tests { if got := normalizeCQLValue(test.value); !reflect.DeepEqual(got, test.want) { diff --git a/apps/desktop/src/lib/dataGrid/__tests__/cqlLiteralJson.spec.ts b/apps/desktop/src/lib/dataGrid/__tests__/cqlLiteralJson.spec.ts new file mode 100644 index 0000000000..573b761ea8 --- /dev/null +++ b/apps/desktop/src/lib/dataGrid/__tests__/cqlLiteralJson.spec.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from "vitest"; +import { cqlLiteralToJsonText } from "../cqlLiteralJson"; + +describe("cqlLiteralToJsonText", () => { + it("converts map literals into JSON objects", () => { + expect(cqlLiteralToJsonText("{'color': 'blue', 'region': 'eu-west', 'tier': 'gold'}")).toBe('{"color":"blue","region":"eu-west","tier":"gold"}'); + expect(cqlLiteralToJsonText("{10: false, 2: true}")).toBe('{"10":false,"2":true}'); + expect(cqlLiteralToJsonText("{}")).toBe("{}"); + }); + + it("converts list, set and tuple literals into JSON arrays", () => { + expect(cqlLiteralToJsonText("['a', 'b']")).toBe('["a","b"]'); + expect(cqlLiteralToJsonText("{'a', 'b'}")).toBe('["a","b"]'); + expect(cqlLiteralToJsonText("(1, 'a', null)")).toBe('[1,"a",null]'); + expect(cqlLiteralToJsonText("[]")).toBe("[]"); + }); + + it("keeps CQL escapes, separators inside strings and non-text scalars", () => { + expect(cqlLiteralToJsonText("['it''s', 'x,y', 'z=1', 'a: \"b\"']")).toBe('["it\'s","x,y","z=1","a: \\"b\\""]'); + expect(cqlLiteralToJsonText("[00112233-4455-6677-8899-aabbccddeeff, '10.0.0.1', 0x01, 1mo2d3ns, 1.5, -2, 12345678901234567890]")).toBe('["00112233-4455-6677-8899-aabbccddeeff","10.0.0.1","0x01","1mo2d3ns",1.5,-2,12345678901234567890]'); + }); + + it("recurses into nested collections", () => { + expect(cqlLiteralToJsonText("{'count': 3, 'tags': ['x', 'y'], 'pos': (1, 2), 'meta': {'k': {'n': null}}}")).toBe('{"count":3,"tags":["x","y"],"pos":[1,2],"meta":{"k":{"n":null}}}'); + }); + + it("rejects text that is not a CQL collection literal", () => { + expect(cqlLiteralToJsonText("plain text")).toBeUndefined(); + expect(cqlLiteralToJsonText("{'a': 1")).toBeUndefined(); + expect(cqlLiteralToJsonText("{'a': 1} trailing")).toBeUndefined(); + expect(cqlLiteralToJsonText("['unterminated]")).toBeUndefined(); + expect(cqlLiteralToJsonText("{'a' 1}")).toBeUndefined(); + expect(cqlLiteralToJsonText("[1,, 2]")).toBeUndefined(); + }); +}); diff --git a/apps/desktop/src/lib/dataGrid/__tests__/dataGridDetail.spec.ts b/apps/desktop/src/lib/dataGrid/__tests__/dataGridDetail.spec.ts index 35b7a08fed..c5b7da0a8d 100644 --- a/apps/desktop/src/lib/dataGrid/__tests__/dataGridDetail.spec.ts +++ b/apps/desktop/src/lib/dataGrid/__tests__/dataGridDetail.spec.ts @@ -145,6 +145,34 @@ describe("data grid detail TSV", () => { }); }); +describe("Cassandra collection cell detail presentation", () => { + function detailFor(value: string, databaseType?: "cassandra" | "mysql") { + return buildDataGridCellDetail({ + rowIndex: 0, + rowId: 0, + row: [value], + columns: ["attrs"], + columnIndex: 0, + displayValue: () => value, + isEditable: false, + databaseType, + }); + } + + it("formats CQL collection literals as JSON for Cassandra results", () => { + expect(detailFor("{'color': 'blue', 'tier': 'gold'}", "cassandra")!.formattedJson).toBe('{\n "color": "blue",\n "tier": "gold"\n}'); + expect(detailFor("(1, 'a')", "cassandra")!.formattedJson).toBe('[\n 1,\n "a"\n]'); + expect(detailFor("{'a', 'b'}", "cassandra")!.formattedJson).toBe('[\n "a",\n "b"\n]'); + }); + + it("keeps plain JSON formatting and leaves other databases untouched", () => { + expect(detailFor('{"k": 1}', "cassandra")!.formattedJson).toBe('{\n "k": 1\n}'); + expect(detailFor("{'color': 'blue'}", "mysql")!.formattedJson).toBe(""); + expect(detailFor("{'color': 'blue'}")!.formattedJson).toBe(""); + expect(detailFor("not a literal", "cassandra")!.formattedJson).toBe(""); + }); +}); + describe("Mongo collection cell detail presentation", () => { it.each([null, "NULL", MONGO_DOCUMENT_GRID_NULL])("presents BSON value %j without leaking grid encoding", (bsonValue) => { const value = mongoDocumentGridValue(bsonValue) as string; diff --git a/apps/desktop/src/lib/dataGrid/cqlLiteralJson.ts b/apps/desktop/src/lib/dataGrid/cqlLiteralJson.ts new file mode 100644 index 0000000000..840f25c493 --- /dev/null +++ b/apps/desktop/src/lib/dataGrid/cqlLiteralJson.ts @@ -0,0 +1,135 @@ +/** + * Converts a CQL collection literal, as the Cassandra agent renders map / list / + * set / tuple / UDT values, into JSON text so Cell Details can format it. + * + * Examples: `{'color': 'blue'}` → `{"color":"blue"}`, `['a', 'b']` → `["a","b"]`, + * `(1, 'a')` → `[1,"a"]`, `{'a', 'b'}` (set) → `["a","b"]`. + * Returns undefined when the text is not a well-formed CQL collection literal. + */ +export function cqlLiteralToJsonText(text: string): string | undefined { + const source = text.trim(); + if (!/^[{[(]/.test(source)) return undefined; + const parser = new CqlLiteralParser(source); + try { + const json = parser.parseValue(); + parser.skipWhitespace(); + return parser.atEnd() ? json : undefined; + } catch { + return undefined; + } +} + +const NUMBER_PATTERN = /^-?\d+(\.\d+)?([eE][+-]?\d+)?$/; + +class CqlLiteralParser { + private index = 0; + + constructor(private readonly source: string) {} + + atEnd(): boolean { + return this.index >= this.source.length; + } + + skipWhitespace(): void { + while (!this.atEnd() && /\s/.test(this.source[this.index]!)) this.index++; + } + + parseValue(): string { + this.skipWhitespace(); + const char = this.source[this.index]; + if (char === undefined) throw new Error("unexpected end"); + if (char === "'") return JSON.stringify(this.parseQuoted()); + if (char === "[") return this.parseSequence("]"); + if (char === "(") return this.parseSequence(")"); + if (char === "{") return this.parseBraces(); + return this.parseBare(); + } + + private parseQuoted(): string { + this.index++; + let result = ""; + for (;;) { + const char = this.source[this.index]; + if (char === undefined) throw new Error("unterminated string"); + this.index++; + if (char !== "'") { + result += char; + continue; + } + if (this.source[this.index] === "'") { + result += "'"; + this.index++; + continue; + } + return result; + } + } + + private parseBare(): string { + const start = this.index; + while (!this.atEnd() && !/[\s,:{}[\]()]/.test(this.source[this.index]!)) this.index++; + const token = this.source.slice(start, this.index); + if (token === "") throw new Error("unexpected token"); + if (token === "null" || token === "true" || token === "false" || NUMBER_PATTERN.test(token)) return token; + return JSON.stringify(token); + } + + private parseSequence(close: string): string { + this.index++; + const items: string[] = []; + this.skipWhitespace(); + if (this.source[this.index] === close) { + this.index++; + return "[]"; + } + for (;;) { + items.push(this.parseValue()); + this.skipWhitespace(); + const char = this.source[this.index++]; + if (char === close) return `[${items.join(",")}]`; + if (char !== ",") throw new Error("expected separator"); + } + } + + private parseBraces(): string { + this.index++; + this.skipWhitespace(); + if (this.source[this.index] === "}") { + this.index++; + return "{}"; + } + const first = this.parseValue(); + this.skipWhitespace(); + if (this.source[this.index] === ":") { + this.index++; + return this.parseMapRest(first); + } + const items = [first]; + for (;;) { + const char = this.source[this.index++]; + if (char === "}") return `[${items.join(",")}]`; + if (char !== ",") throw new Error("expected separator"); + items.push(this.parseValue()); + this.skipWhitespace(); + } + } + + private parseMapRest(firstKey: string): string { + const entries: string[] = []; + let key = firstKey; + for (;;) { + entries.push(`${jsonObjectKey(key)}:${this.parseValue()}`); + this.skipWhitespace(); + const char = this.source[this.index++]; + if (char === "}") return `{${entries.join(",")}}`; + if (char !== ",") throw new Error("expected separator"); + key = this.parseValue(); + this.skipWhitespace(); + if (this.source[this.index++] !== ":") throw new Error("expected colon"); + } + } +} + +function jsonObjectKey(json: string): string { + return json.startsWith('"') ? json : JSON.stringify(json); +} diff --git a/apps/desktop/src/lib/dataGrid/dataGridDetail.ts b/apps/desktop/src/lib/dataGrid/dataGridDetail.ts index 978f847f10..05e2f6624d 100644 --- a/apps/desktop/src/lib/dataGrid/dataGridDetail.ts +++ b/apps/desktop/src/lib/dataGrid/dataGridDetail.ts @@ -2,6 +2,7 @@ import { cellImagePreviewUrl } from "@/lib/dataGrid/cellImageUrl"; import { binaryCellClipboardText } from "@/lib/dataGrid/binaryCellDownload"; import { clipboardCellValue, displayCellValue, type CellValue } from "@/lib/dataGrid/cellValue"; import { formatJsonText } from "@/lib/dataGrid/cellDetailPresentation"; +import { cqlLiteralToJsonText } from "@/lib/dataGrid/cqlLiteralJson"; import type { DatabaseType } from "@/types/database"; export const CELL_DETAIL_VALUE_PREVIEW_MAX_LENGTH = 12_000; @@ -104,7 +105,7 @@ export function buildDataGridCellDetail(options: BuildDataGridCellDetailOptions) const rawValue = options.rawValue?.(value, options.columnIndex) ?? displayCellValue(value); const isNull = options.isNullValue?.(value) ?? value === null; const displayValue = options.displayValue(value, options.columnIndex); - const formattedJson = typeof value === "string" && looksLikeJsonContainer(value) ? (formatJsonText(value) ?? "") : ""; + const formattedJson = typeof value === "string" ? detailFormattedJson(value, options.databaseType) : ""; const rawValuePreview = previewText(rawValue); const displayValuePreview = previewText(displayValue); const type = detailColumnType(options.typeByColumn, options.resultColumnTypes, column, options.columnIndex); @@ -294,6 +295,17 @@ export function filterDataGridDetailFields(fields: return fields.filter((field) => field.column.toLowerCase().includes(kw) || field.rawValuePreview.toLowerCase().includes(kw) || field.displayValuePreview.toLowerCase().includes(kw) || String(field.rowNumber).includes(kw)); } +function detailFormattedJson(value: string, databaseType: DatabaseType | undefined): string { + if (looksLikeJsonContainer(value)) { + const formatted = formatJsonText(value); + if (formatted !== undefined) return formatted; + } + if (databaseType !== "cassandra") return ""; + // The Cassandra agent renders collections as CQL literals, e.g. {'k': 'v'} or (1, 'a'). + const json = cqlLiteralToJsonText(value); + return json === undefined ? "" : (formatJsonText(json) ?? ""); +} + function looksLikeJsonContainer(text: string): boolean { const trimmed = text.trim(); return trimmed.startsWith("{") || trimmed.startsWith("[");