Repository navigation
Bump and functionality #564
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
| 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)}`); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,187 @@ | ||
| /* | ||
| * 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 path from 'path'; | ||
| 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, "''")}'`; | ||
| } | ||
|
|
||
| /** | ||
| * Escape a literal search term for use inside a LIKE/ILIKE pattern, so that | ||
| * embedded `%` and `_` are matched literally rather than as wildcards. | ||
| * Must be paired with `ESCAPE '\\'` in the LIKE expression. | ||
| */ | ||
| function escapeLike(term: string): string { | ||
| return term.replace(/[\\%_]/g, (ch) => `\\${ch}`); | ||
| } | ||
|
|
||
| /** 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': { | ||
| // LIKE patterns use ESCAPE '\' so literal %/_ in the term match literally | ||
| const like = filter.caseSensitive ? 'LIKE' : 'ILIKE'; | ||
| switch (filter.searchType) { | ||
| case 'contains': | ||
| return `${column} ${like} ${quoteString(`%${escapeLike(filter.term)}%`)} ESCAPE '\\\\'`; | ||
| case 'not_contains': | ||
| return `${column} NOT ${filter.caseSensitive ? 'LIKE' : 'ILIKE'} ${quoteString(`%${escapeLike(filter.term)}%`)} ESCAPE '\\\\'`; | ||
| case 'starts_with': | ||
| return `${column} ${filter.caseSensitive ? 'LIKE' : 'ILIKE'} ${quoteString(`${escapeLike(filter.term)}%`)} ESCAPE '\\\\'`; | ||
| case 'ends_with': | ||
| return `${column} ${filter.caseSensitive ? 'LIKE' : 'ILIKE'} ${quoteString(`%${escapeLike(filter.term)}`)} ESCAPE '\\\\'`; | ||
| 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; | ||
| // path.extname handles dotted directories and extensionless files correctly | ||
| const extension = path.extname(filePath).replace(/^\./, '').toLowerCase(); | ||
| const unsupported: string[] = []; | ||
|
|
||
| // The generated SQL is DuckDB-specific (file reads in FROM, ILIKE, | ||
| // regexp_matches). If the active ggsql session was repointed to another | ||
| // backend via "Connect With", the code will fail at runtime; the importer | ||
| // has no way to detect the session's backend, so always note this. | ||
| unsupported.push('Generated code requires a ggsql session using the default DuckDB backend'); | ||
|
|
||
| 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 are joined flatly, mirroring Data Explorer's | ||
| // left-to-right filter semantics. Note that `A AND B OR C` is | ||
| // parsed by SQL as `(A AND B) OR C` due to precedence rules. | ||
| predicates.push(predicates.length === 0 ? predicate : `${filter.condition.toUpperCase()} ${predicate}`); | ||
| } | ||
|
Comment on lines
+168
to
+169
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Just flagging that predicates are merged flatly. So if we have |
||
| 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, | ||
| }; | ||
| }, | ||
| }; | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
If data explorer already guarantees that
valueis a clean numeric value, we are fine but otherwise the assistant suggested to protect against SQL injection by regex-checking for numeric values.