From d677303949ff0cc430fc5a21b04928d85c848f00 Mon Sep 17 00:00:00 2001 From: Alexey Suzdalenko <62180604+suzdalenko-dev@users.noreply.github.com> Date: Sat, 8 Aug 2026 16:43:43 +0200 Subject: [PATCH] Release 0.1.4 advanced SQL navigation --- CHANGELOG.md | 20 + README.md | 34 +- package-lock.json | 4 +- package.json | 126 ++++- src/adapters/oracleAdapter.js | 6 +- src/adapters/postgresqlAdapter.js | 4 +- src/extension.js | 8 + src/managers/editorSessionManager.js | 86 ++-- src/services/sqlNavigation.js | 681 ++++++++++++++++++++++++--- src/test/liveNavigation.live.js | 232 +++++++++ src/test/manifest.test.js | 29 +- src/test/resultViewHtml.test.js | 2 +- src/test/sqlNavigation.test.js | 282 ++++++++++- src/test/sqliteAdapter.test.js | 44 ++ src/views/sqlNavigationProvider.js | 243 ++++++++-- 15 files changed, 1604 insertions(+), 197 deletions(-) create mode 100644 src/test/liveNavigation.live.js diff --git a/CHANGELOG.md b/CHANGELOG.md index dbe2d93..54e1c11 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,26 @@ All notable changes to Simple DB are documented in this file. +## 0.1.4 - 2026-08-08 + +### Added + +- A focused **Simple DB** SQL-editor submenu with **Select / Change Connection...**, **Run Statement**, **Run Script**, **Go to Definition**, and **Go to Declaration**. +- **Create New Connection...** inside the SQL-file connection picker, including the first-`Ctrl+Enter` flow when no profiles exist yet. +- Alias/column navigation such as `c.nombre -> clientes.nombre`. +- Overload-aware navigation. PostgreSQL routine metadata now includes argument/default counts, and Oracle package member navigation distinguishes compatible package members from the call arguments when possible. +- Synonym following for resolvable Oracle and SQL Server targets, with private Oracle synonyms preferred over public synonyms. +- Chained navigation from read-only database source/DDL documents while preserving the originating connection, database, and schema. +- Explicit source/metadata permission messages when an object exists but its source/DDL cannot be read. +- A real SQLite navigation integration test and an opt-in `npm run test:live-navigation` smoke test for real PostgreSQL, MySQL, SQL Server, and Oracle profiles. + +### Changed + +- Oracle package **Go to Declaration** targets the package specification; **Go to Definition** targets the matching package-body implementation. +- Ambiguous overloads return all valid navigation locations instead of silently choosing the first catalog row. +- `Ctrl+Enter` is **Run Statement**, `F5` is **Run Script**, and `F12` invokes Simple DB definition navigation in SQL editors. +- Context-only administrative/object commands are hidden from the Command Palette so the primary SQL workflow is easier to discover. + ## 0.1.3 - 2026-08-08 ### Added diff --git a/README.md b/README.md index 9d1909f..15dbc49 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,8 @@ # Simple DB -Simple DB `0.1.3` is a Visual Studio Code extension written entirely in JavaScript for working with **SQLite, PostgreSQL, MySQL, SQL Server, and Oracle** through a single interface. +Simple DB `0.1.4` is a Visual Studio Code extension written entirely in JavaScript for working with **SQLite, PostgreSQL, MySQL, SQL Server, and Oracle** through a single interface. -The extension opens regular VS Code SQL documents. `F5` launches `src/extension.js` directly: there is no TypeScript, `tsconfig.json`, `dist` folder, or compilation step. +The extension opens regular VS Code SQL documents and loads `src/extension.js` directly: there is no TypeScript, `tsconfig.json`, `dist` folder, or compilation step. ## Main features @@ -20,16 +20,17 @@ The extension opens regular VS Code SQL documents. `F5` launches `src/extension. - View results below the SQL editor in a resizable SQL Developer-style grid with row numbers, multiple result sets, types, `NULL`, affected rows, duration, and cell/row/selection copy actions. - Preserve 64-bit integers and high-precision `NUMBER` values exactly before displaying or exporting them. - Export already-retrieved results to CSV, JSON, or XLSX without executing the SQL again. -- Use native **Go to Definition** / **Go to Declaration** navigation for database objects whose source or DDL is exposed by the connected engine, including routines and Oracle packages. +- Use **Go to Definition** / **Go to Declaration** for tables, views, routines, packages, triggers, types, sequences, indexes, synonyms, and other source/DDL-backed objects supported by each engine. +- Navigate through table aliases/columns, PostgreSQL and Oracle overloads, Oracle package specifications/bodies, and synonym targets without losing the SQL file's connection context. ## Quick start 1. Open **Simple DB** in the Activity Bar and choose **Create Connection**. 2. Select the database engine and enter a connection name. SQLite uses the native file picker; network databases create a JSON profile with the correct default parameters. 3. Edit the generated JSON if needed, press `Ctrl+S`, then use **Test Connection** or **Connect** from the connection menu. Passwords stay outside JSON in VS Code `SecretStorage`. -4. Open or create any `.sql` file. Click the database icon or **Simple DB: Select Connection** in the status bar and choose the connection this file should use. If you press `Ctrl+Enter` before choosing, Simple DB asks you once and attaches the selected connection automatically. -5. Press `Ctrl+Enter` (**Execute Query**) to run the selection or statement at the cursor. Use `Ctrl+Shift+Enter` (**Execute Script**) for the entire document. Saved SQL files keep their selected connection when closed and reopened. -6. Results appear automatically in the resizable **Simple DB — Results** panel below the SQL editor. `F12` / **Go to Definition** and **Go to Declaration** use the same attached connection to resolve database objects. +4. Open or create any `.sql` file. Click the database icon or **Simple DB: Select Connection** in the status bar and choose the connection this file should use. The picker also offers **Create New Connection...**. If you press `Ctrl+Enter` before choosing, Simple DB opens the same picker and then attaches the selected connection automatically. +5. Press `Ctrl+Enter` (**Run Statement**) to run the selection or statement at the cursor. Press `F5` (**Run Script**) for the entire document. Saved SQL files keep their selected connection when closed and reopened. +6. Results appear automatically in the resizable **Simple DB — Results** panel below the SQL editor. `F12` / **Go to Definition** opens the implementation/source; **Go to Declaration** opens the declaration when the engine exposes a separate one. ## Five database engines @@ -41,7 +42,7 @@ The extension opens regular VS Code SQL documents. `F5` launches `src/extension. | SQL Server | `mssql` | databases, schemas, tables, views, procedures/functions, indexes, triggers, sequences, types, synonyms, `GO` | | Oracle | `oracledb` Thin | schemas, tables, views/materialized views, procedures/functions, packages, indexes, triggers, sequences, types, synonyms, PL/SQL | -Oracle uses the default `node-oracledb` Thin mode, so normal connections do not require Oracle Client to be installed. Simple DB `0.1.3` uses SQL authentication with a username and password for SQL Server. +Oracle uses the default `node-oracledb` Thin mode, so normal connections do not require Oracle Client to be installed. Simple DB `0.1.4` uses SQL authentication with a username and password for SQL Server. ## No imposed row limit by default @@ -144,13 +145,15 @@ The `id` is generated and managed by Simple DB. Do not change it. Use **Simple D Right-clicking inside an editor now shows a native **Simple DB** submenu with the main actions: -- Create Connection -- New Query -- Select Connection for SQL File -- Execute Query -- Execute Script +- Select / Change Connection... +- Run Statement (`Ctrl+Enter`) +- Run Script (`F5`) +- Go to Definition (`F12`) +- Go to Declaration -VS Code's native **Go to Definition** and **Go to Declaration** actions are available in SQL editors. Simple DB resolves them against the connection attached to that file and opens the database-provided source/DDL in a read-only virtual SQL document. +The lower-right status bar shows the profile currently attached to the SQL file. **Select / Change Connection...** marks that profile in the picker and lets the file switch to another one. If there are no profiles yet, **Create New Connection...** is available directly in the picker. + +Navigation resolves against the connection attached to the SQL file and opens database-provided source/DDL in a read-only virtual SQL document. Oracle package **Go to Declaration** targets the package specification while **Go to Definition** targets the package body. When an overload can be identified from the call arguments, Simple DB selects it; when several overloads remain valid, it presents all valid locations instead of guessing. `alias.column` references are resolved back to their underlying table/view column, Oracle and SQL Server synonyms are followed to the target object when it is local/resolvable, and navigation can continue from one opened database definition into another. If catalog/source permissions are insufficient, Simple DB reports that explicitly. ### SQLite note @@ -191,12 +194,15 @@ Project commands: |---|---| | `npm run lint` | Run ESLint on JavaScript sources | | `npm test` | Run JavaScript Vitest tests | +| `npm run test:live-navigation` | Run read-only metadata/source smoke checks against real server profiles plus a temporary real SQLite database | | `npm run check` | Run lint + tests | | `npm run package` | Validate and build the VSIX | | `npm run package:win32` | Build a `win32-x64` VSIX | | `npm run package:linux` | Build a `linux-x64` VSIX | -The test suite covers parsing, safety rules, DDL, storage, mocked `SecretStorage`, and a real SQLite integration. External PostgreSQL, MySQL, SQL Server, and Oracle servers require their own credentials/infrastructure for integration testing against live instances. +The normal test suite covers parsing, safety rules, DDL, storage, mocked `SecretStorage`, advanced SQL navigation, and a real SQLite integration. External PostgreSQL, MySQL, SQL Server, and Oracle servers require their own credentials/infrastructure for live verification. + +`npm run test:live-navigation` intentionally does not use mocks. Before running all five engines, define JSON profiles in `SIMPLE_DB_LIVE_POSTGRESQL_PROFILE`, `SIMPLE_DB_LIVE_MYSQL_PROFILE`, `SIMPLE_DB_LIVE_SQLSERVER_PROFILE`, and `SIMPLE_DB_LIVE_ORACLE_PROFILE`, with the matching passwords in `SIMPLE_DB_LIVE__PASSWORD`. An optional `navigationTestSchema` property selects the schema to inspect. `SIMPLE_DB_LIVE_ENGINES=sqlite` can be used to run only the self-contained SQLite live check. The live check only reads catalogs/source on the server engines; its SQLite database is created in a temporary directory. ## Project structure diff --git a/package-lock.json b/package-lock.json index aec1fdf..d89c864 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "simple-db-suzdalenko", - "version": "0.1.3", + "version": "0.1.4", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "simple-db-suzdalenko", - "version": "0.1.3", + "version": "0.1.4", "license": "MIT", "dependencies": { "exceljs": "4.4.0", diff --git a/package.json b/package.json index b48261f..65bf907 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "simple-db-suzdalenko", "displayName": "Simple DB - SQL Client for SQLite, MySQL, PostgreSQL, SQL Server & Oracle", "description": "SQL client and database editor for SQLite, MySQL, PostgreSQL, SQL Server (MSSQL) and Oracle. Browse database objects, run queries, manage DDL and export results.", - "version": "0.1.3", + "version": "0.1.4", "publisher": "suzdalenko-dev", "license": "MIT", "icon": "resources/simple-db-marketplace.png", @@ -123,13 +123,13 @@ }, { "command": "simpleDb.changeEditorConnection", - "title": "Select Connection for SQL File", + "title": "Select / Change Connection...", "category": "Simple DB", "icon": "$(database)" }, { "command": "simpleDb.executeCurrent", - "title": "Execute Query", + "title": "Run Statement", "category": "Simple DB", "icon": "$(play)" }, @@ -140,10 +140,20 @@ }, { "command": "simpleDb.executeDocument", - "title": "Execute Script", + "title": "Run Script", "category": "Simple DB", "icon": "$(run-all)" }, + { + "command": "simpleDb.goToDefinition", + "title": "Go to Definition", + "category": "Simple DB" + }, + { + "command": "simpleDb.goToDeclaration", + "title": "Go to Declaration", + "category": "Simple DB" + }, { "command": "simpleDb.cancelQuery", "title": "Cancel Query", @@ -242,6 +252,80 @@ ] }, "menus": { + "commandPalette": [ + { + "command": "simpleDb.refreshConnections", + "when": "false" + }, + { + "command": "simpleDb.connect", + "when": "false" + }, + { + "command": "simpleDb.disconnect", + "when": "false" + }, + { + "command": "simpleDb.testConnection", + "when": "false" + }, + { + "command": "simpleDb.editConnection", + "when": "false" + }, + { + "command": "simpleDb.setPassword", + "when": "false" + }, + { + "command": "simpleDb.openConnectionsFolder", + "when": "false" + }, + { + "command": "simpleDb.deleteConnection", + "when": "false" + }, + { + "command": "simpleDb.executeSelection", + "when": "false" + }, + { + "command": "simpleDb.beginTransaction", + "when": "false" + }, + { + "command": "simpleDb.commit", + "when": "false" + }, + { + "command": "simpleDb.rollback", + "when": "false" + }, + { + "command": "simpleDb.selectTable", + "when": "false" + }, + { + "command": "simpleDb.showDefinition", + "when": "false" + }, + { + "command": "simpleDb.createObject", + "when": "false" + }, + { + "command": "simpleDb.alterObject", + "when": "false" + }, + { + "command": "simpleDb.dropObjectScript", + "when": "false" + }, + { + "command": "simpleDb.copyQualifiedName", + "when": "false" + } + ], "view/title": [ { "command": "simpleDb.addConnection", @@ -361,33 +445,35 @@ "editor/context": [ { "submenu": "simpleDb.editorContextMenu", - "when": "editorLangId == sql && resourceScheme != simple-db-definition", + "when": "editorLangId == sql", "group": "simpleDb@1" } ], "simpleDb.editorContextMenu": [ { - "command": "simpleDb.addConnection", + "command": "simpleDb.changeEditorConnection", + "when": "editorLangId == sql && resourceScheme != simple-db-definition", "group": "1_connection@1" }, { - "command": "simpleDb.newQuery", - "group": "1_connection@2" + "command": "simpleDb.executeCurrent", + "when": "editorLangId == sql && resourceScheme != simple-db-definition", + "group": "2_run@1" }, { - "command": "simpleDb.changeEditorConnection", - "when": "editorLangId == sql", - "group": "2_query@1" + "command": "simpleDb.executeDocument", + "when": "editorLangId == sql && resourceScheme != simple-db-definition", + "group": "2_run@2" }, { - "command": "simpleDb.executeCurrent", + "command": "simpleDb.goToDefinition", "when": "editorLangId == sql", - "group": "2_query@2" + "group": "3_navigation@1" }, { - "command": "simpleDb.executeDocument", + "command": "simpleDb.goToDeclaration", "when": "editorLangId == sql", - "group": "2_query@3" + "group": "3_navigation@2" } ] }, @@ -400,9 +486,14 @@ }, { "command": "simpleDb.executeDocument", - "key": "ctrl+shift+enter", - "mac": "cmd+shift+enter", + "key": "f5", + "mac": "f5", "when": "editorTextFocus && editorLangId == sql && resourceScheme != simple-db-definition" + }, + { + "command": "simpleDb.goToDefinition", + "key": "f12", + "when": "editorTextFocus && editorLangId == sql" } ], "configuration": { @@ -456,6 +547,7 @@ "vscode:prepublish": "npm run check", "lint": "eslint src", "test": "vitest run --globals", + "test:live-navigation": "node src/test/liveNavigation.live.js", "check": "npm run lint && npm run test", "package": "npm run check && vsce package", "package:win32": "npm run check && vsce package --target win32-x64", diff --git a/src/adapters/oracleAdapter.js b/src/adapters/oracleAdapter.js index c6007de..34f8d2e 100644 --- a/src/adapters/oracleAdapter.js +++ b/src/adapters/oracleAdapter.js @@ -371,9 +371,11 @@ class OracleAdapter extends BaseAdapter { async listSynonyms(_database, schema) { return this._query( `SELECT synonym_name AS "name", - table_owner || '.' || table_name AS "target" + table_owner || '.' || table_name AS "target", + owner AS "owner", + db_link AS "dbLink" FROM all_synonyms - WHERE owner = :owner + WHERE owner IN (:owner, 'PUBLIC') ORDER BY synonym_name`, { owner: schema }, ); diff --git a/src/adapters/postgresqlAdapter.js b/src/adapters/postgresqlAdapter.js index de30898..242928a 100644 --- a/src/adapters/postgresqlAdapter.js +++ b/src/adapters/postgresqlAdapter.js @@ -357,7 +357,9 @@ class PostgreSqlAdapter extends BaseAdapter { database, `SELECT p.proname AS name, CASE p.prokind WHEN 'p' THEN 'PROCEDURE' ELSE 'FUNCTION' END AS type, - pg_get_function_identity_arguments(p.oid) AS signature + pg_get_function_identity_arguments(p.oid) AS signature, + p.pronargs AS "argumentCount", + p.pronargdefaults AS "defaultArgumentCount" FROM pg_proc p JOIN pg_namespace n ON n.oid = p.pronamespace WHERE n.nspname = $1 AND p.prokind IN ('f', 'p') diff --git a/src/extension.js b/src/extension.js index d05fdbe..10381ac 100644 --- a/src/extension.js +++ b/src/extension.js @@ -206,6 +206,7 @@ async function activate(context) { vscode.window.showInformationMessage( `Simple DB: "${saved.name}" created. Edit the JSON, press Ctrl+S, then test or connect.`, ); + return saved; }); registerCommand(context, 'simpleDb.refreshConnections', async () => { @@ -341,6 +342,13 @@ async function activate(context) { editorSessionManager.changeActiveConnection(), ); + registerCommand(context, 'simpleDb.goToDefinition', () => + sqlNavigationProvider.openFromActiveEditor('definition'), + ); + registerCommand(context, 'simpleDb.goToDeclaration', () => + sqlNavigationProvider.openFromActiveEditor('declaration'), + ); + registerCommand(context, 'simpleDb.executeCurrent', () => queryRunner.run('current')); registerCommand(context, 'simpleDb.executeSelection', () => queryRunner.run('selection'), diff --git a/src/managers/editorSessionManager.js b/src/managers/editorSessionManager.js index 531843a..1ff568e 100644 --- a/src/managers/editorSessionManager.js +++ b/src/managers/editorSessionManager.js @@ -139,6 +139,42 @@ class EditorSessionManager { return { document, session }; } + async _pickConnection(session, document) { + while (true) { + const profiles = this.connectionStore.list(); + const items = profiles.map((profile) => ({ + label: + (profile.id === session?.profileId ? '$(check) ' : '$(database) ') + + profile.name, + description: getDatabaseEngine(profile.engine)?.displayName || profile.engine, + detail: databaseForProfile(profile), + profile, + })); + items.push({ + label: '$(add) Create New Connection...', + description: 'Simple DB', + createConnection: true, + }); + const pick = await vscode.window.showQuickPick(items, { + title: 'Simple DB — Select Connection for SQL File', + placeHolder: session + ? 'Choose a different connection for this SQL file' + : 'Choose the connection this SQL file should use', + ignoreFocusOut: true, + }); + if (!pick) return null; + if (!pick.createConnection) return pick.profile; + + const created = await vscode.commands.executeCommand('simpleDb.addConnection'); + if (created) { + if (document) { + await vscode.window.showTextDocument(document, { preview: false }); + } + return created; + } + } + } + async ensureSession(document) { if (!this._isSqlDocument(document)) { throw new Error('Open a regular SQL file before selecting a database connection.'); @@ -146,25 +182,9 @@ class EditorSessionManager { const existing = this.get(document); if (existing) return existing; - const profiles = this.connectionStore.list(); - if (!profiles.length) { - throw new Error('Create a connection in Simple DB first.'); - } - const pick = await vscode.window.showQuickPick( - profiles.map((profile) => ({ - label: `$(database) ${profile.name}`, - description: getDatabaseEngine(profile.engine)?.displayName || profile.engine, - detail: databaseForProfile(profile), - profile, - })), - { - title: 'Simple DB — Select Connection for SQL File', - placeHolder: 'This SQL file will keep using the selected connection', - ignoreFocusOut: true, - }, - ); - if (!pick) return null; - return this.createSession(document, pick.profile); + const profile = await this._pickConnection(undefined, document); + if (!profile) return null; + return this.createSession(document, profile); } async ensureActiveSession() { @@ -184,33 +204,17 @@ class EditorSessionManager { throw new Error('Run COMMIT or ROLLBACK before changing the editor connection.'); } - const profiles = this.connectionStore.list(); - if (!profiles.length) throw new Error('Create a connection in Simple DB first.'); - const pick = await vscode.window.showQuickPick( - profiles.map((profile) => ({ - label: `${profile.id === session?.profileId ? '$(check) ' : '$(database) '}${profile.name}`, - description: getDatabaseEngine(profile.engine)?.displayName || profile.engine, - detail: databaseForProfile(profile), - profile, - })), - { - title: 'Simple DB — Select Connection for SQL File', - placeHolder: session - ? 'Choose a different connection for this SQL file' - : 'Choose the connection this SQL file should use', - ignoreFocusOut: true, - }, - ); - if (!pick) return; + const profile = await this._pickConnection(session, editor.document); + if (!profile) return; if (session) { - session.profileId = pick.profile.id; - session.database = databaseForProfile(pick.profile); - session.schema = schemaForProfile(pick.profile, session.database); + session.profileId = profile.id; + session.database = databaseForProfile(profile); + session.schema = schemaForProfile(profile, session.database); session.transactionNeedsRollback = false; await this._persist(editor.document, session); } else { - await this.createSession(editor.document, pick.profile); + await this.createSession(editor.document, profile); } this.refreshStatusBar(); } diff --git a/src/services/sqlNavigation.js b/src/services/sqlNavigation.js index 25485b6..c8e9d76 100644 --- a/src/services/sqlNavigation.js +++ b/src/services/sqlNavigation.js @@ -2,11 +2,11 @@ const { getDatabaseEngine } = require('../databaseEngines'); -const IDENTIFIER = '(?:"(?:[^"]|"")*"|`(?:[^`]|``)*`|\\[(?:[^\\]]|\\]\\])*\\]|[A-Za-z_$#][A-Za-z0-9_$#]*)'; -const QUALIFIED_IDENTIFIER = new RegExp( - `${IDENTIFIER}(?:\\s*\\.\\s*${IDENTIFIER}){0,3}`, - 'g', -); +const IDENTIFIER_SOURCE = + '(?:"(?:[^"]|"")*"|`(?:[^`]|``)*`|\\[(?:[^\\]]|\\]\\])*\\]|[A-Za-z_$#][A-Za-z0-9_$#]*)'; +const QUALIFIED_IDENTIFIER_SOURCE = + `${IDENTIFIER_SOURCE}(?:\\s*\\.\\s*${IDENTIFIER_SOURCE}){0,3}`; +const QUALIFIED_IDENTIFIER = new RegExp(QUALIFIED_IDENTIFIER_SOURCE, 'g'); const OBJECT_TYPE_BY_GROUP = Object.freeze({ tables: 'table', @@ -36,6 +36,48 @@ const NAVIGATION_GROUP_ORDER = Object.freeze([ 'events', ]); +const RELATION_GROUP_ORDER = Object.freeze([ + 'views', + 'tables', + 'materializedViews', + 'synonyms', +]); + +const CALL_GROUP_ORDER = Object.freeze(['packages', 'procedures', 'synonyms']); + +const ALIAS_STOP_WORDS = new Set([ + 'where', + 'join', + 'inner', + 'left', + 'right', + 'full', + 'cross', + 'on', + 'group', + 'order', + 'having', + 'union', + 'intersect', + 'except', + 'limit', + 'offset', + 'fetch', + 'connect', + 'start', + 'model', + 'qualify', + 'window', +]); + +class SqlNavigationError extends Error { + constructor(code, message) { + super(message); + this.name = 'SqlNavigationError'; + this.code = code; + } +} + function unquoteIdentifier(identifier) { const value = String(identifier || '').trim(); if (value.startsWith('"') && value.endsWith('"')) { @@ -50,6 +92,149 @@ function unquoteIdentifier(identifier) { return value; } +function identifierParts(value) { + const source = String(value || ''); + const parts = []; + const matcher = new RegExp(IDENTIFIER_SOURCE, 'g'); + for (let match = matcher.exec(source); match; match = matcher.exec(source)) { + parts.push(unquoteIdentifier(match[0])); + } + return parts; +} + +function splitTopLevel(value) { + const source = String(value || ''); + const parts = []; + let start = 0; + let depth = 0; + let quote = ''; + for (let index = 0; index < source.length; index += 1) { + const character = source[index]; + if (quote) { + if (character === quote) { + if (source[index + 1] === quote) { + index += 1; + } else { + quote = ''; + } + } + continue; + } + if (character === "'" || character === '"' || character === '`') { + quote = character; + continue; + } + if (character === '(' || character === '[') depth += 1; + if ((character === ')' || character === ']') && depth > 0) depth -= 1; + if (character === ',' && depth === 0) { + parts.push(source.slice(start, index).trim()); + start = index + 1; + } + } + const tail = source.slice(start).trim(); + if (tail || parts.length) parts.push(tail); + return parts.filter(Boolean); +} + +function matchingParenthesis(source, openIndex) { + let depth = 0; + let quote = ''; + for (let index = openIndex; index < source.length; index += 1) { + const character = source[index]; + if (quote) { + if (character === quote) { + if (source[index + 1] === quote) { + index += 1; + } else { + quote = ''; + } + } + continue; + } + if (character === "'" || character === '"' || character === '`') { + quote = character; + continue; + } + if (character === '(') depth += 1; + if (character === ')') { + depth -= 1; + if (depth === 0) return index; + } + } + return -1; +} + +function typeFamily(typeName) { + const value = String(typeName || '').toLowerCase(); + if (/\b(bool|boolean)\b/.test(value)) return 'boolean'; + if (/\b(date|time|timestamp|interval)\b/.test(value)) return 'datetime'; + if (/\b(bytea|blob|binary|raw|varbinary)\b/.test(value)) return 'binary'; + if (/\b(json|jsonb)\b/.test(value)) return 'json'; + if (/\b(int|integer|smallint|bigint|number|numeric|decimal|real|float|double|money)\b/.test(value)) { + return 'number'; + } + if (/\b(char|varchar|varchar2|nvarchar|nvarchar2|text|clob|uuid|xml)\b/.test(value)) { + return 'string'; + } + return ''; +} + +function expressionType(expression) { + const value = String(expression || '') + .trim() + .replace(/^[A-Za-z_$#][A-Za-z0-9_$#]*\s*=>\s*/, ''); + const cast = /(?:::|\bAS\s+)([A-Za-z_][A-Za-z0-9_$#]*(?:\s*\([^)]*\))?)\s*\)?\s*$/i.exec(value); + if (cast) return typeFamily(cast[1]); + if (/^N?'(?:[^']|'')*'$/is.test(value)) return 'string'; + if (/^(?:[-+]?\d+(?:\.\d+)?(?:e[-+]?\d+)?)$/i.test(value)) return 'number'; + if (/^(?:true|false)$/i.test(value)) return 'boolean'; + if (/^(?:date|time|timestamp)\s*'/i.test(value)) return 'datetime'; + if (/^null$/i.test(value)) return ''; + return ''; +} + +function callAt(source, endOffset) { + let openIndex = endOffset; + while (/\s/.test(source[openIndex] || '')) openIndex += 1; + if (source[openIndex] !== '(') return null; + const closeIndex = matchingParenthesis(source, openIndex); + if (closeIndex < 0) return null; + const argumentsList = splitTopLevel(source.slice(openIndex + 1, closeIndex)); + return { + argumentCount: argumentsList.length, + argumentTypes: argumentsList.map(expressionType), + arguments: argumentsList, + openIndex, + closeIndex, + }; +} + +function contextKind(source, start, call) { + if (call) return 'call'; + const before = source.slice(Math.max(0, start - 100), start); + if (/\b(?:from|join|update|into|table|view)\s*$/i.test(before)) return 'relation'; + return 'object'; +} + +function aliasesBefore(source, offset) { + const aliases = new Map(); + const matcher = new RegExp( + `\\b(?:FROM|JOIN)\\s+(${QUALIFIED_IDENTIFIER_SOURCE})(?:\\s+(?:AS\\s+)?(${IDENTIFIER_SOURCE}))?`, + 'gi', + ); + const statementStart = source.lastIndexOf(';', Math.max(0, offset - 1)) + 1; + const nextSeparator = source.indexOf(';', offset); + const statementEnd = nextSeparator >= 0 ? nextSeparator : source.length; + const scope = source.slice(statementStart, statementEnd); + for (let match = matcher.exec(scope); match; match = matcher.exec(scope)) { + if (!match[2]) continue; + const alias = unquoteIdentifier(match[2]); + if (ALIAS_STOP_WORDS.has(alias.toLowerCase())) continue; + aliases.set(alias.toLowerCase(), identifierParts(match[1])); + } + return aliases; +} + function extractSqlReference(text, offset) { const source = String(text || ''); const point = Math.max(0, Math.min(Number(offset) || 0, source.length)); @@ -58,13 +243,19 @@ function extractSqlReference(text, offset) { const start = match.index; const end = start + match[0].length; if (point < start || point > end) continue; - const parts = match[0].split('.').map(unquoteIdentifier).filter(Boolean); + const parts = identifierParts(match[0]); if (!parts.length) return null; + const call = callAt(source, end); + const aliasTarget = + parts.length === 2 ? aliasesBefore(source, start).get(parts[0].toLowerCase()) : null; return { text: match[0], parts, start, end, + call, + contextKind: contextKind(source, start, call), + aliasTarget: aliasTarget?.length ? aliasTarget : null, }; } return null; @@ -89,24 +280,27 @@ function defaultSchema(profile, session, database) { return ''; } -function navigationCandidates(profile, session, reference) { - const parts = reference?.parts || []; - if (!parts.length) return []; +function addCandidate(candidates, candidate) { + const key = [ + candidate.database, + candidate.schema, + candidate.name, + candidate.memberName, + candidate.columnName, + (candidate.groups || []).join(','), + ].join('|').toLowerCase(); + if (!candidates.some((item) => item.key === key)) { + candidates.push({ ...candidate, key }); + } +} +function candidatesForParts(profile, session, reference, parts, options = {}) { const database = defaultDatabase(profile, session); const schema = defaultSchema(profile, session, database); const candidates = []; - const add = (candidate) => { - const key = [ - candidate.database, - candidate.schema, - candidate.name, - (candidate.groups || []).join(','), - ].join('|').toLowerCase(); - if (!candidates.some((item) => item.key === key)) { - candidates.push({ ...candidate, key }); - } - }; + const groups = options.groups; + const columnName = options.columnName || ''; + const add = (candidate) => addCandidate(candidates, { ...candidate, groups, columnName }); if (parts.length === 1) { add({ database, schema, name: parts[0] }); @@ -120,31 +314,57 @@ function navigationCandidates(profile, session, reference) { schema: parts[parts.length - 3], name: parts[parts.length - 2], memberName: parts[parts.length - 1], - groups: ['packages'], }); } else { - add({ database, schema: parts[0], name: parts[1] }); - add({ + const packageCandidate = { database, schema, name: parts[0], memberName: parts[1], - groups: ['packages'], + groups: options.groups || ['packages', 'synonyms'], + columnName, + }; + const schemaCandidate = { + database, + schema: parts[0], + name: parts[1], + groups, + columnName, + }; + if (reference.contextKind === 'call') { + addCandidate(candidates, packageCandidate); + addCandidate(candidates, schemaCandidate); + } else { + addCandidate(candidates, schemaCandidate); + addCandidate(candidates, packageCandidate); + } + } + return candidates; + } + + if (profile.engine === 'sqlserver') { + if (parts.length >= 3) { + add({ + database: parts[parts.length - 3], + schema: parts[parts.length - 2], + name: parts[parts.length - 1], }); + } else { + add({ database, schema: parts[0], name: parts[1] }); } return candidates; } - if (profile.engine === 'sqlserver' && parts.length >= 3) { + if (profile.engine === 'mysql') { add({ - database: parts[parts.length - 3], + database: parts[parts.length - 2], schema: parts[parts.length - 2], name: parts[parts.length - 1], }); return candidates; } - if (profile.engine === 'mysql') { + if (profile.engine === 'sqlite') { add({ database: parts[parts.length - 2], schema: parts[parts.length - 2], @@ -161,66 +381,383 @@ function navigationCandidates(profile, session, reference) { return candidates; } -function matchingObject(objects, name) { - const exact = (objects || []).find((object) => String(object.name) === name); - if (exact) return exact; +function navigationCandidates(profile, session, reference) { + const parts = reference?.parts || []; + if (!parts.length) return []; + + const engineGroups = getDatabaseEngine(profile.engine)?.objectGroups || []; + const contextualGroups = reference.contextKind === 'call' + ? CALL_GROUP_ORDER.filter((group) => engineGroups.includes(group)) + : reference.contextKind === 'relation' + ? RELATION_GROUP_ORDER.filter((group) => engineGroups.includes(group)) + : undefined; + + if (reference.aliasTarget?.length && parts.length === 2) { + const relationGroups = RELATION_GROUP_ORDER.filter((group) => engineGroups.includes(group)); + return candidatesForParts(profile, session, reference, reference.aliasTarget, { + groups: relationGroups, + columnName: parts[1], + }); + } + + return candidatesForParts(profile, session, reference, parts, { + groups: contextualGroups, + }); +} + +function matchingObjects(objects, name) { + const values = (objects || []).filter((object) => String(object.name) === name); + if (values.length) return values; const folded = String(name).toLocaleLowerCase('en-US'); - return (objects || []).find( + return (objects || []).filter( (object) => String(object.name).toLocaleLowerCase('en-US') === folded, ); } -async function resolveSqlDefinition(connectionManager, profile, session, reference) { +function signatureParts(signature) { + return splitTopLevel(signature).map((argument) => { + return argument + .replace(/\b(?:IN|OUT|INOUT|VARIADIC)\b/gi, ' ') + .replace(/\s+/g, ' ') + .trim(); + }); +} + +function routineCandidates(objects, call) { + if (!call || objects.length <= 1) return objects; + const compatible = objects.filter((object) => { + const types = signatureParts(object.signature || ''); + const total = Number.isFinite(Number(object.argumentCount)) + ? Number(object.argumentCount) + : types.length; + const defaults = Math.max(0, Number(object.defaultArgumentCount || 0)); + const required = Math.max(0, total - defaults); + return call.argumentCount >= required && call.argumentCount <= total; + }); + if (compatible.length <= 1) return compatible; + + let bestScore = -1; + const scored = compatible.map((object) => { + const parameterTypes = signatureParts(object.signature || '').map(typeFamily); + let score = 0; + for (let index = 0; index < call.argumentTypes.length; index += 1) { + const actual = call.argumentTypes[index]; + const expected = parameterTypes[index]; + if (actual && expected) score += actual === expected ? 2 : -2; + } + bestScore = Math.max(bestScore, score); + return { object, score }; + }); + return scored.filter((item) => item.score === bestScore).map((item) => item.object); +} + +function parameterBounds(header) { + const openIndex = header.indexOf('('); + if (openIndex < 0) return { required: 0, total: 0, types: [] }; + const closeIndex = matchingParenthesis(header, openIndex); + if (closeIndex < 0) return { required: 0, total: 0, types: [] }; + const parameters = splitTopLevel(header.slice(openIndex + 1, closeIndex)); + const required = parameters.filter((parameter) => !/\bDEFAULT\b|:=/i.test(parameter)).length; + const types = parameters.map((parameter) => { + const cleaned = parameter + .replace(/^\s*[^\s]+\s+/, '') + .replace(/\b(?:IN|OUT|IN OUT|NOCOPY)\b/gi, ' ') + .replace(/\bDEFAULT\b[\s\S]*$/i, '') + .replace(/:=[\s\S]*$/, '') + .trim(); + return typeFamily(cleaned); + }); + return { required, total: parameters.length, types }; +} + +function memberOccurrences(source, memberName, start = 0, end = source.length) { + const escaped = String(memberName).replace(/[.*+?^$()|[\]\\{}]/g, '\\$&'); + const matcher = new RegExp(`\\b(?:PROCEDURE|FUNCTION)\\s+(?:"${escaped}"|${escaped})\\b`, 'gi'); + const region = source.slice(start, end); + const occurrences = []; + for (let match = matcher.exec(region); match; match = matcher.exec(region)) { + const absolute = start + match.index; + const headerEnd = source.indexOf('\n', absolute); + const semicolon = source.indexOf(';', absolute); + const limitCandidates = [headerEnd, semicolon].filter((value) => value >= absolute); + let limit = limitCandidates.length ? Math.min(...limitCandidates) : Math.min(source.length, absolute + 1000); + const open = source.indexOf('(', absolute + match[0].length); + if (open >= 0 && open < end && open < limit + 300) { + const close = matchingParenthesis(source, open); + if (close >= 0) limit = Math.max(limit, close + 1); + } + occurrences.push({ + offset: absolute + match[0].toLowerCase().lastIndexOf(memberName.toLowerCase()), + declarationOffset: absolute, + bounds: parameterBounds(source.slice(absolute, Math.min(end, limit + 1))), + }); + } + return occurrences; +} + +function scoreOccurrence(occurrence, call) { + if (!call) return 0; + if ( + call.argumentCount < occurrence.bounds.required || + call.argumentCount > occurrence.bounds.total + ) { + return Number.NEGATIVE_INFINITY; + } + let score = 1; + for (let index = 0; index < call.argumentTypes.length; index += 1) { + const actual = call.argumentTypes[index]; + const expected = occurrence.bounds.types[index]; + if (actual && expected) score += actual === expected ? 2 : -2; + } + return score; +} + +function bestOccurrences(occurrences, call) { + if (!call || occurrences.length <= 1) return occurrences; + const scored = occurrences.map((occurrence) => ({ + occurrence, + score: scoreOccurrence(occurrence, call), + })); + const best = Math.max(...scored.map((item) => item.score)); + if (!Number.isFinite(best)) return occurrences; + return scored.filter((item) => item.score === best).map((item) => item.occurrence); +} + +function packageMemberOffsets(definition, memberName, mode, call) { + const source = String(definition || ''); + const body = /\bCREATE\s+OR\s+REPLACE\s+PACKAGE\s+BODY\b/i.exec(source); + const bodyStart = body?.index ?? source.length; + const declarations = memberOccurrences(source, memberName, 0, bodyStart); + const implementations = body + ? memberOccurrences(source, memberName, bodyStart, source.length) + : []; + const selectedDeclarations = bestOccurrences(declarations, call); + + if (mode === 'declaration') { + return selectedDeclarations.length + ? selectedDeclarations.map((item) => item.offset) + : bestOccurrences(implementations, call).map((item) => item.offset); + } + + if (implementations.length && selectedDeclarations.length && declarations.length) { + const declarationIndexes = selectedDeclarations.map((item) => declarations.indexOf(item)); + const aligned = declarationIndexes + .map((index) => implementations[index]) + .filter(Boolean); + if (aligned.length) return aligned.map((item) => item.offset); + } + const selectedImplementations = bestOccurrences(implementations, call); + if (selectedImplementations.length) return selectedImplementations.map((item) => item.offset); + return selectedDeclarations.map((item) => item.offset); +} + +function symbolOffset(definition, symbol) { + if (!symbol) return 0; + const escaped = String(symbol).replace(/[.*+?^$()|[\]\\{}]/g, '\\$&'); + const match = new RegExp(escaped, 'i').exec(String(definition || '')); + return match?.index ?? 0; +} + +function permissionError(error) { + const value = `${error?.code || ''} ${error?.number || ''} ${error?.message || ''}`; + return /ORA-01031|ORA-00942|permission denied|not authorized|access denied|VIEW DEFINITION|SELECT command denied|execute command denied|ER_TABLEACCESS_DENIED_ERROR|SQLITE_AUTH|\b229\b/i.test(value); +} + +function synonymCandidate(profile, session, candidate, object) { + const target = String(object?.target || ''); + if (!target || object?.dbLink || target.includes('@')) return null; + const parts = identifierParts(target); + if (!parts.length) return null; + if (profile.engine === 'sqlserver' && parts.length >= 4) return null; + const reference = { parts, contextKind: 'object' }; + const values = candidatesForParts(profile, session, reference, parts, {}); + if (profile.engine === 'oracle' && parts.length >= 2) { + values.unshift({ + database: candidate.database, + schema: parts[parts.length - 2], + name: parts[parts.length - 1], + key: `oracle-synonym|${parts.join('|')}`.toLowerCase(), + }); + } + return values; +} + +function targetKey(candidate, group, object) { + return [candidate.database, candidate.schema, group, object.name, object.signature || ''] + .join('|') + .toLowerCase(); +} + +async function resolveSqlTargets( + connectionManager, + profile, + session, + reference, + mode = 'definition', +) { const engineGroups = getDatabaseEngine(profile.engine)?.objectGroups || []; - const defaultGroups = NAVIGATION_GROUP_ORDER.filter((group) => - engineGroups.includes(group), - ); + const defaultGroups = NAVIGATION_GROUP_ORDER.filter((group) => engineGroups.includes(group)); + const targets = []; + const seenTargets = new Set(); + const visitedSynonyms = new Set(); + let sawObjectWithoutSource = false; + let sawPermissionError = false; + + const resolveCandidates = async (candidates, depth = 0, synonymChain = []) => { + if (depth > 8) return; + for (const candidate of candidates) { + const groups = candidate.groups?.length ? candidate.groups : defaultGroups; + for (const group of groups) { + let objects; + try { + objects = await connectionManager.listObjectGroup( + profile.id, + candidate.database, + candidate.schema, + group, + ); + } catch (error) { + sawPermissionError ||= permissionError(error); + continue; + } + + let matches = matchingObjects(objects, candidate.name); + if (group === 'synonyms') { + const privateMatches = matches.filter( + (object) => + !object.owner || + String(object.owner).toLowerCase() === String(candidate.schema).toLowerCase(), + ); + if (privateMatches.length) matches = privateMatches; + } + if (group === 'procedures') { + matches = routineCandidates(matches, reference.call); + } + for (const object of matches) { + const uniqueKey = targetKey(candidate, group, object); + if (seenTargets.has(uniqueKey)) continue; + + if (group === 'synonyms') { + const synonymKey = `${candidate.database}|${candidate.schema}|${object.name}`.toLowerCase(); + if (visitedSynonyms.has(synonymKey)) continue; + visitedSynonyms.add(synonymKey); + const followed = synonymCandidate(profile, session, candidate, object); + if (followed?.length) { + await resolveCandidates(followed, depth + 1, [ + ...synonymChain, + [object.owner || candidate.schema, object.name].filter(Boolean).join('.'), + ]); + continue; + } + } - for (const candidate of navigationCandidates(profile, session, reference)) { - const groups = candidate.groups || defaultGroups; - for (const group of groups) { - let objects; - try { - objects = await connectionManager.listObjectGroup( - profile.id, - candidate.database, - candidate.schema, - group, - ); - } catch (_error) { - continue; + const objectType = OBJECT_TYPE_BY_GROUP[group]; + if (!objectType) continue; + + if (candidate.columnName && ['tables', 'views', 'materializedViews'].includes(group)) { + try { + const columns = await connectionManager.listColumns( + profile.id, + candidate.database, + candidate.schema, + candidate.name, + ); + if (columns?.length && !matchingObjects(columns, candidate.columnName).length) { + continue; + } + } catch (error) { + sawPermissionError ||= permissionError(error); + } + } + + let definition; + try { + definition = await connectionManager.getObjectDefinition( + profile.id, + candidate.database, + object.owner || candidate.schema, + candidate.name, + objectType, + { ...object, navigationMode: mode }, + ); + } catch (error) { + sawPermissionError ||= permissionError(error); + continue; + } + if (!definition) { + sawObjectWithoutSource = true; + continue; + } + + const source = String(definition); + const offsets = + objectType === 'package' && candidate.memberName + ? packageMemberOffsets(source, candidate.memberName, mode, reference.call) + : [symbolOffset(source, candidate.columnName || candidate.memberName || candidate.name)]; + if (objectType === 'package' && candidate.memberName && !offsets.length) { + continue; + } + seenTargets.add(uniqueKey); + const positions = offsets.length ? offsets : [0]; + const navigationObjectType = + objectType === 'procedure' && String(object.type || '').toUpperCase() === 'FUNCTION' + ? 'function' + : objectType; + for (const offset of positions) { + targets.push({ + database: candidate.database, + schema: object.owner || candidate.schema, + name: candidate.name, + memberName: candidate.memberName || '', + columnName: candidate.columnName || '', + objectType: navigationObjectType, + metadata: object, + definition: source, + definitionOffset: offset, + mode, + synonymChain, + }); + } + } + if (targets.length && reference.contextKind !== 'object') break; } - const object = matchingObject(objects, candidate.name); - if (!object) continue; - - const objectType = OBJECT_TYPE_BY_GROUP[group]; - if (!objectType) continue; - const definition = await connectionManager.getObjectDefinition( - profile.id, - candidate.database, - candidate.schema, - candidate.name, - objectType, - object, - ); - if (!definition) continue; - return { - database: candidate.database, - schema: candidate.schema, - name: candidate.name, - memberName: candidate.memberName || '', - objectType, - metadata: object, - definition: String(definition), - }; } + }; + + await resolveCandidates(navigationCandidates(profile, session, reference)); + + if (!targets.length && (sawPermissionError || sawObjectWithoutSource)) { + const reason = sawPermissionError + ? 'The database account cannot read the required catalog/source metadata.' + : 'The object exists, but its source/DDL is not visible to this database account.'; + throw new SqlNavigationError( + 'SIMPLE_DB_NAVIGATION_SOURCE_UNAVAILABLE', + `${reason} Check metadata/source permissions for ${reference.text}.`, + ); } - return null; + return targets; +} + +async function resolveSqlDefinition(connectionManager, profile, session, reference) { + const targets = await resolveSqlTargets( + connectionManager, + profile, + session, + reference, + 'definition', + ); + return targets[0] || null; } module.exports = { + SqlNavigationError, extractSqlReference, + identifierParts, navigationCandidates, + packageMemberOffsets, resolveSqlDefinition, + resolveSqlTargets, + routineCandidates, + splitTopLevel, unquoteIdentifier, }; diff --git a/src/test/liveNavigation.live.js b/src/test/liveNavigation.live.js new file mode 100644 index 0000000..ff309a7 --- /dev/null +++ b/src/test/liveNavigation.live.js @@ -0,0 +1,232 @@ +'use strict'; + +const fs = require('node:fs/promises'); +const os = require('node:os'); +const path = require('node:path'); +const { createAdapter } = require('../adapters/factory'); +const { getDatabaseEngine } = require('../databaseEngines'); +const { resolveSqlTargets } = require('../services/sqlNavigation'); + +const GROUP_METHODS = Object.freeze({ + tables: 'listTables', + views: 'listViews', + materializedViews: 'listMaterializedViews', + procedures: 'listProcedures', + packages: 'listPackages', + indexes: 'listIndexes', + triggers: 'listTriggers', + sequences: 'listSequences', + types: 'listTypes', + synonyms: 'listSynonyms', + events: 'listEvents', +}); + +const OBJECT_TYPES = Object.freeze({ + tables: 'table', + views: 'view', + materializedViews: 'materializedView', + procedures: 'procedure', + packages: 'package', + indexes: 'index', + triggers: 'trigger', + sequences: 'sequence', + types: 'type', + synonyms: 'synonym', + events: 'event', +}); + +function parseProfile(engine) { + const key = 'SIMPLE_DB_LIVE_' + engine.toUpperCase() + '_PROFILE'; + const value = process.env[key]; + if (!value) { + throw new Error( + 'Missing ' + + key + + '. Provide a JSON connection profile before running live navigation tests.', + ); + } + const profile = JSON.parse(value); + return { + ...profile, + id: profile.id || 'live-' + engine, + engine, + }; +} + +function defaultDatabase(profile, databases) { + return ( + profile.database || + profile.serviceName || + databases[0]?.name || + (profile.engine === 'sqlite' ? 'main' : '') + ); +} + +function defaultSchema(profile, database, schemas) { + if (profile.navigationTestSchema) return profile.navigationTestSchema; + if (profile.engine === 'oracle') return String(profile.user || '').toUpperCase(); + if (profile.engine === 'postgresql') return 'public'; + if (profile.engine === 'sqlserver') return 'dbo'; + if (profile.engine === 'mysql' || profile.engine === 'sqlite') return database; + return schemas[0]?.name || ''; +} + +function managerForAdapter(profile, adapter) { + return { + listObjectGroup: async (_profileId, database, schema, group) => { + const method = GROUP_METHODS[group]; + return method && typeof adapter[method] === 'function' + ? adapter[method](database, schema) + : []; + }, + listColumns: async (_profileId, database, schema, name) => + adapter.listColumns(database, schema, name), + getObjectDefinition: async ( + _profileId, + database, + schema, + name, + type, + metadata, + ) => adapter.getObjectDefinition(database, schema, name, type, metadata), + }; +} + +async function inspectAdapter(profile, adapter) { + await adapter.connect(); + const databases = await adapter.listDatabases(); + const database = defaultDatabase(profile, databases); + const schemas = await adapter.listSchemas(database); + const schema = defaultSchema(profile, database, schemas); + const engine = getDatabaseEngine(profile.engine); + const manager = managerForAdapter(profile, adapter); + let definitions = 0; + + console.log( + profile.engine + ': connected; database=' + database + '; schema=' + schema, + ); + for (const group of engine.objectGroups) { + const method = GROUP_METHODS[group]; + const objects = await adapter[method](database, schema); + if (!objects.length) { + console.log(' ' + group + ': no objects in test schema'); + continue; + } + const object = objects[0]; + const definition = await adapter.getObjectDefinition( + database, + object.owner || schema, + object.name, + OBJECT_TYPES[group], + object, + ); + if (!definition) { + throw new Error( + profile.engine + + ': ' + + group + + ' object ' + + object.name + + ' exists but its source/DDL is not visible.', + ); + } + definitions += 1; + console.log(' ' + group + ': source OK for ' + object.name); + } + + const tables = await adapter.listTables(database, schema); + if (tables.length) { + const table = tables[0]; + const targets = await resolveSqlTargets( + manager, + profile, + { profileId: profile.id, database, schema }, + { + text: table.name, + parts: [table.name], + contextKind: 'relation', + call: null, + }, + 'definition', + ); + if (!targets.length) { + throw new Error(profile.engine + ': resolver could not navigate to ' + table.name); + } + console.log(' resolver: table navigation OK for ' + table.name); + } + if (definitions === 0 && tables.length === 0) { + throw new Error( + profile.engine + ': the selected test schema contains no navigable objects.', + ); + } +} + +async function verifyNetworkEngine(engine) { + const profile = parseProfile(engine); + const password = + process.env['SIMPLE_DB_LIVE_' + engine.toUpperCase() + '_PASSWORD'] || ''; + const adapter = createAdapter(profile, password); + try { + await inspectAdapter(profile, adapter); + } finally { + await adapter.disconnect().catch(() => {}); + } +} + +async function verifySqlite() { + const temporary = await fs.mkdtemp(path.join(os.tmpdir(), 'simple-db-live-sqlite-')); + const profile = { + id: 'live-sqlite', + engine: 'sqlite', + filePath: path.join(temporary, 'navigation.sqlite'), + readOnly: false, + queryTimeoutMs: 0, + }; + const adapter = createAdapter(profile, ''); + const sink = { + start: async () => {}, + rows: async () => {}, + end: async () => {}, + }; + try { + await adapter.connect(); + await adapter.execute( + 'live-navigation', + 'CREATE TABLE navigation_test (id INTEGER PRIMARY KEY, name TEXT);', + { + executionId: 'live-navigation-create', + maxRows: 0, + pageSize: 100, + sink, + }, + ); + await adapter.disconnect(); + await inspectAdapter(profile, adapter); + } finally { + await adapter.disconnect().catch(() => {}); + await fs.rm(temporary, { recursive: true, force: true }); + } +} + +async function main() { + const engines = String( + process.env.SIMPLE_DB_LIVE_ENGINES || + 'sqlite,postgresql,mysql,sqlserver,oracle', + ) + .split(',') + .map((engine) => engine.trim().toLowerCase()) + .filter(Boolean); + for (const engine of engines) { + if (engine === 'sqlite') { + await verifySqlite(); + } else { + await verifyNetworkEngine(engine); + } + } + console.log('Live navigation verification passed for: ' + engines.join(', ') + '.'); +} + +main().catch((error) => { + console.error(error.stack || error.message || String(error)); + process.exitCode = 1; +}); diff --git a/src/test/manifest.test.js b/src/test/manifest.test.js index f29d9d1..76e8b6a 100644 --- a/src/test/manifest.test.js +++ b/src/test/manifest.test.js @@ -3,8 +3,8 @@ const manifest = require('../../package.json'); describe('extension manifest', () => { - it('ships 0.1.3 without the removed History feature', () => { - expect(manifest.version).toBe('0.1.3'); + it('ships 0.1.4 without the removed History feature', () => { + expect(manifest.version).toBe('0.1.4'); expect(JSON.stringify(manifest).toLowerCase()).not.toContain('history'); }); @@ -21,8 +21,31 @@ describe('extension manifest', () => { (command) => command.command === 'simpleDb.changeEditorConnection', ), ).toMatchObject({ - title: 'Select Connection for SQL File', + title: 'Select / Change Connection...', icon: '$(database)', }); }); + + it('keeps the SQL editor context menu focused on Simple DB daily actions', () => { + const menu = manifest.contributes.menus['simpleDb.editorContextMenu']; + expect(menu.map((entry) => entry.command)).toEqual([ + 'simpleDb.changeEditorConnection', + 'simpleDb.executeCurrent', + 'simpleDb.executeDocument', + 'simpleDb.goToDefinition', + 'simpleDb.goToDeclaration', + ]); + }); + + it('uses Ctrl+Enter, F5, and F12 for the main SQL workflow', () => { + const bindings = Object.fromEntries( + manifest.contributes.keybindings.map((binding) => [ + binding.command, + binding.key, + ]), + ); + expect(bindings['simpleDb.executeCurrent']).toBe('ctrl+enter'); + expect(bindings['simpleDb.executeDocument']).toBe('f5'); + expect(bindings['simpleDb.goToDefinition']).toBe('f12'); + }); }); diff --git a/src/test/resultViewHtml.test.js b/src/test/resultViewHtml.test.js index dfffb4c..63ba512 100644 --- a/src/test/resultViewHtml.test.js +++ b/src/test/resultViewHtml.test.js @@ -21,7 +21,7 @@ describe('lower Results view', () => { const panel = packageJson.contributes.viewsContainers.panel; const views = packageJson.contributes.views.simpleDbResults; - expect(packageJson.version).toBe('0.1.3'); + expect(packageJson.version).toBe('0.1.4'); expect(panel).toContainEqual( expect.objectContaining({ id: 'simpleDbResults', title: 'Simple DB' }), ); diff --git a/src/test/sqlNavigation.test.js b/src/test/sqlNavigation.test.js index f4a2b78..8f37039 100644 --- a/src/test/sqlNavigation.test.js +++ b/src/test/sqlNavigation.test.js @@ -4,6 +4,7 @@ const { extractSqlReference, navigationCandidates, resolveSqlDefinition, + resolveSqlTargets, } = require('../services/sqlNavigation'); describe('SQL navigation', () => { @@ -85,8 +86,287 @@ describe('SQL navigation', () => { expect(target).toMatchObject({ schema: 'public', name: 'calculate_total', - objectType: 'procedure', + objectType: 'function', }); expect(target.definition).toContain('FUNCTION public.calculate_total'); }); + + it('maps alias.column to the underlying relation and column', async () => { + const sql = 'SELECT c.nombre FROM clientes c WHERE c.id = 1;'; + const reference = extractSqlReference(sql, sql.indexOf('nombre') + 2); + expect(reference.aliasTarget).toEqual(['clientes']); + + const candidates = navigationCandidates( + { id: 'pg', engine: 'postgresql', database: 'app' }, + { database: 'app', schema: 'public' }, + reference, + ); + expect(candidates[0]).toMatchObject({ + database: 'app', + schema: 'public', + name: 'clientes', + columnName: 'nombre', + }); + + const manager = { + listObjectGroup: async (_id, _database, _schema, group) => + group === 'tables' ? [{ name: 'clientes' }] : [], + listColumns: async () => [{ name: 'id' }, { name: 'nombre' }], + getObjectDefinition: async () => + 'CREATE TABLE public.clientes (id integer, nombre text);', + }; + const targets = await resolveSqlTargets( + manager, + { id: 'pg', engine: 'postgresql', database: 'app' }, + { database: 'app', schema: 'public' }, + reference, + 'definition', + ); + expect(targets).toHaveLength(1); + expect(targets[0]).toMatchObject({ + name: 'clientes', + columnName: 'nombre', + objectType: 'table', + }); + expect( + targets[0].definition.slice(targets[0].definitionOffset), + ).toMatch(/^nombre/i); + }); + + it('uses the Oracle package specification for declaration and body for definition', async () => { + const source = [ + 'CREATE OR REPLACE PACKAGE ORDER_API AS', + ' PROCEDURE CREATE_ORDER(p_id NUMBER);', + ' PROCEDURE CREATE_ORDER(p_id NUMBER, p_name VARCHAR2);', + 'END ORDER_API;', + '/', + '', + 'CREATE OR REPLACE PACKAGE BODY ORDER_API AS', + ' PROCEDURE CREATE_ORDER(p_id NUMBER) IS BEGIN NULL; END;', + ' PROCEDURE CREATE_ORDER(p_id NUMBER, p_name VARCHAR2) IS BEGIN NULL; END;', + 'END ORDER_API;', + '/', + ].join('\n'); + const sql = "BEGIN ORDER_API.CREATE_ORDER(123, 'ABC'); END;"; + const reference = extractSqlReference(sql, sql.indexOf('CREATE_ORDER') + 3); + const manager = { + listObjectGroup: async (_id, _database, schema, group) => + schema === 'FROXA' && group === 'packages' + ? [{ name: 'ORDER_API', type: 'VALID' }] + : [], + getObjectDefinition: async () => source, + }; + const profile = { + id: 'oracle', + engine: 'oracle', + serviceName: 'ORCL', + user: 'FROXA', + }; + const session = { database: 'ORCL', schema: 'FROXA' }; + + const [declaration] = await resolveSqlTargets( + manager, + profile, + session, + reference, + 'declaration', + ); + const [definition] = await resolveSqlTargets( + manager, + profile, + session, + reference, + 'definition', + ); + const bodyOffset = source.indexOf('PACKAGE BODY'); + expect(declaration.definitionOffset).toBeLessThan(bodyOffset); + expect(definition.definitionOffset).toBeGreaterThan(bodyOffset); + expect(source.slice(declaration.definitionOffset)).toMatch(/^CREATE_ORDER/i); + expect(source.slice(definition.definitionOffset)).toMatch(/^CREATE_ORDER/i); + expect(source.slice(definition.definitionOffset - 12, definition.definitionOffset + 60)) + .toContain('p_name VARCHAR2'); + }); + + it('chooses the PostgreSQL overload compatible with the call signature', async () => { + const sql = "SELECT calculate_total(7, 'EUR');"; + const reference = extractSqlReference(sql, sql.indexOf('calculate_total') + 3); + const seenSignatures = []; + const manager = { + listObjectGroup: async (_id, _database, _schema, group) => + group === 'procedures' + ? [ + { + name: 'calculate_total', + type: 'FUNCTION', + signature: 'integer', + argumentCount: 1, + defaultArgumentCount: 0, + }, + { + name: 'calculate_total', + type: 'FUNCTION', + signature: 'integer, text', + argumentCount: 2, + defaultArgumentCount: 0, + }, + ] + : [], + getObjectDefinition: async (_id, _database, _schema, _name, _type, metadata) => { + seenSignatures.push(metadata.signature); + return 'CREATE FUNCTION public.calculate_total(' + metadata.signature + ') RETURNS integer AS $$ SELECT 1 $$ LANGUAGE sql;'; + }, + }; + const targets = await resolveSqlTargets( + manager, + { id: 'pg', engine: 'postgresql', database: 'app' }, + { database: 'app', schema: 'public' }, + reference, + ); + + expect(targets).toHaveLength(1); + expect(targets[0].metadata.signature).toBe('integer, text'); + expect(seenSignatures).toEqual(['integer, text']); + }); + + it('returns every equally valid overload instead of guessing', async () => { + const sql = 'SELECT convert_value(input_value);'; + const reference = extractSqlReference(sql, sql.indexOf('convert_value') + 3); + const manager = { + listObjectGroup: async (_id, _database, _schema, group) => + group === 'procedures' + ? [ + { + name: 'convert_value', + type: 'FUNCTION', + signature: 'integer', + argumentCount: 1, + }, + { + name: 'convert_value', + type: 'FUNCTION', + signature: 'text', + argumentCount: 1, + }, + ] + : [], + getObjectDefinition: async (_id, _database, _schema, _name, _type, metadata) => + 'CREATE FUNCTION convert_value(' + metadata.signature + ') RETURNS text AS $$ SELECT NULL $$ LANGUAGE sql;', + }; + const targets = await resolveSqlTargets( + manager, + { id: 'pg', engine: 'postgresql', database: 'app' }, + { database: 'app', schema: 'public' }, + reference, + ); + + expect(targets).toHaveLength(2); + expect(targets.map((target) => target.metadata.signature)).toEqual([ + 'integer', + 'text', + ]); + }); + + it('follows an Oracle synonym to the source object', async () => { + const sql = 'SELECT * FROM CLIENTES_ACTIVOS;'; + const reference = extractSqlReference(sql, sql.indexOf('CLIENTES_ACTIVOS') + 3); + const manager = { + listObjectGroup: async (_id, _database, schema, group) => { + if (schema === 'FROXA' && group === 'synonyms') { + return [ + { + name: 'CLIENTES_ACTIVOS', + owner: 'FROXA', + target: 'CORE.CLIENTES', + }, + ]; + } + if (schema === 'CORE' && group === 'tables') { + return [{ name: 'CLIENTES' }]; + } + return []; + }, + getObjectDefinition: async (_id, _database, schema, name) => + schema === 'CORE' && name === 'CLIENTES' + ? 'CREATE TABLE CORE.CLIENTES (ID NUMBER);' + : null, + }; + const targets = await resolveSqlTargets( + manager, + { id: 'oracle', engine: 'oracle', serviceName: 'ORCL', user: 'FROXA' }, + { database: 'ORCL', schema: 'FROXA' }, + reference, + ); + + expect(targets).toHaveLength(1); + expect(targets[0]).toMatchObject({ + schema: 'CORE', + name: 'CLIENTES', + objectType: 'table', + synonymChain: ['FROXA.CLIENTES_ACTIVOS'], + }); + }); + + it('follows a local SQL Server synonym across database/schema qualifiers', async () => { + const sql = 'SELECT * FROM ActiveCustomers;'; + const reference = extractSqlReference(sql, sql.indexOf('ActiveCustomers') + 3); + const manager = { + listObjectGroup: async (_id, database, schema, group) => { + if (database === 'Sales' && schema === 'dbo' && group === 'synonyms') { + return [ + { + name: 'ActiveCustomers', + target: '[Archive].[reporting].[Customers]', + }, + ]; + } + if ( + database === 'Archive' && + schema === 'reporting' && + group === 'tables' + ) { + return [{ name: 'Customers' }]; + } + return []; + }, + getObjectDefinition: async (_id, database, schema, name) => + database === 'Archive' && schema === 'reporting' && name === 'Customers' + ? 'CREATE TABLE reporting.Customers (id bigint);' + : null, + }; + const targets = await resolveSqlTargets( + manager, + { id: 'mssql', engine: 'sqlserver', database: 'Sales' }, + { database: 'Sales', schema: 'dbo' }, + reference, + ); + + expect(targets).toHaveLength(1); + expect(targets[0]).toMatchObject({ + database: 'Archive', + schema: 'reporting', + name: 'Customers', + objectType: 'table', + }); + }); + + it('reports when an existing object source is not visible', async () => { + const sql = 'SELECT * FROM secret_view;'; + const reference = extractSqlReference(sql, sql.indexOf('secret_view') + 2); + const manager = { + listObjectGroup: async (_id, _database, _schema, group) => + group === 'views' ? [{ name: 'secret_view' }] : [], + getObjectDefinition: async () => null, + }; + + await expect( + resolveSqlTargets( + manager, + { id: 'mssql', engine: 'sqlserver', database: 'Sales' }, + { database: 'Sales', schema: 'dbo' }, + reference, + ), + ).rejects.toMatchObject({ + code: 'SIMPLE_DB_NAVIGATION_SOURCE_UNAVAILABLE', + }); + }); }); diff --git a/src/test/sqliteAdapter.test.js b/src/test/sqliteAdapter.test.js index 65de806..c5da963 100644 --- a/src/test/sqliteAdapter.test.js +++ b/src/test/sqliteAdapter.test.js @@ -4,6 +4,7 @@ const fs = require('node:fs/promises'); const os = require('node:os'); const path = require('node:path'); const { SqliteAdapter } = require('../adapters/sqliteAdapter'); +const { extractSqlReference, resolveSqlTargets } = require('../services/sqlNavigation'); function collectingSink() { const sets = []; @@ -177,4 +178,47 @@ END;`); expect(await adapter.getObjectDefinition('main', 'main', 'items', 'table')).toMatch(/^CREATE TABLE/i); expect(await adapter.getObjectDefinition('main', 'main', 'trg_items', 'trigger')).toMatch(/^CREATE TRIGGER/i); }); + + it('resolves Go to Definition targets through the real SQLite catalog', async () => { + await execute('CREATE TABLE clientes (id INTEGER PRIMARY KEY, nombre TEXT NOT NULL);'); + const manager = { + listObjectGroup: async (_profileId, database, schema, group) => { + const methods = { + tables: 'listTables', + views: 'listViews', + indexes: 'listIndexes', + triggers: 'listTriggers', + }; + const method = methods[group]; + return method ? adapter[method](database, schema) : []; + }, + listColumns: async (_profileId, database, schema, name) => + adapter.listColumns(database, schema, name), + getObjectDefinition: async ( + _profileId, + database, + schema, + name, + type, + metadata, + ) => adapter.getObjectDefinition(database, schema, name, type, metadata), + }; + const sql = 'SELECT c.nombre FROM clientes c;'; + const reference = extractSqlReference(sql, sql.indexOf('nombre') + 2); + const targets = await resolveSqlTargets( + manager, + { id: 'sqlite-test', engine: 'sqlite', filePath: adapter.profile.filePath }, + { database: 'main', schema: 'main' }, + reference, + ); + + expect(targets).toHaveLength(1); + expect(targets[0]).toMatchObject({ + schema: 'main', + name: 'clientes', + columnName: 'nombre', + objectType: 'table', + }); + expect(targets[0].definition).toMatch(/^CREATE TABLE clientes/i); + }); }); diff --git a/src/views/sqlNavigationProvider.js b/src/views/sqlNavigationProvider.js index 16a319f..b1a3fb0 100644 --- a/src/views/sqlNavigationProvider.js +++ b/src/views/sqlNavigationProvider.js @@ -2,18 +2,27 @@ const vscode = require('vscode'); const { DEFINITION_SCHEME } = require('../managers/editorSessionManager'); -const { extractSqlReference, resolveSqlDefinition } = require('../services/sqlNavigation'); +const { extractSqlReference, resolveSqlTargets } = require('../services/sqlNavigation'); -function findSymbolPosition(text, symbol) { - if (!symbol) return new vscode.Position(0, 0); - const escaped = String(symbol).replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); - const match = new RegExp(`\\b${escaped}\\b`, 'i').exec(text); - if (!match) return new vscode.Position(0, 0); - const before = text.slice(0, match.index); +function positionAt(text, offset) { + const source = String(text || ''); + const safeOffset = Math.max(0, Math.min(Number(offset) || 0, source.length)); + const before = source.slice(0, safeOffset); const lines = before.split('\n'); return new vscode.Position(lines.length - 1, lines[lines.length - 1].length); } +function findSymbolPosition(text, symbol, startOffset = 0) { + if (!symbol) return new vscode.Position(0, 0); + const escaped = String(symbol).replace(/[.*+?^$()|[\]\\{}]/g, '\\$&'); + const match = new RegExp(escaped, 'i').exec(String(text || '').slice(startOffset)); + return positionAt(text, match ? startOffset + match.index : startOffset); +} + +function cancellationToken() { + return { isCancellationRequested: false }; +} + class SqlNavigationProvider { constructor(connectionStore, connectionManager, editorSessionManager) { this.connectionStore = connectionStore; @@ -24,64 +33,211 @@ class SqlNavigationProvider { } provideTextDocumentContent(uri) { - return this.documents.get(uri.toString()) || '-- Simple DB definition is no longer available.'; + return ( + this.documents.get(uri.toString())?.content || + '-- Simple DB definition is no longer available.' + ); } - async _location(document, position, token) { - if (document.uri.scheme === DEFINITION_SCHEME || token.isCancellationRequested) { - return null; + async _context(document) { + if (document.uri.scheme === DEFINITION_SCHEME) { + const record = this.documents.get(document.uri.toString()); + if (!record) return null; + const profile = this.connectionStore.get(record.profileId); + if (!profile) return null; + return { + profile, + session: { + profileId: profile.id, + database: record.database, + schema: record.schema, + }, + }; } + + const session = await this.editorSessionManager.ensureSession(document); + if (!session) return null; + const profile = this.connectionStore.get(session.profileId); + return profile ? { profile, session } : null; + } + + _virtualLocation(profile, target, localDocuments) { + const sourceKey = [ + profile.id, + target.database, + target.schema, + target.objectType, + target.name, + target.mode, + target.definition, + ].join('\u0000'); + let record = localDocuments.get(sourceKey); + if (!record) { + const qualified = [target.schema, target.name].filter(Boolean).join('.'); + const via = target.synonymChain?.length + ? '\n-- Resolved via synonym: ' + target.synonymChain.join(' -> ') + : ''; + const heading = + '-- Simple DB | ' + + profile.name + + ' | ' + + target.mode + + ' | ' + + target.objectType + + ' ' + + qualified + + via + + '\n\n'; + const content = heading + target.definition; + const serial = this.serial; + this.serial += 1; + const uri = vscode.Uri.from({ + scheme: DEFINITION_SCHEME, + path: '/' + encodeURIComponent(qualified || target.name) + '.sql', + query: + 'profile=' + + encodeURIComponent(profile.id) + + '&mode=' + + target.mode + + '&v=' + + serial, + }); + record = { + uri, + content, + headerLength: heading.length, + profileId: profile.id, + database: target.database, + schema: target.schema, + target, + }; + localDocuments.set(sourceKey, record); + this.documents.set(uri.toString(), record); + } + + const offset = record.headerLength + Math.max(0, Number(target.definitionOffset || 0)); + return new vscode.Location(record.uri, positionAt(record.content, offset)); + } + + async _locations(document, position, token, mode) { + if (token.isCancellationRequested) return null; const reference = extractSqlReference( document.getText(), document.offsetAt(position), ); if (!reference) return null; - const session = await this.editorSessionManager.ensureSession(document); - if (!session || token.isCancellationRequested) return null; - const profile = this.connectionStore.get(session.profileId); - if (!profile) return null; - - await this.connectionManager.ensureConnected(profile.id); + const context = await this._context(document); + if (!context || token.isCancellationRequested) return null; + await this.connectionManager.ensureConnected(context.profile.id); if (token.isCancellationRequested) return null; - const target = await resolveSqlDefinition( + + const targets = await resolveSqlTargets( this.connectionManager, - profile, - session, + context.profile, + context.session, reference, + mode, ); - if (!target || token.isCancellationRequested) return null; - - const qualified = [target.schema, target.name].filter(Boolean).join('.'); - const header = `-- Simple DB | ${profile.name} | ${target.objectType} ${qualified}\n\n`; - const content = `${header}${target.definition}`; - const serial = this.serial; - this.serial += 1; - const uri = vscode.Uri.from({ - scheme: DEFINITION_SCHEME, - path: `/${encodeURIComponent(target.name)}.sql`, - query: `profile=${encodeURIComponent(profile.id)}&v=${serial}`, - }); - if (this.documents.size >= 50) { - this.documents.delete(this.documents.keys().next().value); - } - this.documents.set(uri.toString(), content); + if (!targets.length || token.isCancellationRequested) return null; + + const localDocuments = new Map(); + return targets.map((target) => + this._virtualLocation(context.profile, target, localDocuments), + ); + } - const symbol = target.memberName || target.name; - return new vscode.Location(uri, findSymbolPosition(content, symbol)); + _reportError(error, mode) { + const action = mode === 'declaration' ? 'declaration' : 'definition'; + if (error?.code === 'SIMPLE_DB_NAVIGATION_SOURCE_UNAVAILABLE') { + vscode.window.showWarningMessage('Simple DB: ' + error.message); + return; + } + vscode.window.showErrorMessage( + 'Simple DB: could not find ' + action + ' — ' + error.message, + ); } - async provideDefinition(document, position, token) { + async _provide(document, position, token, mode) { try { - return await this._location(document, position, token); + const locations = await this._locations(document, position, token, mode); + if (!locations?.length) return null; + return locations.length === 1 ? locations[0] : locations; } catch (error) { - vscode.window.showErrorMessage(`Simple DB: could not find definition — ${error.message}`); + this._reportError(error, mode); return null; } } - async provideDeclaration(document, position, token) { - return this.provideDefinition(document, position, token); + provideDefinition(document, position, token) { + return this._provide(document, position, token, 'definition'); + } + + provideDeclaration(document, position, token) { + return this._provide(document, position, token, 'declaration'); + } + + async _openLocation(location) { + const document = await vscode.workspace.openTextDocument(location.uri); + await vscode.window.showTextDocument(document, { + preview: false, + selection: new vscode.Range(location.range.start, location.range.start), + }); + } + + async openFromActiveEditor(mode) { + const editor = vscode.window.activeTextEditor; + if (!editor || editor.document.languageId !== 'sql') { + throw new Error('Open a SQL editor before navigating to a database object.'); + } + let locations; + try { + locations = await this._locations( + editor.document, + editor.selection.active, + cancellationToken(), + mode, + ); + } catch (error) { + this._reportError(error, mode); + return; + } + if (!locations?.length) { + vscode.window.showInformationMessage( + 'Simple DB: no ' + mode + ' was found for the symbol under the cursor.', + ); + return; + } + if (locations.length === 1) { + await this._openLocation(locations[0]); + return; + } + + const items = []; + for (const location of locations) { + const document = await vscode.workspace.openTextDocument(location.uri); + const line = document.lineAt(location.range.start.line).text.trim(); + const record = this.documents.get(location.uri.toString()); + items.push({ + label: line || record?.target?.name || 'Database object', + description: [ + record?.target?.metadata?.signature, + [record?.target?.schema, record?.target?.name].filter(Boolean).join('.'), + ] + .filter(Boolean) + .join(' — '), + detail: record?.target?.objectType, + location, + }); + } + const title = + mode === 'declaration' ? 'Simple DB — Choose Declaration' : 'Simple DB — Choose Definition'; + const pick = await vscode.window.showQuickPick(items, { + title, + placeHolder: 'Multiple database objects or overloads match this reference', + ignoreFocusOut: true, + }); + if (pick) await this._openLocation(pick.location); } dispose() { @@ -92,4 +248,5 @@ class SqlNavigationProvider { module.exports = { SqlNavigationProvider, findSymbolPosition, + positionAt, };