fix(cassandra): render collection values as CQL literals

This commit is contained in:
thanhthai3010 authored and GitHub committed 2026-09-21 16:39:32 +08:00
1 parent 32389f7dc2
commit 3b58269ca5
7 files changed
+268 -14

No files matched your search

@@ -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();
});
});
@@ -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;
@@ -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);
}
@@ -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<T extends DataGridCellDetail>(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("[");