diff --git a/src/notebooks/deepnote/converters/sqlBlockConverter.ts b/src/notebooks/deepnote/converters/sqlBlockConverter.ts new file mode 100644 index 0000000000..26aef54a58 --- /dev/null +++ b/src/notebooks/deepnote/converters/sqlBlockConverter.ts @@ -0,0 +1,37 @@ +import { NotebookCellData, NotebookCellKind } from 'vscode'; + +import type { BlockConverter } from './blockConverter'; +import type { DeepnoteBlock } from '../deepnoteTypes'; + +/** + * Converter for SQL blocks. + * + * SQL blocks are rendered as code cells with SQL language for proper syntax highlighting. + * The SQL source code is stored in the cell content and displayed in the code editor. + * + * During execution, the createPythonCode function from @deepnote/blocks will generate + * the appropriate Python code to execute the SQL query based on the block's metadata + * (which includes the sql_integration_id and other SQL-specific settings). + */ +export class SqlBlockConverter implements BlockConverter { + applyChangesToBlock(block: DeepnoteBlock, cell: NotebookCellData): void { + // Store the SQL source code from the cell editor back to the block + block.content = cell.value || ''; + } + + canConvert(blockType: string): boolean { + return blockType.toLowerCase() === 'sql'; + } + + convertToCell(block: DeepnoteBlock): NotebookCellData { + // Create a code cell with SQL language for syntax highlighting + // The SQL source code is displayed in the editor + const cell = new NotebookCellData(NotebookCellKind.Code, block.content || '', 'sql'); + + return cell; + } + + getSupportedTypes(): string[] { + return ['sql']; + } +} diff --git a/src/notebooks/deepnote/converters/sqlBlockConverter.unit.test.ts b/src/notebooks/deepnote/converters/sqlBlockConverter.unit.test.ts new file mode 100644 index 0000000000..ae60c7ecb4 --- /dev/null +++ b/src/notebooks/deepnote/converters/sqlBlockConverter.unit.test.ts @@ -0,0 +1,199 @@ +import { assert } from 'chai'; +import { NotebookCellData, NotebookCellKind } from 'vscode'; + +import type { DeepnoteBlock } from '../deepnoteTypes'; +import { SqlBlockConverter } from './sqlBlockConverter'; +import dedent from 'dedent'; + +suite('SqlBlockConverter', () => { + let converter: SqlBlockConverter; + + setup(() => { + converter = new SqlBlockConverter(); + }); + + suite('canConvert', () => { + test('returns true for "sql" type', () => { + assert.strictEqual(converter.canConvert('sql'), true); + }); + + test('returns true for "SQL" type (case insensitive)', () => { + assert.strictEqual(converter.canConvert('SQL'), true); + }); + + test('returns false for other types', () => { + assert.strictEqual(converter.canConvert('code'), false); + assert.strictEqual(converter.canConvert('markdown'), false); + assert.strictEqual(converter.canConvert('text-cell-h1'), false); + }); + }); + + suite('getSupportedTypes', () => { + test('returns array with "sql"', () => { + const types = converter.getSupportedTypes(); + + assert.deepStrictEqual(types, ['sql']); + }); + }); + + suite('convertToCell', () => { + test('converts SQL block to code cell with sql language', () => { + const block: DeepnoteBlock = { + blockGroup: 'test-group', + content: 'SELECT * FROM users WHERE age > 18', + id: 'sql-block-123', + sortingKey: 'a0', + type: 'sql' + }; + + const cell = converter.convertToCell(block); + + assert.strictEqual(cell.kind, NotebookCellKind.Code); + assert.strictEqual(cell.value, 'SELECT * FROM users WHERE age > 18'); + assert.strictEqual(cell.languageId, 'sql'); + }); + + test('handles empty SQL content', () => { + const block: DeepnoteBlock = { + blockGroup: 'test-group', + content: '', + id: 'sql-block-456', + sortingKey: 'a1', + type: 'sql' + }; + + const cell = converter.convertToCell(block); + + assert.strictEqual(cell.kind, NotebookCellKind.Code); + assert.strictEqual(cell.value, ''); + assert.strictEqual(cell.languageId, 'sql'); + }); + + test('handles undefined SQL content', () => { + const block: DeepnoteBlock = { + blockGroup: 'test-group', + id: 'sql-block-789', + sortingKey: 'a2', + type: 'sql' + }; + + const cell = converter.convertToCell(block); + + assert.strictEqual(cell.kind, NotebookCellKind.Code); + assert.strictEqual(cell.value, ''); + assert.strictEqual(cell.languageId, 'sql'); + }); + + test('preserves multi-line SQL queries', () => { + const sqlQuery = dedent` + SELECT + u.name, + u.email, + COUNT(o.id) as order_count + FROM users u + LEFT JOIN orders o ON u.id = o.user_id + WHERE u.created_at > '2024-01-01' + GROUP BY u.id, u.name, u.email + ORDER BY order_count DESC + LIMIT 10 + `; + + const block: DeepnoteBlock = { + blockGroup: 'test-group', + content: sqlQuery, + id: 'sql-block-complex', + sortingKey: 'a3', + type: 'sql' + }; + + const cell = converter.convertToCell(block); + + assert.strictEqual(cell.kind, NotebookCellKind.Code); + assert.strictEqual(cell.value, sqlQuery); + assert.strictEqual(cell.languageId, 'sql'); + }); + + test('preserves SQL block with metadata', () => { + const block: DeepnoteBlock = { + blockGroup: 'test-group', + content: 'SELECT * FROM products', + id: 'sql-block-with-metadata', + metadata: { + sql_integration_id: 'postgres-prod', + table_state_spec: '{"pageSize": 50}' + }, + sortingKey: 'a4', + type: 'sql' + }; + + const cell = converter.convertToCell(block); + + assert.strictEqual(cell.kind, NotebookCellKind.Code); + assert.strictEqual(cell.value, 'SELECT * FROM products'); + assert.strictEqual(cell.languageId, 'sql'); + // Metadata is handled by the data converter, not the block converter + }); + }); + + suite('applyChangesToBlock', () => { + test('updates block content from cell value', () => { + const block: DeepnoteBlock = { + blockGroup: 'test-group', + content: 'SELECT * FROM old_table', + id: 'sql-block-123', + sortingKey: 'a0', + type: 'sql' + }; + const cell = new NotebookCellData( + NotebookCellKind.Code, + 'SELECT * FROM new_table WHERE active = true', + 'sql' + ); + + converter.applyChangesToBlock(block, cell); + + assert.strictEqual(block.content, 'SELECT * FROM new_table WHERE active = true'); + }); + + test('handles empty cell value', () => { + const block: DeepnoteBlock = { + blockGroup: 'test-group', + content: 'SELECT * FROM users', + id: 'sql-block-456', + sortingKey: 'a1', + type: 'sql' + }; + const cell = new NotebookCellData(NotebookCellKind.Code, '', 'sql'); + + converter.applyChangesToBlock(block, cell); + + assert.strictEqual(block.content, ''); + }); + + test('does not modify other block properties', () => { + const block: DeepnoteBlock = { + blockGroup: 'test-group', + content: 'SELECT * FROM old_query', + id: 'sql-block-789', + metadata: { + sql_integration_id: 'postgres-prod', + custom: 'value' + }, + sortingKey: 'a2', + type: 'sql' + }; + const cell = new NotebookCellData(NotebookCellKind.Code, 'SELECT * FROM new_query', 'sql'); + + converter.applyChangesToBlock(block, cell); + + assert.strictEqual(block.content, 'SELECT * FROM new_query'); + assert.strictEqual(block.id, 'sql-block-789'); + assert.strictEqual(block.type, 'sql'); + assert.strictEqual(block.sortingKey, 'a2'); + assert.deepStrictEqual(block.metadata, { + sql_integration_id: 'postgres-prod', + custom: 'value' + }); + }); + }); +}); diff --git a/src/notebooks/deepnote/deepnoteDataConverter.ts b/src/notebooks/deepnote/deepnoteDataConverter.ts index bda5baae3f..5e8603f49e 100644 --- a/src/notebooks/deepnote/deepnoteDataConverter.ts +++ b/src/notebooks/deepnote/deepnoteDataConverter.ts @@ -5,8 +5,9 @@ import { generateBlockId, generateSortingKey } from './dataConversionUtils'; import { ConverterRegistry } from './converters/converterRegistry'; import { CodeBlockConverter } from './converters/codeBlockConverter'; import { addPocketToCellMetadata, createBlockFromPocket } from './pocket'; -import { TextBlockConverter } from './converters/textBlockConverter'; import { MarkdownBlockConverter } from './converters/markdownBlockConverter'; +import { SqlBlockConverter } from './converters/sqlBlockConverter'; +import { TextBlockConverter } from './converters/textBlockConverter'; /** * Utility class for converting between Deepnote block structures and VS Code notebook cells. @@ -17,8 +18,9 @@ export class DeepnoteDataConverter { constructor() { this.registry.register(new CodeBlockConverter()); - this.registry.register(new TextBlockConverter()); this.registry.register(new MarkdownBlockConverter()); + this.registry.register(new SqlBlockConverter()); + this.registry.register(new TextBlockConverter()); } /** diff --git a/src/notebooks/deepnote/deepnoteDataConverter.unit.test.ts b/src/notebooks/deepnote/deepnoteDataConverter.unit.test.ts index 2a9fedc3a1..8efc7b0734 100644 --- a/src/notebooks/deepnote/deepnoteDataConverter.unit.test.ts +++ b/src/notebooks/deepnote/deepnoteDataConverter.unit.test.ts @@ -59,6 +59,33 @@ suite('DeepnoteDataConverter', () => { assert.strictEqual(cells[0].metadata?.__deepnotePocket?.type, 'markdown'); }); + test('converts SQL block to cell with sql language', () => { + const blocks: DeepnoteBlock[] = [ + { + blockGroup: 'test-group', + id: 'block3', + type: 'sql', + content: 'SELECT * FROM users WHERE id = 1', + sortingKey: 'a2', + metadata: { + sql_integration_id: 'postgres-123' + } + } + ]; + + const cells = converter.convertBlocksToCells(blocks); + + assert.strictEqual(cells.length, 1); + assert.strictEqual(cells[0].kind, NotebookCellKind.Code); + assert.strictEqual(cells[0].value, 'SELECT * FROM users WHERE id = 1'); + assert.strictEqual(cells[0].languageId, 'sql'); + // id should be at top level, not in pocket + assert.strictEqual(cells[0].metadata?.id, 'block3'); + assert.strictEqual(cells[0].metadata?.__deepnotePocket?.type, 'sql'); + assert.strictEqual(cells[0].metadata?.__deepnotePocket?.sortingKey, 'a2'); + assert.strictEqual(cells[0].metadata?.sql_integration_id, 'postgres-123'); + }); + test('handles execution count', () => { const blocks: DeepnoteBlock[] = [ {