feat(neo4j): expand node query properties into result columns

This commit is contained in:
Hand Sonic
2026-10-01 15:52:30 +08:00
committed by GitHub
parent 38a00c7cb7
commit 660071870b
12 changed files with 433 additions and 29 deletions
@@ -0,0 +1,50 @@
package main
import (
"encoding/json"
"reflect"
"strings"
"testing"
neo4j "github.com/neo4j/neo4j-go-driver/v6/neo4j"
)
func TestNodeResultKeepsSourceWidthAndExactPropertyValues(t *testing.T) {
node := neo4j.Node{Labels: []string{"Person"}, Props: map[string]any{
"name": "Ada", "age": int64(9007199254740993), "active": true, "scores": []any{int64(1), int64(2)},
}}
row := normalizeRecord(&neo4j.Record{Values: []any{node, int64(7), nil}}, 3)
cell, ok := row[0].(nodeCell)
if !ok || len(row) != 3 || row[1] != "7" || row[2] != nil {
t.Fatalf("unexpected record: %#v", row)
}
want := []nodeProperty{
{Name: "active", Type: "Boolean", Value: "true"},
{Name: "age", Type: "Integer", Value: "9007199254740993"},
{Name: "name", Type: "String", Value: "Ada"},
{Name: "scores", Type: "List", Value: "[1,2]"},
}
if cell.Marker != "v1" || cell.Display != formatNode(node) || !reflect.DeepEqual(cell.Properties, want) {
t.Fatalf("unexpected node cell: %#v", cell)
}
encoded, err := json.Marshal(row)
if err != nil || !strings.Contains(string(encoded), `"value":"9007199254740993"`) {
t.Fatalf("inexact wire value: %s, err=%v", encoded, err)
}
}
func TestEmptyNodeAndNonNodeValues(t *testing.T) {
node := neo4j.Node{Labels: []string{"Empty"}}
cell := normalizeNodeCell(node)
if cell.Properties == nil || len(cell.Properties) != 0 {
t.Fatalf("empty properties must serialize as []: %#v", cell)
}
for _, value := range []any{nil, "(:Person {\"name\":\"literal\"})", true, int64(42),
neo4j.Relationship{Type: "KNOWS"}, neo4j.Path{Nodes: []neo4j.Node{node}},
[]any{node}, map[string]any{"node": node}} {
row := normalizeRecord(&neo4j.Record{Values: []any{value}}, 1)
if !reflect.DeepEqual(row[0], normalizeQueryValue(value)) {
t.Fatalf("non-node result changed for %T: %#v", value, row)
}
}
}
+28 -1
View File
@@ -6,6 +6,7 @@ import (
"fmt"
"reflect"
"regexp"
"sort"
"strconv"
"strings"
"time"
@@ -242,11 +243,37 @@ func normalizeRecord(record *neo4j.Record, width int) []any {
return row
}
for index := 0; index < width && index < len(record.Values); index++ {
row[index] = normalizeQueryValue(record.Values[index])
if node, ok := record.Values[index].(neo4j.Node); ok {
row[index] = normalizeNodeCell(node)
} else {
row[index] = normalizeQueryValue(record.Values[index])
}
}
return row
}
type nodeProperty struct {
Name string `json:"name"`
Type string `json:"type"`
Value any `json:"value"`
}
type nodeCell struct {
Marker string `json:"__dbx_neo4j_node"`
Display string `json:"display"`
Properties []nodeProperty `json:"properties"`
}
// Keep the original record keys for cursor paging; the grid projects properties.
func normalizeNodeCell(node neo4j.Node) nodeCell {
properties := make([]nodeProperty, 0, len(node.Props))
for name, value := range node.Props {
properties = append(properties, nodeProperty{Name: name, Type: neo4jTypeName(value), Value: normalizeQueryValue(value)})
}
sort.Slice(properties, func(i, j int) bool { return properties[i].Name < properties[j].Name })
return nodeCell{Marker: "v1", Display: formatNode(node), Properties: properties}
}
func normalizeQueryValue(value any) any {
if value == nil {
return nil
@@ -500,6 +500,7 @@ interface DataGridProps {
sortColumnIndex?: number;
sortDirection?: DataGridSortDirection;
sortMode?: DataGridSortMode;
databaseSortEnabled?: boolean;
tableMeta?: {
catalog?: string;
database?: string;
@@ -599,6 +600,7 @@ const props = withDefaults(defineProps<DataGridProps>(), {
totalRowCountIsExact: true,
inexactTotalRowCountMode: "at-least",
paginationEnabled: true,
databaseSortEnabled: true,
loadAllRowsEnabled: true,
// Omitted row-action limits must keep normal table-data editing.
allowInsertRows: undefined,
@@ -922,7 +924,7 @@ function sortMenuItems(column: string, columnIndex: number) {
column,
columnIndex,
state: currentColumnSortState(),
databaseSortEnabled: databaseSortSupportedForDatabase(resolvedDatabaseType.value),
databaseSortEnabled: props.databaseSortEnabled !== false && databaseSortSupportedForDatabase(resolvedDatabaseType.value),
labels: {
databaseAscending: t("grid.sortDatabaseAscending"),
databaseDescending: t("grid.sortDatabaseDescending"),
@@ -12358,7 +12360,7 @@ const gridContextMenuItems = computed<ContextMenuItem[]>(() => {
canFilter: canUseWhereSearch.value,
hasSort: !!sortCol.value,
sortMode: sortMode.value,
databaseSortEnabled: databaseSortSupportedForDatabase(resolvedDatabaseType.value),
databaseSortEnabled: props.databaseSortEnabled !== false && databaseSortSupportedForDatabase(resolvedDatabaseType.value),
frozenColumnCount: frozenColumnCount.value,
contextVisibleColIdx: contextHeaderVisibleColIdx.value ?? undefined,
hasColumnSelection: hasColumnSelection.value,
@@ -75,7 +75,7 @@ function transposeRecordHeader(host: HTMLElement, recordIndex: number): HTMLElem
return header;
}
function mountGrid() {
function mountGrid(databaseSortEnabled?: boolean) {
const pinia = createPinia();
setActivePinia(pinia);
const settingsStore = useSettingsStore();
@@ -121,6 +121,7 @@ function mountGrid() {
h(DataGrid, {
result: result.value,
databaseType: "mysql",
databaseSortEnabled,
context: "table-data",
sortColumn: sortColumn.value,
sortColumnIndex: sortColumnIndex.value,
@@ -174,6 +175,21 @@ afterEach(() => {
});
describe("DataGrid transpose sorting", () => {
it("retains local sorting when database sorting is explicitly disabled", async () => {
const { host, onSort } = mountGrid(false);
await settle();
await openTranspose(host);
transposeField(host, 1).querySelector<HTMLButtonElement>("[data-grid-transpose-sort]")!.click();
await settle();
const labels = [...document.querySelectorAll('[role="menuitem"]')].map((item) => item.textContent?.trim());
expect(labels).toContain(i18n.global.t("grid.sortCurrentPageAscending"));
expect(labels).not.toContain(i18n.global.t("grid.sortDatabaseDescending"));
menuAction(i18n.global.t("grid.sortCurrentPageAscending")).click();
await settle();
expect(onSort).toHaveBeenLastCalledWith("score", 1, "asc", undefined, "local");
expect(transposeValues(host, 1)).toEqual(["2", "10", "NULL"]);
});
it("uses the shared local/database sort actions and keeps the active record and row selection", async () => {
const { host, onSort } = mountGrid();
await settle();
@@ -4,6 +4,8 @@ import { safeLocalStorageGet, safeLocalStorageSet } from "@/lib/backend/safeStor
import { appendDebugLog, isDebugLoggingEnabled } from "@/lib/backend/debugLog";
import { canReloadUnavailableDataTab, restoredDataTabReloadFilters } from "@/lib/table/tableDataRefresh";
import { defaultViewForResult } from "@/lib/query/queryResultDefaultView";
import { extractNeo4jNodeCells, projectNeo4jNodeResult } from "@/lib/neo4j/neo4jNodeResult";
import { useNeo4jNodeTableResult } from "@/composables/useNeo4jNodeTableResult";
import { queryResultMessages } from "@/lib/query/queryResultMessages";
import { isQueryExecutionErrorResult } from "@/lib/query/queryResultError";
import { hasQueryOutput as tabHasQueryOutput } from "@/lib/query/queryOutput";
@@ -513,7 +515,7 @@ const tabularResults = computed(() => tabularResultItems(props.activeTab.results
const allResultExportSheets = computed(() =>
tabularResults.value.map((item) => ({
sheetName: item.label || t("tabs.resultN", { n: item.n }),
result: item.result,
result: activeEffectiveDatabaseType.value === "neo4j" ? projectNeo4jNodeResult(item.result) : item.result,
sql: item.index === props.activeTab.activeResultIndex ? queryResultExecutionSql(props.activeTab) : item.result.sourceStatement,
})),
);
@@ -607,6 +609,26 @@ const resultRunFallbackLabel = (sequence: number) => t(resultTabNamingMode.value
const activeResultGridCacheKey = computed(() => resultGridCacheKey(props.activeTab));
const activeResultGridColumnWidthCacheKey = computed(() => resultGridColumnWidthCacheKey(props.activeTab));
const activeResultGridInstanceKey = computed(() => resultGridInstanceKey(props.activeTab));
const hasNeo4jNodes = computed(() => activeEffectiveDatabaseType.value === "neo4j" && !!props.activeTab.result?.neo4j_node_cells?.length);
const neo4jNodeTable = useNeo4jNodeTableResult(
computed(() => props.activeTab.result),
activeResultGridInstanceKey,
);
const activeGridResult = computed(() => (hasNeo4jNodes.value ? neo4jNodeTable.result.value : props.activeTab.result));
const activeGridSort = computed(() => (hasNeo4jNodes.value ? neo4jNodeTable.sort.value : undefined));
function sortQueryGrid(column: string, columnIndex: number, direction: "asc" | "desc" | null, whereInput?: string, mode?: DataGridSortMode, effectiveOrderBy?: string) {
if (hasNeo4jNodes.value) neo4jNodeTable.setSort(column, columnIndex, direction);
else emit("sort", props.activeTab.id, column, columnIndex, direction, whereInput, mode, effectiveOrderBy);
}
async function fetchGridResultForExport(onProgress?: (info: { rowsExported: number; totalRows: number | null }) => void) {
const isNeo4j = activeEffectiveDatabaseType.value === "neo4j";
const result = await queryStore.fetchTabResultForExport(props.activeTab.id, onProgress);
if (!result || !isNeo4j) return result;
extractNeo4jNodeCells(result);
return projectNeo4jNodeResult(result);
}
const activeResultSql = computed(() => resultSqlForGrid(props.activeTab));
const activeResultExportSql = computed(() => queryResultExecutionSql(props.activeTab));
const activeStatementExecutionMarkers = computed(() =>
@@ -2422,21 +2444,22 @@ defineExpose({
:pending-state-key="activeResultGridInstanceKey"
:view-generation="activeTab.resultViewGeneration"
class="flex-1 min-h-0"
:result="activeTab.result"
:sort-column="activeTab.resultSortColumn"
:sort-column-index="activeTab.resultSortColumnIndex"
:sort-direction="activeTab.resultSortDirection"
:sort-mode="activeTab.resultSortMode"
:result="activeGridResult!"
:sort-column="hasNeo4jNodes ? activeGridSort?.column : activeTab.resultSortColumn"
:sort-column-index="hasNeo4jNodes ? activeGridSort?.columnIndex : activeTab.resultSortColumnIndex"
:sort-direction="hasNeo4jNodes ? activeGridSort?.direction : activeTab.resultSortDirection"
:sort-mode="hasNeo4jNodes ? 'local' : activeTab.resultSortMode"
:database-sort-enabled="!hasNeo4jNodes"
:initial-order-by-input="activeTab.orderByInput"
:sql="activeResultSql"
:export-sql="activeResultExportSql"
:loading="activeResultIsLoading"
:editable="!!activeTab.queryAnalysis || !!mongoQueryResultSaveHandler"
:source-columns="activeTab.querySourceColumns"
:joined-write-targets="activeTab.queryWriteTargets"
:readonly-column-indexes="groupedQueryReadonlyColumnIndexes(activeTab)"
:result-column-comments="activeTab.resultColumnComments"
:query-display-source-columns="activeTab.queryDisplaySourceColumns"
:editable="!hasNeo4jNodes && (!!activeTab.queryAnalysis || !!mongoQueryResultSaveHandler)"
:source-columns="hasNeo4jNodes ? undefined : activeTab.querySourceColumns"
:joined-write-targets="hasNeo4jNodes ? undefined : activeTab.queryWriteTargets"
:readonly-column-indexes="hasNeo4jNodes ? undefined : groupedQueryReadonlyColumnIndexes(activeTab)"
:result-column-comments="hasNeo4jNodes ? undefined : activeTab.resultColumnComments"
:query-display-source-columns="hasNeo4jNodes ? undefined : activeTab.queryDisplaySourceColumns"
:custom-save-handler="mongoQueryResultSaveHandler"
:mongo-update-target="mongoQueryResultSaveHandler && activeTab.result.mongo_copy_documents?.length === activeTab.result.rows.length ? activeTab.mongoEditTarget : undefined"
:query-editability-reason="activeTab.queryEditabilityReason"
@@ -2451,7 +2474,7 @@ defineExpose({
:connection-id="activeResultConnectionId"
:database="activeResultDatabase"
:schema="activeResultSchema"
:table-meta="activeTab.tableMeta"
:table-meta="hasNeo4jNodes ? undefined : activeTab.tableMeta"
:table-info-tab="activeTab.tableInfoTab"
:page-offset="activeTab.resultPageOffset"
:page-limit="activeTab.resultPageLimit"
@@ -2464,18 +2487,20 @@ defineExpose({
:total-row-count-loading="activeTab.resultTotalRowCountLoading"
:page-jump-progress="activeTab.resultPageJumpProgress"
:on-execute-sql="async (sql: string) => emit('executeSql', activeTab.id, sql)"
:full-export-result="(onProgress?: (info: { rowsExported: number; totalRows: number | null }) => void) => queryStore.fetchTabResultForExport(activeTab.id, onProgress)"
:full-export-result="fetchGridResultForExport"
:query-result-export-request="
(options: {
exportId: string;
filePath: string;
format: 'csv' | 'xlsx' | 'json' | 'txt' | 'sql';
includeSqlSheet?: boolean;
exportTableName?: string;
exportColumnTypes?: Array<string | null | undefined>;
exportColumnExtras?: Array<string | null | undefined>;
insertMode?: SqlInsertMode;
}) => queryStore.buildQueryResultExportRequest(activeTab.id, options)
hasNeo4jNodes
? undefined
: (options: {
exportId: string;
filePath: string;
format: 'csv' | 'xlsx' | 'json' | 'txt' | 'sql';
includeSqlSheet?: boolean;
exportTableName?: string;
exportColumnTypes?: Array<string | null | undefined>;
exportColumnExtras?: Array<string | null | undefined>;
insertMode?: SqlInsertMode;
}) => queryStore.buildQueryResultExportRequest(activeTab.id, options)
"
:all-export-results="allResultExportSheets"
:export-file-base-name="activeQueryResultExportBaseName"
@@ -2487,7 +2512,7 @@ defineExpose({
@local-column-filters-change="(filters: Record<string, string[]>) => queryStore.updateDataGridLocalColumnFilters(activeTab.id, filters)"
@reload="(sql?: string, searchText?: string, whereInput?: string, orderBy?: string, limit?: number, offset?: number, intent?: DataGridReloadIntent) => emit('reload', activeTab.id, sql, searchText, whereInput, orderBy, limit, offset, intent)"
@paginate="(offset: number, limit: number, whereInput?: string, orderBy?: string, appendResult?: boolean) => emit('paginate', activeTab.id, offset, limit, whereInput, orderBy, appendResult)"
@sort="(column: string, columnIndex: number, direction: 'asc' | 'desc' | null, whereInput?: string, mode?: DataGridSortMode, effectiveOrderBy?: string) => emit('sort', activeTab.id, column, columnIndex, direction, whereInput, mode, effectiveOrderBy)"
@sort="sortQueryGrid"
@change-query-timeout="(connectionId: string) => emit('openConnectionSettings', connectionId, 'advanced')"
>
<template #result-toolbar-leading="{ compact }">
@@ -0,0 +1,28 @@
import { computed, ref, watch, type ComputedRef } from "vue";
import type { QueryResult } from "@/types/database";
import { projectNeo4jNodeResult } from "@/lib/neo4j/neo4jNodeResult";
import { sortDataGridRowIndexes, type DataGridSortDirection } from "@/lib/dataGrid/dataGridSort";
export function useNeo4jNodeTableResult(source: ComputedRef<QueryResult | undefined>, ownerKey: ComputedRef<string>) {
const sort = ref<{ column: string; columnIndex: number; direction: DataGridSortDirection }>();
watch(ownerKey, () => (sort.value = undefined));
const projected = computed(() => (source.value ? projectNeo4jNodeResult(source.value) : undefined));
const result = computed(() => {
const result = projected.value;
const state = sort.value;
if (!result || !state || result.columns[state.columnIndex] !== state.column) return result;
const indexes = sortDataGridRowIndexes(result.rows, state.columnIndex, state.direction, result.column_types?.[state.columnIndex]);
const rowIndexMap = new Map(indexes.map((sourceIndex, targetIndex) => [sourceIndex, targetIndex]));
return {
...result,
rows: indexes.map((index) => result.rows[index]!),
spatial_values: result.spatial_values ? indexes.map((index) => result.spatial_values![index]!) : undefined,
large_value_cells: result.large_value_cells?.map((cell) => ({ ...cell, row_index: rowIndexMap.get(cell.row_index)! })),
appended_from_row_count: undefined,
};
});
function setSort(column: string, columnIndex: number, direction: DataGridSortDirection | null) {
sort.value = direction ? { column, columnIndex, direction } : undefined;
}
return { result, sort, setSort };
}
@@ -0,0 +1,129 @@
import { computed, effectScope, nextTick, ref } from "vue";
import { describe, expect, it } from "vitest";
import { extractNeo4jNodeCells, projectNeo4jNodeResult, type Neo4jNodeProperty } from "@/lib/neo4j/neo4jNodeResult";
import { useNeo4jNodeTableResult } from "@/composables/useNeo4jNodeTableResult";
import { appendQueryResultSegment } from "@/stores/queryStore";
import { buildTabResultSnapshot, decodeTabResultSnapshot, encodeTabResultSnapshot } from "@/lib/tabs/tabResultCache";
import type { QueryResult, QueryTab } from "@/types/database";
function node(properties: Neo4jNodeProperty[], display = "(:QA)") {
return { __dbx_neo4j_node: "v1", display, properties };
}
function property(name: string, value: string, type = "String"): Neo4jNodeProperty {
return { name, type, value };
}
function result(columns: string[], rows: unknown[][]): QueryResult {
const value = { columns, rows, affected_rows: 0, execution_time_ms: 1 } as QueryResult;
extractNeo4jNodeCells(value);
return value;
}
describe("Neo4j node table projection", () => {
it("qualifies multiple node aliases, keeps scalars and exact integers, and fills absent properties with null", () => {
const source = result(
["p", "c", "score"],
[
[node([property("name", "Ada"), property("age", "9007199254740993", "Integer")]), node([property("name", "QA Labs")]), "7"],
[node([property("name", "Bob")]), null, "8"],
],
);
const projected = projectNeo4jNodeResult(source);
expect(projected.columns).toEqual(["p", "c", "score", "p.name", "p.age", "c.name"]);
expect(projected.rows).toEqual([
["(:QA)", "(:QA)", "7", "Ada", "9007199254740993", "QA Labs"],
["(:QA)", null, "8", "Bob", null, null],
]);
expect(projected.hidden_column_indexes).toEqual([0, 1]);
expect(projected.column_types?.[4]).toBe("Integer");
expect(source.columns).toEqual(["p", "c", "score"]);
expect(source.rows[0]).toEqual(["(:QA)", "(:QA)", "7"]);
expect(projectNeo4jNodeResult(projected)).toBe(projected);
});
it("retains empty and mixed node/scalar source cells, including node-looking strings", () => {
const literal = JSON.stringify(node([property("fake", "no")]));
const source = result(["n"], [[node([property("value", "42", "Integer")])], [node([])], [literal], [null], [node([property("value", "text")])]]);
const projected = projectNeo4jNodeResult(source);
expect(projected.hidden_column_indexes).toEqual([]);
expect(projected.rows[2]).toEqual([literal, null]);
expect(projected.column_types?.[1]).toBe("Any");
expect(projectNeo4jNodeResult(result(["n"], [[node([])]]))).toMatchObject({ columns: ["n"], rows: [["(:QA)"]] });
});
it("disambiguates existing aliases, dots and prototype-like property names without losing values", () => {
const projected = projectNeo4jNodeResult(result(["n", "n.name", "n.name (2)"], [[node([property("name", "Ada"), property("__proto__", "safe"), property("a.b", "dot")]), "scalar", "another"]]));
expect(projected.columns).toEqual(["n", "n.name", "n.name (2)", "n.name (3)", "n.__proto__", "n.a.b"]);
expect(projected.rows[0]?.slice(3)).toEqual(["Ada", "safe", "dot"]);
});
it("does not normalize unknown versions, malformed envelopes or ordinary scalar results", () => {
const envelopes = [{ ...node([]), __dbx_neo4j_node: "v2" }, node([{ ...property("bad", "x"), value: {} } as unknown as Neo4jNodeProperty]), node([property("a", "x"), property("a", "y")])];
const source = result(
["n"],
envelopes.map((value) => [value]),
);
expect(source.neo4j_node_cells).toBeUndefined();
expect(source.rows).toEqual(envelopes.map((value) => [value]));
const scalar = result(["name"], [["Ada"], [null]]);
expect(projectNeo4jNodeResult(scalar)).toBe(scalar);
});
it("keeps generated ordinals stable across cursor pages and clips metadata at the row cap", () => {
const first = result(["n"], [[null], [node([property("name", "Ada")])]]);
const second = { columns: ["n"], rows: [[node([property("late", "yes")])], [node([property("discarded", "no")])]], affected_rows: 0, execution_time_ms: 1, has_more: true } as QueryResult;
const merged = appendQueryResultSegment(first, second, 3);
expect(merged.columns).toEqual(["n"]);
expect(merged.neo4j_node_cells?.map((cell) => cell.row_index)).toEqual([1, 2]);
const projected = projectNeo4jNodeResult(merged);
expect(projected.columns).toEqual(["n", "n.name", "n.late"]);
expect(projected.rows).toEqual([
[null, null, null],
["(:QA)", "Ada", null],
["(:QA)", null, "yes"],
]);
expect(merged.has_more).toBe(false);
const emptyFirst = appendQueryResultSegment(result(["n"], [[null]]), result(["n"], [[node([property("new", "yes")])]]), 10);
expect(projectNeo4jNodeResult(emptyFirst).rows).toEqual([
[null, null],
["(:QA)", "yes"],
]);
});
it("retains typed properties through active and historical result cache restore without cursor ids", () => {
const source = { ...result(["n"], [[node([property("age", "9007199254740993", "Integer")])]]), session_id: "private-cursor" };
const tab = { result: source, results: [source], resultRuns: [{ id: "run", title: "Run", sequence: 1, sql: "RETURN n", createdAt: 1, result: source, results: [source] }] } as QueryTab;
const snapshot = buildTabResultSnapshot(tab)!;
expect(snapshot.result?.neo4j_node_cells).not.toBe(source.neo4j_node_cells);
const restored = decodeTabResultSnapshot(encodeTabResultSnapshot(snapshot));
for (const value of [restored.result, restored.results?.[0], restored.resultRuns?.[0].result, restored.resultRuns?.[0].results?.[0]]) {
expect(value?.session_id).toBeUndefined();
expect(projectNeo4jNodeResult(value!).rows).toEqual([["(:QA)", "9007199254740993"]]);
}
});
it("sorts only presentation rows with exact integers and resets on result changes", async () => {
const scope = effectScope();
const source = ref(result(["n"], [[node([property("age", "9007199254740993", "Integer")])], [node([property("age", "9007199254740992", "Integer")])]]));
source.value.large_value_cells = [{ row_index: 0, column_index: 0, original_bytes: 100 }];
const key = ref("first");
const table = scope.run(() =>
useNeo4jNodeTableResult(
computed(() => source.value),
computed(() => key.value),
),
)!;
table.setSort("n.age", 1, "asc");
expect(table.result.value?.rows.map((row) => row[1])).toEqual(["9007199254740992", "9007199254740993"]);
expect(table.result.value?.large_value_cells?.[0].row_index).toBe(1);
expect(source.value.neo4j_node_cells?.[0].properties[0].value).toBe("9007199254740993");
table.setSort("n.age", 1, null);
expect(table.result.value?.rows[0]?.[1]).toBe("9007199254740993");
table.setSort("n.age", 1, "desc");
key.value = "second";
await nextTick();
expect(table.sort.value).toBeUndefined();
scope.stop();
});
});
@@ -0,0 +1,114 @@
import type { QueryResult } from "@/types/database";
type CellValue = QueryResult["rows"][number][number];
export interface Neo4jNodeProperty {
name: string;
type: string;
value: CellValue;
}
export interface Neo4jNodeCell {
row_index: number;
column_index: number;
properties: Neo4jNodeProperty[];
}
function isCellValue(value: unknown): value is CellValue {
return value === null || typeof value === "string" || typeof value === "boolean" || (typeof value === "number" && Number.isFinite(value));
}
function nodeEnvelope(value: unknown): { display: string; properties: Neo4jNodeProperty[] } | undefined {
if (!value || typeof value !== "object") return;
const cell = value as Record<string, unknown>;
if (cell.__dbx_neo4j_node !== "v1" || typeof cell.display !== "string" || !Array.isArray(cell.properties)) return;
const names = new Set<string>();
for (const value of cell.properties) {
if (!value || typeof value !== "object") return;
const property = value as Record<string, unknown>;
if (typeof property.name !== "string" || typeof property.type !== "string" || !isCellValue(property.value) || names.has(property.name)) return;
names.add(property.name);
}
return { display: cell.display, properties: cell.properties as Neo4jNodeProperty[] };
}
/** Normalize typed wire cells once, keeping primitive rows and original columns. */
export function extractNeo4jNodeCells(result: QueryResult): void {
let rows: QueryResult["rows"] | undefined;
let cells: Neo4jNodeCell[] | undefined;
result.rows.forEach((row, row_index) => {
row.forEach((value, column_index) => {
if (column_index >= result.columns.length) return;
const cell = nodeEnvelope(value);
if (!cell) return;
rows ??= result.rows.map((row) => [...row]);
cells ??= [...(result.neo4j_node_cells ?? [])];
rows[row_index]![column_index] = cell.display;
cells.push({ row_index, column_index, properties: cell.properties });
});
});
if (rows) result.rows = rows;
if (cells) result.neo4j_node_cells = cells;
}
export function appendNeo4jNodeCells(previous: QueryResult, segment: QueryResult, appendedRowCount: number): Neo4jNodeCell[] | undefined {
const cells = [...(previous.neo4j_node_cells ?? []), ...(segment.neo4j_node_cells ?? []).filter((cell) => cell.row_index < appendedRowCount).map((cell) => ({ ...cell, row_index: cell.row_index + previous.rows.length }))];
return cells.length ? cells : undefined;
}
/** Append alias-qualified properties without changing source ordinals or cursors. */
export function projectNeo4jNodeResult(result: QueryResult): QueryResult {
if (!result.neo4j_node_cells?.length) return result;
const columns = [...result.columns];
const types = result.columns.map((_, index) => result.column_types?.[index] ?? "Unknown");
const usedNames = new Set(columns);
const propertiesByColumn = new Map<number, Map<string, number>>();
const nodeRowsByColumn = new Map<number, Set<number>>();
const keepOriginal = new Set<number>();
const propertyTypes = new Map<number, Set<string>>();
const cells = result.neo4j_node_cells.filter((cell) => cell.row_index >= 0 && cell.row_index < result.rows.length && cell.column_index >= 0 && cell.column_index < result.columns.length);
for (const cell of cells) {
const source = cell.column_index;
let nodeRows = nodeRowsByColumn.get(source);
if (!nodeRows) nodeRowsByColumn.set(source, (nodeRows = new Set()));
nodeRows.add(cell.row_index);
if (cell.properties.length === 0) keepOriginal.add(source);
let properties = propertiesByColumn.get(source);
if (!properties) propertiesByColumn.set(source, (properties = new Map()));
for (const property of cell.properties) {
let index = properties.get(property.name);
if (index === undefined) {
const base = `${result.columns[source]}.${property.name}`;
let name = base;
for (let suffix = 2; usedNames.has(name); suffix++) name = `${base} (${suffix})`;
index = columns.length;
columns.push(name);
usedNames.add(name);
properties.set(property.name, index);
propertyTypes.set(index, new Set());
}
if (property.type !== "Null") propertyTypes.get(index)!.add(property.type);
}
}
if (columns.length === result.columns.length) return result;
for (const [index, observed] of propertyTypes) types[index] = observed.size === 1 ? [...observed][0]! : observed.size === 0 ? "Null" : "Any";
const rows = result.rows.map((row) => [...row, ...Array<CellValue>(columns.length - result.columns.length).fill(null)]);
for (const cell of cells) {
for (const property of cell.properties) rows[cell.row_index]![propertiesByColumn.get(cell.column_index)!.get(property.name)!] = property.value;
}
const hidden = new Set(result.hidden_column_indexes);
for (const [source, nodeRows] of nodeRowsByColumn) {
// Empty nodes and mixed scalar/node columns must retain their source value.
if (!keepOriginal.has(source) && result.rows.every((row, index) => row[source] == null || nodeRows.has(index))) hidden.add(source);
}
return {
...result,
columns,
column_types: types,
column_sortables: columns.map((_, index) => result.column_sortables?.[index] ?? true),
hidden_column_indexes: [...hidden],
rows,
neo4j_node_cells: undefined,
spatial_values: result.spatial_values?.map((row) => [...row, ...Array<number | null>(columns.length - result.columns.length).fill(null)]),
};
}
@@ -58,6 +58,7 @@ export interface TabResultSnapshot {
interface ColumnarQueryResult {
columns: string[];
neo4j_node_cells?: QueryResult["neo4j_node_cells"];
spatial_columns?: QueryResult["spatial_columns"];
spatial_values?: QueryResult["spatial_values"];
large_value_cells?: QueryResult["large_value_cells"];
@@ -355,6 +356,7 @@ function stripSessionIds(result: QueryResult | undefined): QueryResult | undefin
if (!result) return undefined;
return {
columns: [...result.columns],
neo4j_node_cells: result.neo4j_node_cells ? clonePlain(result.neo4j_node_cells) : undefined,
execution_error: result.execution_error,
statement_index: result.statement_index,
column_types: result.column_types ? [...result.column_types] : undefined,
@@ -420,6 +422,7 @@ function toColumnarResult(result: QueryResult | undefined): ColumnarQueryResult
}
const metadata = removeUndefinedFields({
columns: [...result.columns],
neo4j_node_cells: result.neo4j_node_cells ? clonePlain(result.neo4j_node_cells) : undefined,
execution_error: result.execution_error,
statement_index: result.statement_index,
column_types: result.column_types ? [...result.column_types] : undefined,
@@ -457,6 +460,7 @@ function fromColumnarResult(result: ColumnarQueryResult | undefined): QueryResul
const rows = Array.from({ length: result.rowCount }, (_, rowIndex) => result.columnValues.map((values) => values[rowIndex] ?? null));
return {
columns: [...result.columns],
neo4j_node_cells: result.neo4j_node_cells ? clonePlain(result.neo4j_node_cells) : undefined,
execution_error: result.execution_error,
statement_index: result.statement_index,
column_types: result.column_types ? [...result.column_types] : undefined,
+7
View File
@@ -1,4 +1,5 @@
import { createQueryRequestTiming } from "@/lib/queryRequestTiming";
import { appendNeo4jNodeCells, extractNeo4jNodeCells } from "@/lib/neo4j/neo4jNodeResult";
import { UPDATE_RESTORE_KEY, assertUpdateAllowsInteraction } from "@/lib/app/updatePreparation";
import { defineStore } from "pinia";
import { isRedisMonitorCommand, startRedisMonitor } from "@/lib/redis/redisMonitor";
@@ -305,7 +306,9 @@ function droppedTableObjectSchemaCandidates(target: DroppedTableObjectTarget): S
}
function markQueryResultRowsRaw(result: QueryResult): QueryResult {
extractNeo4jNodeCells(result);
markRaw(result.rows);
if (result.neo4j_node_cells) markRaw(result.neo4j_node_cells);
if (result.large_value_cells) markRaw(result.large_value_cells);
if (result.mongo_documents) markRaw(result.mongo_documents);
if (result.mongo_copy_documents) markRaw(result.mongo_copy_documents);
@@ -333,6 +336,8 @@ export function appendQueryResultSegment(previous: QueryResult, segment: QueryRe
throw new Error("Result columns changed while loading the next segment");
}
const remainingRows = Math.max(0, maxRows - previous.rows.length);
markQueryResultRowsRaw(previous);
markQueryResultRowsRaw(segment);
const appendedRowCount = Math.min(remainingRows, segment.rows.length);
const appendParallelValues = <T>(existing: T[] | undefined, next: T[] | undefined): T[] | undefined => {
if (!existing || !next) return undefined;
@@ -356,6 +361,7 @@ export function appendQueryResultSegment(previous: QueryResult, segment: QueryRe
...segment,
appended_from_row_count: previous.rows.length,
rows: [...previous.rows, ...segment.rows.slice(0, appendedRowCount)],
neo4j_node_cells: appendNeo4jNodeCells(previous, segment, appendedRowCount),
spatial_columns: spatial_columns.length > 0 ? spatial_columns : undefined,
spatial_values: appendParallelValues(previous.spatial_values, segment.spatial_values),
large_value_cells: appendLargeValueCells(previous.large_value_cells, segment.large_value_cells, previous.rows.length, appendedRowCount),
@@ -399,6 +405,7 @@ function releaseResultObjectPayload(result: QueryResult): void {
result.local_column_filters = undefined;
result.local_hidden_column_keys = undefined;
result.mongo_documents = undefined;
result.neo4j_node_cells = undefined;
result.mongo_copy_documents = undefined;
result.large_value_cells = undefined;
result.elasticsearch_raw_body = undefined;
+2
View File
@@ -1270,6 +1270,8 @@ export type QueryResultSourceLabelKind = "source" | "comment";
export interface QueryResult {
columns: string[];
/** Typed Neo4j node properties; source columns remain unchanged for paging. */
neo4j_node_cells?: import("@/lib/neo4j/neo4jNodeResult").Neo4jNodeCell[];
/** One SRID per geometry/geography column (first non-null observed). */
spatial_columns?: SpatialColumn[];
/**
Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB