fix(cassandra): render collection values as CQL literals
This commit is contained in:
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("[");
|
||||
|
||||
Reference in new issue
Block a user