Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,23 @@
## [Unreleased]

### Added

- The Positron extension now registers a ggsql data importer, so dragging a
csv/tsv/parquet/json file into Positron offers to generate the ggsql code
that loads it into a table, including any filters and sorts shown in the
Data Explorer (#536).
- The Positron extension now registers a bundled agent skill, so agents
automatically discover how to write and run ggsql queries (#536).

### Fixed

- Free facet dimensions now resolve their domain, breaks, labels, and minor
breaks per panel in core (`Scale::panels`, indexed by the canonical panel
order shared by both writers). The Vega-Lite writer no longer pins the
globally resolved break set as `axis.values` on a free dimension — which had
left most panels showing a single tick — and the hephaestus writer consumes
the core-resolved per-panel scales instead of deriving panel extents itself
(#516).
- Fixed a parser bug that interpreted comment characters inside string literals
as initializing a comment (#555).
- Fixed a bug in stat_aggregate prevented transposed layers from properly
Expand Down
6 changes: 6 additions & 0 deletions doc/get_started/tooling/positron-vscode.qmd
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,12 @@ If you use `.sql` files with other database tooling and would prefer the extensi
"ggsql.enableSqlFiles": false
```

## Importing data files

Dragging a `.csv`, `.tsv`, `.parquet`, or `.json` file into Positron offers to generate the code that loads it, and ggsql is one of the importers on offer. The generated code is a `CREATE TABLE … AS SELECT …` statement that reads the file into a table, and if you open the import dialog from a Data Explorer view it can also reproduce the filters and sorts you have applied there as `WHERE` and `ORDER BY` clauses.

The generated code targets a duckdb session — the default, empty in-memory connection ggsql starts with. Reading files with `FROM 'file.csv'` is duckdb functionality, so the import will not run in a session attached to another database backend with `-- @connect:`. For the same reason, filter expressions use duckdb syntax such as `ILIKE` and `regexp_matches()`; anything the importer cannot translate is listed as a warning next to the generated code rather than dropped silently.

## Database connections

By default, ggsql starts in the Positron console with an empty in-memory duckdb database connection. A "magic" comment can be used to initiate a different database connection after the session has launched, which can be either a comment in your source file or invoked directly in the console.
Expand Down
3 changes: 3 additions & 0 deletions ggsql-vscode/.gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,6 @@ out-test
.positron-test/
bundled
*.vsix

# Generated at package time by scripts/sync-skill.js from doc/vendor/SKILL.md
skills/ggsql/SKILL.md
8 changes: 4 additions & 4 deletions ggsql-vscode/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 3 additions & 2 deletions ggsql-vscode/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -178,7 +178,8 @@
"watch": "npm-run-all -p watch:*",
"watch:esbuild": "node esbuild.js --watch",
"watch:tsc": "tsc --noEmit --watch --project tsconfig.json",
"package": "npm run check-types && node esbuild.js --production",
"sync-skill": "node scripts/sync-skill.js",
"package": "npm run sync-skill && npm run check-types && node esbuild.js --production",
"check-types": "tsc --noEmit",
"lint": "eslint src --ext ts",
"compile-tests": "tsc -p tsconfig.test.json",
Expand All @@ -192,7 +193,7 @@
"toml": "^3.0.0"
},
"devDependencies": {
"@posit-dev/positron": "^0.2.7",
"@posit-dev/positron": "^0.2.9",
"@posit-dev/positron-test-electron": "^0.0.3",
"@types/mocha": "^10.0.10",
"@types/node": "^18.x",
Expand Down
34 changes: 34 additions & 0 deletions ggsql-vscode/scripts/sync-skill.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
/*
* Copies the canonical ggsql agent skill into the extension before packaging.
*
* The canonical source within this repo is doc/vendor/SKILL.md, which
* ggsql-cli/build.rs keeps in sync with the posit-dev/skills repository
* (rebuild the CLI with GGSQL_UPDATE_SKILL=1 to refresh it). The extension
* registers the skills/ directory as an agent skill root, so the packaged
* copy must live inside the extension; this script materialises it at
* package time rather than keeping a second, hand-maintained copy that
* would drift.
*/

const fs = require('fs');
const path = require('path');

const repoRoot = path.join(__dirname, '..', '..');
const source = path.join(repoRoot, 'doc', 'vendor', 'SKILL.md');
const destDir = path.join(__dirname, '..', 'skills', 'ggsql');
const dest = path.join(destDir, 'SKILL.md');

const content = fs.readFileSync(source, 'utf8');

// Positron discovers skills by name/description frontmatter; fail loudly if
// the canonical file ever loses them instead of shipping a broken skill.
for (const field of ['name:', 'description:']) {
if (!content.startsWith('---') || !content.includes(`\n${field}`)) {
console.error(`sync-skill: ${source} is missing '${field}' frontmatter`);
process.exit(1);
}
}

fs.mkdirSync(destDir, { recursive: true });
fs.writeFileSync(dest, content);
console.log(`sync-skill: copied ${path.relative(repoRoot, source)} -> ${path.relative(repoRoot, dest)}`);
166 changes: 166 additions & 0 deletions ggsql-vscode/src/dataImporter.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
/*
* ggsql data importer.
*
* Registers a Data Explorer importer so that dragging a csv/parquet/json file
* into Positron offers "ggsql" as a way to load it. The generated code is a
* ggsql query that reads the file into a table, reproducing any row filters
* and sorts from the current Data Explorer view.
*/

import * as positron from '@posit-dev/positron';

/** File extensions ggsql can read through its duckdb backend. */
const READABLE_EXTENSIONS = ['csv', 'tsv', 'parquet', 'json', 'jsonl', 'ndjson'];

/** Column types whose stringified values can be embedded in SQL unquoted. */
const NUMERIC_TYPES = new Set(['integer', 'number', 'float', 'double', 'decimal']);

/** A small list of SQL reserved words, so Positron can suffix colliding variable names. */
const RESERVED_NAMES = [
'select', 'from', 'where', 'table', 'group', 'order', 'by', 'insert',
'update', 'delete', 'create', 'drop', 'join', 'union', 'all', 'and',
'or', 'not', 'null', 'as', 'on', 'in', 'between', 'like', 'limit',
];

/** Quote a string value for SQL, escaping embedded quotes by doubling. */
function quoteString(value: string): string {
return `'${value.replace(/'/g, "''")}'`;
}

/** Quote an identifier (column or table name) for SQL. */
function quoteIdentifier(name: string): string {
return `"${name.replace(/"/g, '""')}"`;
}

/**
* Render a stringified filter value as a SQL literal, using the column's
* display type to decide whether quoting is needed.
*/
function renderValue(value: string, columnType: string): string {
const type = columnType.toLowerCase();
if (NUMERIC_TYPES.has(type)) {
return value;
}
if (type === 'boolean') {
return value.toLowerCase() === 'true' ? 'TRUE' : 'FALSE';
}
return quoteString(value);
}

/** Translate one Data Explorer row filter into a SQL predicate. */
function renderFilter(filter: positron.DataImportRowFilter): string | undefined {
const column = quoteIdentifier(filter.columnName);
switch (filter.filterType) {
case 'between':
return `${column} BETWEEN ${renderValue(filter.leftValue, filter.columnType)} AND ${renderValue(filter.rightValue, filter.columnType)}`;
case 'not_between':
return `${column} NOT BETWEEN ${renderValue(filter.leftValue, filter.columnType)} AND ${renderValue(filter.rightValue, filter.columnType)}`;
case 'compare':
return `${column} ${filter.op} ${renderValue(filter.value, filter.columnType)}`;
case 'search': {
const like = filter.caseSensitive ? 'LIKE' : 'ILIKE';
switch (filter.searchType) {
case 'contains':
return `${column} ${like} ${quoteString(`%${filter.term}%`)}`;
case 'not_contains':
return `${column} NOT ${like} ${quoteString(`%${filter.term}%`)}`;
case 'starts_with':
return `${column} ${like} ${quoteString(`${filter.term}%`)}`;
case 'ends_with':
return `${column} ${like} ${quoteString(`%${filter.term}`)}`;
case 'regex_match':
return filter.caseSensitive
? `regexp_matches(${column}, ${quoteString(filter.term)})`
: `regexp_matches(${column}, ${quoteString(filter.term)}, 'i')`;
}
break;
}
case 'set_membership': {
const values = filter.values.map((v) => renderValue(v, filter.columnType)).join(', ');
return `${column} ${filter.inclusive ? 'IN' : 'NOT IN'} (${values})`;
}
case 'is_null':
return `${column} IS NULL`;
case 'not_null':
return `${column} IS NOT NULL`;
case 'is_empty':
return `${column} = ''`;
case 'not_empty':
return `${column} <> ''`;
case 'is_true':
return `${column}`;
case 'is_false':
return `NOT ${column}`;
}
return undefined;
}

/** Build the FROM clause, honouring import options that need explicit reader calls. */
function renderFrom(filePath: string, extension: string, options: positron.DataImportOptions): string {
if (
options.hasHeaderRow === false &&
(extension === 'csv' || extension === 'tsv')
) {
return `read_csv(${quoteString(filePath)}, header = false)`;
}
return quoteString(filePath);
}

/** The ggsql data importer offered by the Data Explorer import dialog. */
export const ggsqlDataImporter: positron.DataImporter = {
languageId: 'ggsql',
displayName: 'ggsql',
fileExtensions: READABLE_EXTENSIONS,
reservedNames: RESERVED_NAMES,

generateCode(request: positron.DataImportRequest): positron.DataImportResult {
const filePath = request.fileUri.fsPath;
const extension = filePath.split('.').pop()?.toLowerCase() ?? '';
const unsupported: string[] = [];

if (request.options.sheetName !== undefined) {
unsupported.push(`Worksheet selection ('${request.options.sheetName}')`);
}
if (
request.options.hasHeaderRow === false &&
extension !== 'csv' &&
extension !== 'tsv'
) {
unsupported.push('Header row option (only supported for csv/tsv files)');
}

const lines = [
`CREATE TABLE ${quoteIdentifier(request.variableName)} AS`,
'SELECT *',
`FROM ${renderFrom(filePath, extension, request.options)}`,
];

const view = request.view;
if (view && view.rowFilters.length > 0) {
const predicates: string[] = [];
for (const filter of view.rowFilters) {
const predicate = renderFilter(filter);
if (predicate === undefined) {
unsupported.push(`Row filter on ${filter.columnName} (${filter.filterType})`);
continue;
}
predicates.push(predicates.length === 0 ? predicate : `${filter.condition.toUpperCase()} ${predicate}`);
}
if (predicates.length > 0) {
lines.push(`WHERE ${predicates.join('\n ')}`);
}
}

if (view && view.sortKeys.length > 0) {
const keys = view.sortKeys.map(
(key) => `${quoteIdentifier(key.columnName)} ${key.ascending ? 'ASC' : 'DESC'}`
);
lines.push(`ORDER BY ${keys.join(', ')}`);
}

return {
code: lines.join('\n') + ';',
unsupported: unsupported.length > 0 ? unsupported : undefined,
};
},
};
24 changes: 24 additions & 0 deletions ggsql-vscode/src/extension.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ import { activateContextKeys } from './context';
import { parseCells } from './cellParser';
import { CELL_LANGUAGE_IDS, isGgsqlDocument } from './languages';
import { activateSqlAssociationPrompt } from './sqlAssociation';
import { ggsqlDataImporter } from './dataImporter';
import * as path from 'path';

// Output channel for logging
const outputChannel = vscode.window.createOutputChannel('ggsql');
Expand Down Expand Up @@ -75,6 +77,28 @@ export function activate(context: vscode.ExtensionContext): void {

log(`Registered ${drivers.length} connection drivers`);

// Register the ggsql data importer for the Data Explorer import dialog.
// Requires positron API >= 0.2.9; skip on older Positron builds.
if (typeof positronApi.dataExplorer?.registerDataImporter === 'function') {
context.subscriptions.push(
positronApi.dataExplorer.registerDataImporter(ggsqlDataImporter)
);
log('Registered ggsql data importer');
} else {
log('positron.dataExplorer.registerDataImporter not available - skipping data importer');
}

// Register the bundled agent skill root so agents discover the ggsql skill.
// Requires positron API >= 0.2.9; skip on older Positron builds.
if (typeof positronApi.ai?.registerAgentSkillRoot === 'function') {
context.subscriptions.push(
positronApi.ai.registerAgentSkillRoot(path.join(context.extensionPath, 'skills'))
);
log('Registered ggsql agent skill root');
} else {
log('positron.ai.registerAgentSkillRoot not available - skipping agent skill root');
}

// Register "Source Current File" command for the editor run button
context.subscriptions.push(
vscode.commands.registerCommand('ggsql.sourceCurrentFile', async () => {
Expand Down
Loading
Loading