mirror of
https://github.com/t8y2/dbx.git
synced 2026-10-19 16:16:45 +00:00
feat(neo4j): expand node query properties into result columns
This commit is contained in:
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 |
Reference in New Issue
Block a user