Skip to content

Commit 9c81863

Browse files
authored
feat: SQL metadata output renderer (#100)
1 parent a626bb3 commit 9c81863

File tree

6 files changed

+264
-0
lines changed

6 files changed

+264
-0
lines changed

build/esbuild/build.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -336,6 +336,18 @@ async function buildAll() {
336336
path.join(extensionFolder, 'dist', 'webviews', 'webview-side', 'vegaRenderer', 'vegaRenderer.js'),
337337
{ target: 'web', watch: isWatchMode }
338338
),
339+
build(
340+
path.join(extensionFolder, 'src', 'webviews', 'webview-side', 'sql-metadata-renderer', 'index.ts'),
341+
path.join(
342+
extensionFolder,
343+
'dist',
344+
'webviews',
345+
'webview-side',
346+
'sqlMetadataRenderer',
347+
'sqlMetadataRenderer.js'
348+
),
349+
{ target: 'web', watch: isWatchMode }
350+
),
339351
build(
340352
path.join(extensionFolder, 'src', 'webviews', 'webview-side', 'variable-view', 'index.tsx'),
341353
path.join(extensionFolder, 'dist', 'webviews', 'webview-side', 'viewers', 'variableView.js'),

package.json

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1856,6 +1856,15 @@
18561856
"application/vnd.vega.v5+json"
18571857
],
18581858
"requiresMessaging": "optional"
1859+
},
1860+
{
1861+
"id": "deepnote-sql-metadata-renderer",
1862+
"displayName": "Deepnote SQL Metadata Renderer",
1863+
"entrypoint": "./dist/webviews/webview-side/sqlMetadataRenderer/sqlMetadataRenderer.js",
1864+
"mimeTypes": [
1865+
"application/vnd.deepnote.sql-output-metadata+json"
1866+
],
1867+
"requiresMessaging": "optional"
18591868
}
18601869
],
18611870
"viewsContainers": {

src/notebooks/deepnote/deepnoteDataConverter.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -220,6 +220,10 @@ export class DeepnoteDataConverter {
220220
);
221221
} else if (item.mime === 'application/vnd.vega.v5+json') {
222222
data['application/vnd.vega.v5+json'] = JSON.parse(new TextDecoder().decode(item.data));
223+
} else if (item.mime === 'application/vnd.deepnote.sql-output-metadata+json') {
224+
data['application/vnd.deepnote.sql-output-metadata+json'] = JSON.parse(
225+
new TextDecoder().decode(item.data)
226+
);
223227
}
224228
}
225229

@@ -305,6 +309,15 @@ export class DeepnoteDataConverter {
305309
);
306310
}
307311

312+
if (data['application/vnd.deepnote.sql-output-metadata+json']) {
313+
items.push(
314+
NotebookCellOutputItem.json(
315+
data['application/vnd.deepnote.sql-output-metadata+json'],
316+
'application/vnd.deepnote.sql-output-metadata+json'
317+
)
318+
);
319+
}
320+
308321
if (data['application/vnd.vegalite.v5+json']) {
309322
const patchedVegaLiteSpec = produce(
310323
data['application/vnd.vegalite.v5+json'] as TopLevel<LayerSpec<Field>>,

src/notebooks/deepnote/deepnoteDataConverter.unit.test.ts

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -431,6 +431,48 @@ suite('DeepnoteDataConverter', () => {
431431
assert.strictEqual(outputs[0].items[0].mime, 'text/plain');
432432
assert.strictEqual(new TextDecoder().decode(outputs[0].items[0].data), 'fallback text');
433433
});
434+
435+
test('converts SQL metadata output', () => {
436+
const sqlMetadata = {
437+
status: 'read_from_cache_success',
438+
cache_created_at: '2024-10-21T10:30:00Z',
439+
compiled_query: 'SELECT * FROM users',
440+
variable_type: 'dataframe',
441+
integration_id: 'postgres-prod',
442+
size_in_bytes: 2621440
443+
};
444+
445+
const deepnoteOutputs: DeepnoteOutput[] = [
446+
{
447+
output_type: 'execute_result',
448+
execution_count: 1,
449+
data: {
450+
'application/vnd.deepnote.sql-output-metadata+json': sqlMetadata
451+
}
452+
}
453+
];
454+
455+
const blocks: DeepnoteBlock[] = [
456+
{
457+
blockGroup: 'test-group',
458+
id: 'block1',
459+
type: 'code',
460+
content: 'SELECT * FROM users',
461+
sortingKey: 'a0',
462+
outputs: deepnoteOutputs
463+
}
464+
];
465+
466+
const cells = converter.convertBlocksToCells(blocks);
467+
const outputs = cells[0].outputs!;
468+
469+
assert.strictEqual(outputs.length, 1);
470+
assert.strictEqual(outputs[0].items.length, 1);
471+
assert.strictEqual(outputs[0].items[0].mime, 'application/vnd.deepnote.sql-output-metadata+json');
472+
473+
const outputData = JSON.parse(new TextDecoder().decode(outputs[0].items[0].data));
474+
assert.deepStrictEqual(outputData, sqlMetadata);
475+
});
434476
});
435477

436478
suite('round trip conversion', () => {
@@ -468,6 +510,53 @@ suite('DeepnoteDataConverter', () => {
468510
assert.deepStrictEqual(roundTripBlocks, originalBlocks);
469511
});
470512

513+
test('SQL metadata output round-trips correctly', () => {
514+
const sqlMetadata = {
515+
status: 'read_from_cache_success',
516+
cache_created_at: '2024-10-21T10:30:00Z',
517+
compiled_query: 'SELECT * FROM users WHERE active = true',
518+
variable_type: 'dataframe',
519+
integration_id: 'postgres-prod',
520+
size_in_bytes: 2621440
521+
};
522+
523+
const originalBlocks: DeepnoteBlock[] = [
524+
{
525+
blockGroup: 'test-group',
526+
id: 'sql-block',
527+
type: 'code',
528+
content: 'SELECT * FROM users WHERE active = true',
529+
sortingKey: 'a0',
530+
executionCount: 1,
531+
metadata: {},
532+
outputs: [
533+
{
534+
output_type: 'execute_result',
535+
execution_count: 1,
536+
data: {
537+
'application/vnd.deepnote.sql-output-metadata+json': sqlMetadata
538+
}
539+
}
540+
]
541+
}
542+
];
543+
544+
const cells = converter.convertBlocksToCells(originalBlocks);
545+
const roundTripBlocks = converter.convertCellsToBlocks(cells);
546+
547+
// The round-trip should preserve the SQL metadata output
548+
assert.strictEqual(roundTripBlocks.length, 1);
549+
assert.strictEqual(roundTripBlocks[0].id, 'sql-block');
550+
assert.strictEqual(roundTripBlocks[0].outputs?.length, 1);
551+
552+
const output = roundTripBlocks[0].outputs![0] as {
553+
output_type: string;
554+
data?: Record<string, unknown>;
555+
};
556+
assert.strictEqual(output.output_type, 'execute_result');
557+
assert.deepStrictEqual(output.data?.['application/vnd.deepnote.sql-output-metadata+json'], sqlMetadata);
558+
});
559+
471560
test('real deepnote notebook round-trips without losing data', () => {
472561
// Inline test data representing a real Deepnote notebook with various block types
473562
// blockGroup is an optional field not in the DeepnoteBlock interface, so we cast as any
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
import React, { memo } from 'react';
2+
3+
export interface SqlMetadataRendererProps {
4+
data: {
5+
cache_created_at?: string;
6+
compiled_query?: string;
7+
integration_id?: string;
8+
size_in_bytes?: number;
9+
status: string;
10+
variable_type?: string;
11+
};
12+
}
13+
14+
const getStatusMessage = (status: string) => {
15+
switch (status) {
16+
case 'read_from_cache_success':
17+
return {
18+
icon: '✓',
19+
text: 'Query result loaded from cache',
20+
color: 'var(--vscode-testing-iconPassed)'
21+
};
22+
case 'success_no_cache':
23+
return {
24+
icon: 'ℹ',
25+
text: 'Query executed successfully',
26+
color: 'var(--vscode-notificationsInfoIcon-foreground)'
27+
};
28+
case 'cache_not_supported_for_query':
29+
return {
30+
icon: 'ℹ',
31+
text: 'Caching not supported for this query type',
32+
color: 'var(--vscode-notificationsInfoIcon-foreground)'
33+
};
34+
default:
35+
return {
36+
icon: 'ℹ',
37+
text: `Status: ${status}`,
38+
color: 'var(--vscode-foreground)'
39+
};
40+
}
41+
};
42+
43+
const formatBytes = (bytes: number) => {
44+
if (bytes < 1024) {
45+
return `${bytes} B`;
46+
}
47+
if (bytes < 1024 * 1024) {
48+
return `${(bytes / 1024).toFixed(2)} KB`;
49+
}
50+
if (bytes < 1024 * 1024 * 1024) {
51+
return `${(bytes / (1024 * 1024)).toFixed(2)} MB`;
52+
}
53+
return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`;
54+
};
55+
56+
export const SqlMetadataRenderer = memo(function SqlMetadataRenderer({ data }: SqlMetadataRendererProps) {
57+
const statusInfo = getStatusMessage(data.status);
58+
59+
return (
60+
<div
61+
style={{
62+
padding: '8px 12px',
63+
margin: '4px 0',
64+
borderLeft: `3px solid ${statusInfo.color}`,
65+
backgroundColor: 'var(--vscode-textBlockQuote-background)',
66+
fontSize: '12px',
67+
fontFamily: 'var(--vscode-font-family)',
68+
color: 'var(--vscode-foreground)'
69+
}}
70+
>
71+
<div
72+
style={{
73+
display: 'flex',
74+
alignItems: 'center',
75+
gap: '8px',
76+
marginBottom: data.cache_created_at || data.size_in_bytes ? '6px' : '0'
77+
}}
78+
>
79+
<span style={{ color: statusInfo.color, fontSize: '14px', fontWeight: 'bold' }}>{statusInfo.icon}</span>
80+
<span style={{ fontWeight: 500 }}>{statusInfo.text}</span>
81+
</div>
82+
83+
{data.cache_created_at && (
84+
<div style={{ marginLeft: '22px', opacity: 0.8, fontSize: '11px' }}>
85+
Cache created: {new Date(data.cache_created_at).toLocaleString()}
86+
</div>
87+
)}
88+
89+
{data.size_in_bytes !== undefined && (
90+
<div style={{ marginLeft: '22px', opacity: 0.8, fontSize: '11px' }}>
91+
Result size: {formatBytes(data.size_in_bytes)}
92+
</div>
93+
)}
94+
</div>
95+
);
96+
});
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
import type { ActivationFunction, OutputItem, RendererContext } from 'vscode-notebook-renderer';
2+
import * as React from 'react';
3+
import * as ReactDOM from 'react-dom';
4+
5+
import { SqlMetadataRenderer } from './SqlMetadataRenderer';
6+
7+
/**
8+
* Renderer for SQL metadata output (application/vnd.deepnote.sql-output-metadata+json).
9+
* This renderer displays information about SQL query execution, including cache status,
10+
* query size, and other metadata.
11+
*/
12+
export const activate: ActivationFunction = (_context: RendererContext<unknown>) => {
13+
const roots = new Map<string, HTMLElement>();
14+
15+
return {
16+
renderOutputItem(outputItem: OutputItem, element: HTMLElement) {
17+
try {
18+
const data = outputItem.json();
19+
20+
const root = document.createElement('div');
21+
element.appendChild(root);
22+
roots.set(outputItem.id, root);
23+
24+
ReactDOM.render(React.createElement(SqlMetadataRenderer, { data }), root);
25+
} catch (error) {
26+
console.error(`Error rendering SQL metadata: ${error}`);
27+
const errorDiv = document.createElement('div');
28+
errorDiv.style.padding = '10px';
29+
errorDiv.style.color = 'var(--vscode-errorForeground)';
30+
errorDiv.textContent = `Error rendering SQL metadata: ${error}`;
31+
element.appendChild(errorDiv);
32+
}
33+
},
34+
35+
disposeOutputItem(id?: string) {
36+
if (id) {
37+
const root = roots.get(id);
38+
if (root) {
39+
ReactDOM.unmountComponentAtNode(root);
40+
roots.delete(id);
41+
}
42+
}
43+
}
44+
};
45+
};

0 commit comments

Comments
 (0)