Skip to content

Commit da5778d

Browse files
authored
Merge pull request #153 from TabularisDB/fix/query-builder
feat(query-builder): add mini result grid, schema hook, and layout util
2 parents 8cbf542 + 8c79525 commit da5778d

5 files changed

Lines changed: 280 additions & 0 deletions

File tree

Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
1+
import { useRef, useState, useCallback, useMemo } from 'react';
2+
import { useVirtualizer } from '@tanstack/react-virtual';
3+
import { ArrowUpDown, Copy, Loader2 } from 'lucide-react';
4+
import { copyTextToClipboard } from '../../utils/clipboard';
5+
6+
interface MiniResultGridProps {
7+
columns: string[];
8+
rows: unknown[][];
9+
loading?: boolean;
10+
message?: string;
11+
}
12+
13+
export function MiniResultGrid({ columns, rows, loading, message }: MiniResultGridProps) {
14+
const parentRef = useRef<HTMLDivElement>(null);
15+
const [sortCol, setSortCol] = useState<string | null>(null);
16+
const [sortDir, setSortDir] = useState<'asc' | 'desc'>('asc');
17+
18+
const handleSort = useCallback((col: string) => {
19+
if (sortCol === col) {
20+
setSortDir((d) => (d === 'asc' ? 'desc' : 'asc'));
21+
} else {
22+
setSortCol(col);
23+
setSortDir('asc');
24+
}
25+
}, [sortCol]);
26+
27+
const displayRows = useMemo(() => {
28+
if (!sortCol) return rows;
29+
const idx = columns.indexOf(sortCol);
30+
if (idx === -1) return rows;
31+
const sorted = [...rows].sort((a, b) => {
32+
const av = a[idx] ?? '';
33+
const bv = b[idx] ?? '';
34+
if (av < bv) return sortDir === 'asc' ? -1 : 1;
35+
if (av > bv) return sortDir === 'asc' ? 1 : -1;
36+
return 0;
37+
});
38+
return sorted;
39+
}, [rows, sortCol, sortDir, columns]);
40+
41+
const virtualizer = useVirtualizer({
42+
count: displayRows.length,
43+
getScrollElement: () => parentRef.current,
44+
estimateSize: () => 32,
45+
overscan: 10,
46+
});
47+
48+
if (loading) {
49+
return (
50+
<div className="h-full flex items-center justify-center text-muted gap-2">
51+
<Loader2 size={18} className="animate-spin" />
52+
<span className="text-sm">Running query...</span>
53+
</div>
54+
);
55+
}
56+
57+
if (message) {
58+
return (
59+
<div className="h-full flex items-center justify-center text-muted text-sm">
60+
{message}
61+
</div>
62+
);
63+
}
64+
65+
if (columns.length === 0) {
66+
return (
67+
<div className="h-full flex items-center justify-center text-muted text-sm">
68+
No results
69+
</div>
70+
);
71+
}
72+
73+
return (
74+
<div className="h-full flex flex-col overflow-hidden text-sm">
75+
<div className="flex-1 overflow-auto" ref={parentRef}>
76+
<table className="w-full border-collapse">
77+
<thead className="sticky top-0 z-10 bg-elevated">
78+
<tr>
79+
{columns.map((col) => (
80+
<th
81+
key={col}
82+
className="text-left px-3 py-1.5 text-xs font-semibold text-secondary border-b border-strong whitespace-nowrap cursor-pointer select-none hover:text-primary transition-colors"
83+
onClick={() => handleSort(col)}
84+
>
85+
<div className="flex items-center gap-1">
86+
{col}
87+
<ArrowUpDown size={12} className={sortCol === col ? 'text-blue-400' : 'text-muted opacity-50'} />
88+
</div>
89+
</th>
90+
))}
91+
</tr>
92+
</thead>
93+
<tbody>
94+
<tr style={{ height: virtualizer.getTotalSize() }}>
95+
<td colSpan={columns.length} className="p-0">
96+
<div style={{ position: 'relative', height: `${virtualizer.getTotalSize()}px` }}>
97+
{virtualizer.getVirtualItems().map((virtualRow) => {
98+
const row = displayRows[virtualRow.index];
99+
return (
100+
<div
101+
key={virtualRow.key}
102+
className="flex absolute left-0 w-full border-b border-strong/30 hover:bg-surface-secondary/50 transition-colors"
103+
style={{
104+
height: `${virtualRow.size}px`,
105+
transform: `translateY(${virtualRow.start}px)`,
106+
}}
107+
>
108+
{columns.map((col, colIdx) => {
109+
const value = row[colIdx];
110+
const display = value === null ? 'NULL' : String(value);
111+
return (
112+
<div
113+
key={col}
114+
className="px-3 py-1.5 text-primary whitespace-nowrap overflow-hidden text-ellipsis flex-1 min-w-[80px]"
115+
title={display}
116+
>
117+
<span className={value === null ? 'text-muted italic' : ''}>{display}</span>
118+
</div>
119+
);
120+
})}
121+
</div>
122+
);
123+
})}
124+
</div>
125+
</td>
126+
</tr>
127+
</tbody>
128+
</table>
129+
</div>
130+
<div className="px-3 py-1.5 text-xs text-muted border-t border-strong bg-elevated flex items-center justify-between shrink-0">
131+
<span>{rows.length} rows</span>
132+
<button
133+
onClick={() => copyTextToClipboard(columns.join('\t') + '\n' + rows.map((r) => r.join('\t')).join('\n'))}
134+
className="flex items-center gap-1 hover:text-primary transition-colors"
135+
title="Copy all"
136+
>
137+
<Copy size={12} />
138+
Copy
139+
</button>
140+
</div>
141+
</div>
142+
);
143+
}

src/hooks/useSchemaMetadata.ts

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
import { useCallback, useState } from 'react';
2+
import type { TableSchema, ForeignKey } from '../types/editor';
3+
4+
interface SchemaMetadata {
5+
schema: TableSchema[] | null;
6+
getForeignKeys: (tableName: string) => ForeignKey[];
7+
getTableColumns: (tableName: string) => { name: string; type: string }[];
8+
loadSchema: (fetcher: () => Promise<TableSchema[]>) => Promise<void>;
9+
isLoaded: boolean;
10+
}
11+
12+
/**
13+
* Hook to cache and query schema metadata for the query builder.
14+
*/
15+
export function useSchemaMetadata(): SchemaMetadata {
16+
const [schema, setSchema] = useState<TableSchema[] | null>(null);
17+
18+
const loadSchema = useCallback(async (fetcher: () => Promise<TableSchema[]>) => {
19+
if (schema) return;
20+
const data = await fetcher();
21+
setSchema(data);
22+
}, [schema]);
23+
24+
const getForeignKeys = useCallback((tableName: string): ForeignKey[] => {
25+
const table = schema?.find((t) => t.name === tableName);
26+
return table?.foreign_keys ?? [];
27+
}, [schema]);
28+
29+
const getTableColumns = useCallback((tableName: string): { name: string; type: string }[] => {
30+
const table = schema?.find((t) => t.name === tableName);
31+
return table?.columns.map((c) => ({ name: c.name, type: c.data_type })) ?? [];
32+
}, [schema]);
33+
34+
return {
35+
schema,
36+
getForeignKeys,
37+
getTableColumns,
38+
loadSchema,
39+
isLoaded: schema !== null,
40+
};
41+
}

src/utils/queryBuilderLayout.ts

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
import dagre from 'dagre';
2+
import type { Node, Edge } from '@xyflow/react';
3+
import type { TableNodeData } from '../components/ui/TableNode';
4+
5+
const NODE_WIDTH = 220;
6+
const ROW_HEIGHT = 24;
7+
const HEADER_HEIGHT = 40;
8+
const RANK_SEP = 180;
9+
const NODE_SEP = 60;
10+
11+
export interface LayoutOptions {
12+
direction?: 'LR' | 'TB';
13+
preserveNodeIds?: Set<string>;
14+
}
15+
16+
export function layoutQueryBuilderGraph(
17+
nodes: Node<TableNodeData>[],
18+
edges: Edge[],
19+
options: LayoutOptions = {}
20+
): Node<TableNodeData>[] {
21+
const { direction = 'LR', preserveNodeIds } = options;
22+
23+
const dagreGraph = new dagre.graphlib.Graph();
24+
dagreGraph.setDefaultEdgeLabel(() => ({}));
25+
dagreGraph.setGraph({
26+
rankdir: direction,
27+
ranksep: RANK_SEP,
28+
nodesep: NODE_SEP,
29+
});
30+
31+
nodes.forEach((node) => {
32+
const height = HEADER_HEIGHT + (node.data.columns?.length ?? 0) * ROW_HEIGHT;
33+
dagreGraph.setNode(node.id, { width: NODE_WIDTH, height });
34+
});
35+
36+
edges.forEach((edge) => {
37+
dagreGraph.setEdge(edge.source, edge.target);
38+
});
39+
40+
dagre.layout(dagreGraph);
41+
42+
return nodes.map((node) => {
43+
if (preserveNodeIds?.has(node.id)) {
44+
return node;
45+
}
46+
47+
const nodeWithPosition = dagreGraph.node(node.id);
48+
return {
49+
...node,
50+
position: {
51+
x: nodeWithPosition.x - NODE_WIDTH / 2,
52+
y: nodeWithPosition.y - nodeWithPosition.height / 2,
53+
},
54+
};
55+
});
56+
}
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import { describe, it, expect } from 'vitest';
2+
import { layoutQueryBuilderGraph } from '../../src/utils/queryBuilderLayout';
3+
import type { Node, Edge } from '@xyflow/react';
4+
import type { TableNodeData } from '../../src/components/ui/TableNode';
5+
6+
describe('queryBuilderLayout', () => {
7+
it('should layout nodes without overlap', () => {
8+
const nodes: Node<TableNodeData>[] = [
9+
{ id: 'a', type: 'table', position: { x: 0, y: 0 }, data: { label: 'users', columns: [{ name: 'id', type: 'INT' }], selectedColumns: {}, columnAggregations: {}, columnAliases: {} } },
10+
{ id: 'b', type: 'table', position: { x: 0, y: 0 }, data: { label: 'posts', columns: [{ name: 'id', type: 'INT' }, { name: 'title', type: 'VARCHAR' }], selectedColumns: {}, columnAggregations: {}, columnAliases: {} } },
11+
{ id: 'c', type: 'table', position: { x: 0, y: 0 }, data: { label: 'comments', columns: [{ name: 'id', type: 'INT' }], selectedColumns: {}, columnAggregations: {}, columnAliases: {} } },
12+
];
13+
const edges: Edge[] = [
14+
{ id: 'e1', source: 'a', target: 'b', type: 'join' },
15+
{ id: 'e2', source: 'b', target: 'c', type: 'join' },
16+
];
17+
18+
const result = layoutQueryBuilderGraph(nodes, edges, { direction: 'LR' });
19+
20+
expect(result).toHaveLength(3);
21+
const uniqueX = new Set(result.map((n) => Math.round(n.position.x)));
22+
const uniqueY = new Set(result.map((n) => Math.round(n.position.y)));
23+
// At least some nodes should be separated horizontally or vertically
24+
expect(uniqueX.size + uniqueY.size).toBeGreaterThan(2);
25+
});
26+
27+
it('should preserve nodes in preserveNodeIds', () => {
28+
const nodes: Node<TableNodeData>[] = [
29+
{ id: 'a', type: 'table', position: { x: 100, y: 200 }, data: { label: 'users', columns: [], selectedColumns: {}, columnAggregations: {}, columnAliases: {} } },
30+
{ id: 'b', type: 'table', position: { x: 0, y: 0 }, data: { label: 'posts', columns: [], selectedColumns: {}, columnAggregations: {}, columnAliases: {} } },
31+
];
32+
const edges: Edge[] = [];
33+
34+
const result = layoutQueryBuilderGraph(nodes, edges, { direction: 'LR', preserveNodeIds: new Set(['a']) });
35+
36+
const preserved = result.find((n) => n.id === 'a');
37+
expect(preserved?.position).toEqual({ x: 100, y: 200 });
38+
});
39+
});

tsconfig.tsbuildinfo

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
{"fileNames":[],"fileInfos":[],"root":[],"version":"5.9.3"}

0 commit comments

Comments
 (0)