Skip to content

Commit 9efe836

Browse files
authored
data explorer: duckdb sql backend for convert to code (#9138)
Since we store the active where and sort clauses, we can probably just reuse them to show to users. Since these are what is filtering/sorting the data explorer, I assume they won't be stale. I haven't dug around too much to see if there are any other drawbacks to reusing these, but it does make this feature quite simple! ### Release Notes <!-- Optionally, replace `N/A` with text to be included in the next release notes. The `N/A` bullets are ignored. If you refer to one or more Positron issues, these issues are used to collect information about the feature or bugfix, such as the relevant language pack as determined by Github labels of type `lang: `. The note will automatically be tagged with the language. These notes are typically filled by the Positron team. If you are an external contributor, you may ignore this section. --> #### New Features - #8986 Add Convert to Code that generates SQL for headless Data Explorer sessions. #### Bug Fixes - N/A ### QA Notes - Open up "flights.parquet" file in `positron-qa-examples` - Do some filters, sorts, etc. DuckDB displays the number of rows only if it's less than 9999, so if you want to do a quick sanity check that it's equivalent to number of rows in the data explorer, make sure you narrow it down accordingly! - Convert to Code button -> copy to clipboard - In Python, `pip install duckdb` - Run ⏬ ```python import duckdb conn = duckdb.connect() conn.execute("CREATE TABLE flights AS SELECT * FROM 'data-files/flights/flights.parquet'") conn.execute(""" ADD COPIED SQL HERE """).df()
1 parent 6c717bd commit 9efe836

File tree

2 files changed

+295
-2
lines changed

2 files changed

+295
-2
lines changed

extensions/positron-duckdb/src/extension.ts

Lines changed: 38 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
import * as vscode from 'vscode';
77
import {
88
BackendState,
9+
CodeSyntaxName,
910
ColumnDisplayType,
1011
ColumnFilter,
1112
ColumnFilterType,
@@ -21,6 +22,8 @@ import {
2122
ColumnSortKey,
2223
ColumnSummaryStats,
2324
ColumnValue,
25+
ConvertedCode,
26+
ConvertToCodeParams,
2427
DataExplorerBackendRequest,
2528
DataExplorerFrontendEvent,
2629
DataExplorerResponse,
@@ -1326,7 +1329,8 @@ END`;
13261329
]
13271330
},
13281331
convert_to_code: {
1329-
support_status: SupportStatus.Unsupported,
1332+
support_status: SupportStatus.Supported,
1333+
code_syntaxes: [{ code_syntax_name: 'SQL' }]
13301334
}
13311335
}
13321336
};
@@ -1654,6 +1658,35 @@ END`;
16541658
const numRows = Number(result.toArray()[0].num_rows);
16551659
return [numRows, numColumns];
16561660
}
1661+
1662+
async suggestCodeSyntaxes(): RpcResponse<CodeSyntaxName> {
1663+
return {
1664+
code_syntax_name: 'SQL'
1665+
};
1666+
}
1667+
1668+
async convertToCode(params: ConvertToCodeParams, uri: string): RpcResponse<ConvertedCode> {
1669+
const parsedUri = vscode.Uri.parse(uri);
1670+
const filename = path.basename(parsedUri.path, path.extname(parsedUri.path));
1671+
1672+
// Escape any quotes in the filename to prevent SQL injection
1673+
const escapedFilename = filename.replace(/"/g, '""');
1674+
const result = ["SELECT * ", `FROM "${escapedFilename}"`];
1675+
1676+
if (this._whereClause) {
1677+
const whereClause = this._whereClause.replace(/\n/g, ' ').trim();
1678+
result.push(whereClause);
1679+
}
1680+
1681+
if (this._sortClause) {
1682+
const sortClause = this._sortClause.replace(/\n/g, ' ').trim();
1683+
result.push(sortClause);
1684+
}
1685+
1686+
return {
1687+
converted_code: result
1688+
};
1689+
}
16571690
}
16581691

16591692
/**
@@ -1823,6 +1856,10 @@ export class DataExplorerRpcHandler implements vscode.Disposable {
18231856
return table.setSortColumns(rpc.params as SetSortColumnsParams);
18241857
case DataExplorerBackendRequest.SearchSchema:
18251858
return table.searchSchema(rpc.params as SearchSchemaParams);
1859+
case DataExplorerBackendRequest.SuggestCodeSyntax:
1860+
return table.suggestCodeSyntaxes();
1861+
case DataExplorerBackendRequest.ConvertToCode:
1862+
return table.convertToCode(rpc.params as ConvertToCodeParams, rpc.uri!);
18261863
case DataExplorerBackendRequest.SetColumnFilters:
18271864
return `${rpc.method} not yet implemented`;
18281865
default:

extensions/positron-duckdb/src/test/extension.test.ts

Lines changed: 257 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -260,7 +260,12 @@ suite('Positron DuckDB Extension Test Suite', () => {
260260
ExportFormat.Html
261261
]
262262
},
263-
convert_to_code: { support_status: SupportStatus.Unsupported }
263+
convert_to_code: {
264+
support_status: SupportStatus.Supported,
265+
code_syntaxes: [{
266+
code_syntax_name: "SQL"
267+
}]
268+
}
264269
}
265270
} satisfies BackendState);
266271

@@ -2117,4 +2122,255 @@ suite('Positron DuckDB Extension Test Suite', () => {
21172122
assert.strictEqual(numberStats.median, '42', 'Median should be 42');
21182123
assert.strictEqual(numberStats.stdev, '0', 'Standard deviation should be 0 for single value');
21192124
});
2125+
2126+
test('convertToCode - with row filters', async () => {
2127+
const tableName = makeTempTableName();
2128+
2129+
// Create a test table with more diverse data for filtering
2130+
await createTempTable(tableName, [
2131+
{
2132+
name: 'id',
2133+
type: 'INTEGER',
2134+
values: ['1', '2', '3', '4', '5']
2135+
},
2136+
{
2137+
name: 'name',
2138+
type: 'VARCHAR',
2139+
values: ["'Alice'", "'Bob'", "'Charlie'", "'David'", "'Eve'"]
2140+
},
2141+
{
2142+
name: 'age',
2143+
type: 'INTEGER',
2144+
values: ['25', '30', '35', '40', '45']
2145+
}
2146+
]);
2147+
2148+
const uri = vscode.Uri.from({ scheme: 'duckdb', path: tableName });
2149+
2150+
// Get full schema to build row filter
2151+
const fullSchema = await getSchema(tableName);
2152+
2153+
// Create filter: age > 30
2154+
const rowFilter: RowFilter = {
2155+
filter_id: 'test-filter',
2156+
condition: RowFilterCondition.And,
2157+
column_schema: fullSchema.columns[2], // age column
2158+
filter_type: RowFilterType.Compare,
2159+
params: {
2160+
op: FilterComparisonOp.Gt,
2161+
value: '30'
2162+
}
2163+
};
2164+
2165+
// Apply the filter first so it's reflected in the SQL generation
2166+
await dxExec({
2167+
method: DataExplorerBackendRequest.SetRowFilters,
2168+
uri: uri.toString(),
2169+
params: {
2170+
filters: [rowFilter]
2171+
}
2172+
});
2173+
2174+
// Test convert to code with row filter applied
2175+
const result = await dxExec({
2176+
method: DataExplorerBackendRequest.ConvertToCode,
2177+
uri: uri.toString(),
2178+
params: {
2179+
column_filters: [],
2180+
row_filters: [rowFilter],
2181+
sort_keys: [],
2182+
code_syntax_name: { code_syntax_name: 'SQL' }
2183+
}
2184+
});
2185+
2186+
assert.ok(result, 'Convert to code result should be returned');
2187+
assert.ok(result.converted_code, 'Converted code should be present');
2188+
assert.strictEqual(result.converted_code.length, 3, 'Should have 3 lines of code');
2189+
assert.strictEqual(result.converted_code[0], 'SELECT * ', 'First line should be SELECT * ');
2190+
assert.strictEqual(result.converted_code[1], `FROM "${tableName}"`, `Second line should reference the table name`);
2191+
assert.strictEqual(result.converted_code[2], 'WHERE "age" > 30', 'Third line should have the WHERE clause');
2192+
});
2193+
2194+
test('convertToCode - with sort columns', async () => {
2195+
const tableName = makeTempTableName();
2196+
2197+
// Create a test table with more diverse data for sorting
2198+
await createTempTable(tableName, [
2199+
{
2200+
name: 'id',
2201+
type: 'INTEGER',
2202+
values: ['1', '2', '3', '4', '5']
2203+
},
2204+
{
2205+
name: 'name',
2206+
type: 'VARCHAR',
2207+
values: ["'Alice'", "'Bob'", "'Charlie'", "'David'", "'Eve'"]
2208+
},
2209+
{
2210+
name: 'age',
2211+
type: 'INTEGER',
2212+
values: ['25', '30', '35', '40', '45']
2213+
}
2214+
]);
2215+
2216+
const uri = vscode.Uri.from({ scheme: 'duckdb', path: tableName });
2217+
2218+
// Create sort key: sort by name descending
2219+
const sortKey: ColumnSortKey = {
2220+
column_index: 1, // name column
2221+
ascending: false
2222+
};
2223+
2224+
// Apply the sort key first so it's reflected in the SQL generation
2225+
await dxExec({
2226+
method: DataExplorerBackendRequest.SetSortColumns,
2227+
uri: uri.toString(),
2228+
params: {
2229+
sort_keys: [sortKey]
2230+
}
2231+
});
2232+
2233+
// Test convert to code with sort key applied
2234+
const result = await dxExec({
2235+
method: DataExplorerBackendRequest.ConvertToCode,
2236+
uri: uri.toString(),
2237+
params: {
2238+
column_filters: [],
2239+
row_filters: [],
2240+
sort_keys: [sortKey],
2241+
code_syntax_name: { code_syntax_name: 'SQL' }
2242+
}
2243+
});
2244+
2245+
assert.ok(result, 'Convert to code result should be returned');
2246+
assert.ok(result.converted_code, 'Converted code should be present');
2247+
assert.strictEqual(result.converted_code.length, 3, 'Should have 3 lines of code');
2248+
assert.strictEqual(result.converted_code[0], 'SELECT * ', 'First line should be SELECT * ');
2249+
assert.strictEqual(result.converted_code[1], `FROM "${tableName}"`, `Second line should reference the table name`);
2250+
assert.strictEqual(result.converted_code[2], 'ORDER BY "name" DESC', 'Third line should have the ORDER BY clause');
2251+
});
2252+
2253+
test('convertToCode - with both row filters and sort columns', async () => {
2254+
const tableName = makeTempTableName();
2255+
2256+
// Create a test table with data for filtering and sorting
2257+
await createTempTable(tableName, [
2258+
{
2259+
name: 'id',
2260+
type: 'INTEGER',
2261+
values: ['1', '2', '3', '4', '5']
2262+
},
2263+
{
2264+
name: 'name',
2265+
type: 'VARCHAR',
2266+
values: ["'Alice'", "'Bob'", "'Charlie'", "'David'", "'Eve'"]
2267+
},
2268+
{
2269+
name: 'age',
2270+
type: 'INTEGER',
2271+
values: ['25', '30', '35', '40', '45']
2272+
}
2273+
]);
2274+
2275+
const uri = vscode.Uri.from({ scheme: 'duckdb', path: tableName });
2276+
2277+
// Get full schema to build row filter
2278+
const fullSchema = await getSchema(tableName);
2279+
2280+
// Create filter: age > 30
2281+
const rowFilter: RowFilter = {
2282+
filter_id: 'test-filter',
2283+
condition: RowFilterCondition.And,
2284+
column_schema: fullSchema.columns[2], // age column
2285+
filter_type: RowFilterType.Compare,
2286+
params: {
2287+
op: FilterComparisonOp.Gt,
2288+
value: '30'
2289+
}
2290+
};
2291+
2292+
// Create sort key: sort by name ascending
2293+
const sortKey: ColumnSortKey = {
2294+
column_index: 1, // name column
2295+
ascending: true
2296+
};
2297+
2298+
// Apply the filter and sort key
2299+
await dxExec({
2300+
method: DataExplorerBackendRequest.SetRowFilters,
2301+
uri: uri.toString(),
2302+
params: {
2303+
filters: [rowFilter]
2304+
}
2305+
});
2306+
2307+
await dxExec({
2308+
method: DataExplorerBackendRequest.SetSortColumns,
2309+
uri: uri.toString(),
2310+
params: {
2311+
sort_keys: [sortKey]
2312+
}
2313+
});
2314+
2315+
// Test convert to code with both row filter and sort key applied
2316+
const result = await dxExec({
2317+
method: DataExplorerBackendRequest.ConvertToCode,
2318+
uri: uri.toString(),
2319+
params: {
2320+
column_filters: [],
2321+
row_filters: [rowFilter],
2322+
sort_keys: [sortKey],
2323+
code_syntax_name: { code_syntax_name: 'SQL' }
2324+
}
2325+
});
2326+
2327+
assert.ok(result, 'Convert to code result should be returned');
2328+
assert.ok(result.converted_code, 'Converted code should be present');
2329+
assert.strictEqual(result.converted_code.length, 4, 'Should have 4 lines of code');
2330+
assert.strictEqual(result.converted_code[0], 'SELECT * ', 'First line should be SELECT * ');
2331+
assert.strictEqual(result.converted_code[1], `FROM "${tableName}"`, `Second line should reference the table name`);
2332+
assert.strictEqual(result.converted_code[2], 'WHERE "age" > 30', 'Third line should have the WHERE clause');
2333+
assert.strictEqual(result.converted_code[3], 'ORDER BY "name"', 'Fourth line should have the ORDER BY clause');
2334+
});
2335+
2336+
test('convertToCode - with long/complex filename/URI', async () => {
2337+
// Use a long filename that needs to be quoted in SQL
2338+
const specialTableName = makeTempTableName() + '_complex_tablename_with_underscores';
2339+
2340+
// Create a simple test table
2341+
await createTempTable(specialTableName, [
2342+
{
2343+
name: 'id',
2344+
type: 'INTEGER',
2345+
values: ['1', '2', '3']
2346+
},
2347+
{
2348+
name: 'data',
2349+
type: 'VARCHAR',
2350+
values: ["'A'", "'B'", "'C'"]
2351+
}
2352+
]);
2353+
2354+
const uri = vscode.Uri.from({ scheme: 'duckdb', path: specialTableName });
2355+
2356+
// Test convert to code with a complex filename
2357+
const result = await dxExec({
2358+
method: DataExplorerBackendRequest.ConvertToCode,
2359+
uri: uri.toString(),
2360+
params: {
2361+
column_filters: [],
2362+
row_filters: [],
2363+
sort_keys: [],
2364+
code_syntax_name: { code_syntax_name: 'SQL' }
2365+
}
2366+
});
2367+
2368+
assert.ok(result, 'Convert to code result should be returned');
2369+
assert.ok(result.converted_code, 'Converted code should be present');
2370+
assert.strictEqual(result.converted_code.length, 2, 'Should have 2 lines of code');
2371+
assert.strictEqual(result.converted_code[0], 'SELECT * ', 'First line should be SELECT * ');
2372+
2373+
// Verify that the table name is properly quoted in SQL
2374+
assert.strictEqual(result.converted_code[1], `FROM "${specialTableName}"`, 'Second line should properly quote the table name');
2375+
});
21202376
});

0 commit comments

Comments
 (0)