├── requirements.dev.txt ├── style ├── index.js ├── index.css ├── base.css ├── icons │ ├── add_primary.svg │ ├── edit_outline_black.svg │ └── delete_outline_black.svg └── connector.css ├── setup.py ├── .yarnrc.yml ├── babel.config.js ├── setup.cfg ├── tsconfig.test.json ├── jupysql_plugin ├── widgets │ ├── __init__.py │ ├── connector_widget.py │ ├── connections.py │ └── db_templates.py ├── exceptions.py └── __init__.py ├── jupyter-config ├── jupyter_server_config.d │ └── jupysql_plugin.json └── jupyter_notebook_config.d │ └── jupysql_plugin.json ├── src ├── extension.ts ├── __tests__ │ └── jupysql_plugin.spec.ts ├── const │ └── env.ts ├── index.ts ├── version.ts ├── completer │ ├── index.ts │ ├── keywords.json │ └── customconnector.ts ├── widgets │ ├── index.ts │ └── connector.ts ├── editor │ ├── editor.ts │ └── index.ts ├── utils │ └── util.ts ├── settings │ └── index.ts ├── comm.ts └── formatter │ ├── formatter.ts │ └── index.ts ├── install.json ├── requirements.txt ├── ui-tests ├── tests │ ├── jupysql_plugin.test.ts-snapshots │ │ └── light-input-syntax-highlighting-ipynb-cell-0-darwin.png │ ├── format_sql.test.ts │ ├── utils.ts │ ├── jupysql_plugin.test.ts │ ├── completer.test.ts │ ├── widget2.test.ts │ ├── widget.test.ts │ └── widget3.test.ts ├── playwright.config.js ├── package.json ├── jupyter_server_test_config.py ├── notebooks │ └── input-syntax-highlighting.ipynb └── README.md ├── settings-schema └── settings.json ├── .eslintrc.js ├── .github ├── pull_request_template.md └── workflows │ └── ci.yaml ├── jest.config.js ├── RELEASE.md ├── doc └── README.md ├── tsconfig.json ├── noxfile.py ├── LICENSE ├── tests ├── conftest.py ├── test_connector_widget.py ├── test_connections.py └── test_db_templates.py ├── CHANGELOG.md ├── .gitignore ├── pyproject.toml ├── package.json └── README.md /requirements.dev.txt: -------------------------------------------------------------------------------- 1 | pkgmt -------------------------------------------------------------------------------- /style/index.js: -------------------------------------------------------------------------------- 1 | import './base.css'; 2 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | __import__("setuptools").setup() 2 | -------------------------------------------------------------------------------- /style/index.css: -------------------------------------------------------------------------------- 1 | @import url('base.css'); 2 | -------------------------------------------------------------------------------- /.yarnrc.yml: -------------------------------------------------------------------------------- 1 | enableImmutableInstalls: false 2 | 3 | nodeLinker: node-modules -------------------------------------------------------------------------------- /babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = require('@jupyterlab/testutils/lib/babel.config'); 2 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [flake8] 2 | max-line-length = 88 3 | extend-ignore = E203 4 | extend-exclude = node_modules -------------------------------------------------------------------------------- /tsconfig.test.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig", 3 | "compilerOptions": { 4 | "types": ["jest"] 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /jupysql_plugin/widgets/__init__.py: -------------------------------------------------------------------------------- 1 | from jupysql_plugin.widgets.connector_widget import ConnectorWidget 2 | 3 | __all__ = ["ConnectorWidget"] 4 | -------------------------------------------------------------------------------- /style/base.css: -------------------------------------------------------------------------------- 1 | /* 2 | See the JupyterLab Developer Guide for useful CSS Patterns: 3 | 4 | https://jupyterlab.readthedocs.io/en/stable/developer/css.html 5 | */ 6 | -------------------------------------------------------------------------------- /jupyter-config/jupyter_server_config.d/jupysql_plugin.json: -------------------------------------------------------------------------------- 1 | { 2 | "ServerApp": { 3 | "jpserver_extensions": { 4 | "jupysql_plugin": true 5 | } 6 | } 7 | } -------------------------------------------------------------------------------- /jupyter-config/jupyter_notebook_config.d/jupysql_plugin.json: -------------------------------------------------------------------------------- 1 | { 2 | "NotebookApp": { 3 | "nbserver_extensions": { 4 | "jupysql_plugin": true 5 | } 6 | } 7 | } -------------------------------------------------------------------------------- /src/extension.ts: -------------------------------------------------------------------------------- 1 | (window as any).__webpack_public_path__ = 2 | document.querySelector('body')!.getAttribute('data-base-url') + 3 | 'nbextensions/jupysql-plugin'; 4 | 5 | export * from './index'; -------------------------------------------------------------------------------- /style/icons/add_primary.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /install.json: -------------------------------------------------------------------------------- 1 | { 2 | "packageManager": "python", 3 | "packageName": "jupysql-plugin", 4 | "uninstallInstructions": "Use your Python package manager (pip, conda, etc.) to uninstall the package jupysql-plugin" 5 | } -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | jupyterlab>=4 2 | build 3 | twine 4 | hatch 5 | pytest 6 | pytest-jupyter[server] # for testing the server extension 7 | duckdb-engine 8 | jupysql 9 | 10 | # optional dependency, only needed for the connector widget 11 | ipywidgets -------------------------------------------------------------------------------- /ui-tests/tests/jupysql_plugin.test.ts-snapshots/light-input-syntax-highlighting-ipynb-cell-0-darwin.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ploomber/jupysql-plugin/HEAD/ui-tests/tests/jupysql_plugin.test.ts-snapshots/light-input-syntax-highlighting-ipynb-cell-0-darwin.png -------------------------------------------------------------------------------- /src/__tests__/jupysql_plugin.spec.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Example of [Jest](https://jestjs.io/docs/getting-started) unit tests 3 | */ 4 | 5 | describe('jupysql-plugin', () => { 6 | it('should be tested', () => { 7 | expect(1 + 1).toEqual(2); 8 | }); 9 | }); 10 | -------------------------------------------------------------------------------- /src/const/env.ts: -------------------------------------------------------------------------------- 1 | export const DEPLOYMENT_ENDPOINTS = { 2 | NEW_JOB: "https://platform.ploomber.io/dashboards/", 3 | NEW_NOTEBOOK: "https://platform.ploomber.io/notebooks", 4 | } 5 | 6 | export const DOCS = { 7 | GET_API_KEY: "https://docs.cloud.ploomber.io/en/latest/quickstart/apikey.html", 8 | VOILA_EXAMPLES: "https://docs.cloud.ploomber.io/en/latest/examples/voila.html", 9 | } -------------------------------------------------------------------------------- /ui-tests/playwright.config.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Configuration for Playwright using default from @jupyterlab/galata 3 | */ 4 | const baseConfig = require('@jupyterlab/galata/lib/playwright-config'); 5 | 6 | module.exports = { 7 | ...baseConfig, 8 | webServer: { 9 | command: 'jlpm start', 10 | url: 'http://localhost:8888/lab', 11 | timeout: 120 * 1000, 12 | reuseExistingServer: process.env.REUSE_EXISTING_SERVER || !process.env.CI 13 | } 14 | }; -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import { plugin_completer } from './completer/index'; 2 | import { plugin_editor } from './editor/index'; 3 | import { plugin_formatting } from './formatter/index'; 4 | import { plugin_widget } from './widgets/index'; 5 | import { plugin_settings } from './settings/index'; 6 | 7 | export * from './version'; 8 | export default [ 9 | plugin_completer, 10 | plugin_editor, 11 | plugin_formatting, 12 | plugin_widget, 13 | plugin_settings 14 | ]; 15 | -------------------------------------------------------------------------------- /settings-schema/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "title": "JupySQL", 3 | "description": "JupySQL settings.", 4 | "properties": { 5 | "showFormatSQL": { 6 | "type": "boolean", 7 | "title": "Show Format SQL button", 8 | "description": "If set, a button will be shown in the toolbar to format the SQL code.", 9 | "default": true 10 | } 11 | }, 12 | "additionalProperties": false, 13 | "type": "object" 14 | } -------------------------------------------------------------------------------- /jupysql_plugin/exceptions.py: -------------------------------------------------------------------------------- 1 | class ConnectionWithNameAlreadyExists(Exception): 2 | """ 3 | Raised when a user tries to store a new connection with the widget, but 4 | there's already a connection with such name in the connections file 5 | """ 6 | 7 | def __init__(self, name): 8 | self.name = name 9 | self.message = ( 10 | f"A connection named {name!r} already exists in your connections file" 11 | ) 12 | super().__init__(self.message) 13 | -------------------------------------------------------------------------------- /src/version.ts: -------------------------------------------------------------------------------- 1 | 2 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment 3 | // @ts-ignore 4 | // eslint-disable-next-line @typescript-eslint/no-var-requires 5 | const data = require('../package.json'); 6 | 7 | /** 8 | * The _model_module_version/_view_module_version this package implements. 9 | * 10 | * The html widget manager assumes that this is the same as the npm package 11 | * version number. 12 | */ 13 | export const MODULE_VERSION = data.version; 14 | 15 | /* 16 | * The current package name. 17 | */ 18 | export const MODULE_NAME = data.name; 19 | -------------------------------------------------------------------------------- /ui-tests/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "jupysql-plugin-ui-tests", 3 | "version": "1.0.0", 4 | "description": "JupyterLab jupysql-plugin Integration Tests", 5 | "private": true, 6 | "scripts": { 7 | "start": "jupyter lab --config jupyter_server_test_config.py", 8 | "start:detached": "yarn run start&", 9 | "test": "jlpm playwright test", 10 | "test:update": "jlpm playwright test --update-snapshots" 11 | }, 12 | "devDependencies": { 13 | "@jupyterlab/galata": "^5.0.5", 14 | "@playwright/test": "^1.37.0", 15 | "klaw-sync": "^6.0.0" 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /ui-tests/tests/format_sql.test.ts: -------------------------------------------------------------------------------- 1 | import { test } from '@jupyterlab/galata'; 2 | import { expect } from '@playwright/test'; 3 | 4 | test('test format SQL', async ({ page }) => { 5 | await page.notebook.createNew("sample.ipynb"); 6 | await page.notebook.openByPath("sample.ipynb"); 7 | await page.notebook.activate("sample.ipynb"); 8 | await page.notebook.addCell("code", "%%sql\nselect * from table") 9 | await page.getByTestId('format-btn').locator('button').click({ force: true }); 10 | await page.waitForTimeout(2000); 11 | 12 | await expect(page.locator('body')).toContainText('SELECT'); 13 | await expect(page.locator('body')).toContainText('FROM'); 14 | }); -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | "env": { 3 | "browser": true, 4 | "es2021": true 5 | }, 6 | "extends": "standard-with-typescript", 7 | "overrides": [ 8 | { 9 | "env": { 10 | "node": true 11 | }, 12 | "files": [ 13 | ".eslintrc.{js,cjs}", 14 | "" 15 | ], 16 | "parserOptions": { 17 | "sourceType": "script", 18 | "project": "./tsconfig.json", 19 | } 20 | } 21 | ], 22 | "parserOptions": { 23 | "ecmaVersion": "latest", 24 | "sourceType": "module", 25 | }, 26 | "rules": { 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /.github/pull_request_template.md: -------------------------------------------------------------------------------- 1 | ## Describe your changes 2 | 3 | ## Issue number 4 | 5 | Closes #X 6 | 7 | ## Checklist before requesting a review 8 | 9 | - [ ] Performed a self-review of my code 10 | - [ ] Formatted my code with [`pkgmt format`](https://ploomber-contributing.readthedocs.io/en/latest/contributing/pr.html#linting-formatting) 11 | - [ ] Added [tests](https://ploomber-contributing.readthedocs.io/en/latest/contributing/pr.html#testing) (when necessary). 12 | - [ ] Added [docstring](https://ploomber-contributing.readthedocs.io/en/latest/contributing/pr.html#documenting-changes-and-new-features) documentation and update the [changelog](https://ploomber-contributing.readthedocs.io/en/latest/contributing/pr.html#changelog) (when needed) 13 | -------------------------------------------------------------------------------- /jupysql_plugin/__init__.py: -------------------------------------------------------------------------------- 1 | from ._version import __version__ # noqa: F401 2 | 3 | _module_name = "jupysql-plugin" 4 | 5 | 6 | def _jupyter_labextension_paths(): 7 | return [{"src": "labextension", "dest": "jupysql-plugin"}] 8 | 9 | 10 | def _jupyter_server_extension_points(): 11 | return [{"module": "jupysql_plugin"}] 12 | 13 | 14 | def _load_jupyter_server_extension(serverapp): 15 | """ 16 | This function is called when the extension is loaded. 17 | Parameters 18 | ---------- 19 | server_app: jupyterlab.labapp.LabApp 20 | JupyterLab application instance 21 | """ 22 | serverapp.log.info(f"Registered {_module_name} server extension") 23 | 24 | 25 | load_jupyter_server_extension = _load_jupyter_server_extension 26 | -------------------------------------------------------------------------------- /ui-tests/jupyter_server_test_config.py: -------------------------------------------------------------------------------- 1 | """Server configuration for integration tests. 2 | 3 | !! Never use this configuration in production because it 4 | opens the server to the world and provide access to JupyterLab 5 | JavaScript objects through the global window variable. 6 | """ 7 | 8 | from jupyterlab.galata import configure_jupyter_server 9 | from pathlib import Path 10 | 11 | from ploomber_core.telemetry import telemetry 12 | 13 | configure_jupyter_server(c) # noqa: F821 14 | 15 | dot_ploomber = str(Path(c.ServerApp.root_dir, "dot-ploomber")) # noqa: F821 16 | 17 | # Uncomment to set server log level to debug level 18 | # c.ServerApp.log_level = "DEBUG" 19 | 20 | 21 | # patch the ploomber configurationd directory so it doesn't 22 | # interfere with the user's configuration 23 | telemetry.DEFAULT_HOME_DIR = dot_ploomber 24 | -------------------------------------------------------------------------------- /ui-tests/notebooks/input-syntax-highlighting.ipynb: -------------------------------------------------------------------------------- 1 | { 2 | "cells": [ 3 | { 4 | "cell_type": "code", 5 | "execution_count": null, 6 | "id": "1507b57c-c39e-46c6-a4e6-24f7cafe75bd", 7 | "metadata": {}, 8 | "outputs": [], 9 | "source": [ 10 | "%%sql\n", 11 | "SELECT * FROM table_name" 12 | ] 13 | } 14 | ], 15 | "metadata": { 16 | "kernelspec": { 17 | "display_name": "Python 3 (ipykernel)", 18 | "language": "python", 19 | "name": "python3" 20 | }, 21 | "language_info": { 22 | "codemirror_mode": { 23 | "name": "ipython", 24 | "version": 3 25 | }, 26 | "file_extension": ".py", 27 | "mimetype": "text/x-python", 28 | "name": "python", 29 | "nbconvert_exporter": "python", 30 | "pygments_lexer": "ipython3", 31 | "version": "3.10.12" 32 | } 33 | }, 34 | "nbformat": 4, 35 | "nbformat_minor": 5 36 | } 37 | -------------------------------------------------------------------------------- /src/completer/index.ts: -------------------------------------------------------------------------------- 1 | import { 2 | JupyterFrontEnd, 3 | JupyterFrontEndPlugin, 4 | } from '@jupyterlab/application'; 5 | import { ICompletionProviderManager } from '@jupyterlab/completer'; 6 | import { INotebookTracker } from '@jupyterlab/notebook'; 7 | 8 | import { SQLCompleterProvider } from './customconnector'; 9 | 10 | /** 11 | * Initialization data for the extension. 12 | */ 13 | const plugin_completer: JupyterFrontEndPlugin = { 14 | id: 'completer', 15 | description: 'Minimal JupyterLab extension setting up the completion.', 16 | autoStart: true, 17 | requires: [ICompletionProviderManager, INotebookTracker], 18 | activate: async ( 19 | app: JupyterFrontEnd, 20 | completionManager: ICompletionProviderManager, 21 | notebooks: INotebookTracker 22 | ) => { 23 | completionManager.registerProvider(new SQLCompleterProvider()); 24 | } 25 | }; 26 | 27 | export { plugin_completer } 28 | -------------------------------------------------------------------------------- /src/widgets/index.ts: -------------------------------------------------------------------------------- 1 | import { Application, IPlugin } from '@lumino/application'; 2 | import { Widget } from '@lumino/widgets'; 3 | import { IJupyterWidgetRegistry } from '@jupyter-widgets/base'; 4 | 5 | import * as connectorWidget from './connector'; 6 | import { MODULE_NAME, MODULE_VERSION } from '../version'; 7 | 8 | 9 | 10 | const EXTENSION_ID = 'jupysql-plugin:plugin'; 11 | 12 | /** 13 | * The widgets plugin. 14 | */ 15 | const plugin_widget: IPlugin, void> = { 16 | id: EXTENSION_ID, 17 | requires: [IJupyterWidgetRegistry], 18 | activate: activateWidgetExtension, 19 | autoStart: true, 20 | }; 21 | 22 | /** 23 | * Activate the widget extension. 24 | */ 25 | function activateWidgetExtension( 26 | app: Application, 27 | registry: IJupyterWidgetRegistry 28 | ): void { 29 | registry.registerWidget({ 30 | name: MODULE_NAME, 31 | version: MODULE_VERSION, 32 | exports: connectorWidget, 33 | }); 34 | } 35 | 36 | export { plugin_widget } 37 | -------------------------------------------------------------------------------- /src/editor/editor.ts: -------------------------------------------------------------------------------- 1 | import { Compartment, EditorState, Extension } from "@codemirror/state" 2 | import { python } from "@codemirror/lang-python" 3 | import { sql } from '@codemirror/lang-sql' 4 | 5 | const MAGIC = '%%sql'; 6 | const languageConf = new Compartment; 7 | 8 | /** 9 | * This function is called for every transaction (change in cell input). 10 | * If the cell is an SQL cell (starting with '%%sql'), then the language is set to SQL. 11 | */ 12 | const autoLanguage = EditorState.transactionExtender.of(tr => { 13 | // Check if the cell input content start with '%%sql', and configure the syntax 14 | // highlighting to SQL if necessary (default to python). 15 | const isSQL = tr.newDoc.sliceString(0, MAGIC.length) === MAGIC; 16 | return { 17 | effects: languageConf.reconfigure(isSQL ? sql() : python()) 18 | }; 19 | }) 20 | 21 | 22 | // Full extension composed of elemental extensions 23 | export function languageSelection(): Extension { 24 | return [ 25 | languageConf.of(python()), 26 | autoLanguage, 27 | ]; 28 | } 29 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | const jestJupyterLab = require('@jupyterlab/testutils/lib/jest-config'); 2 | 3 | const esModules = [ 4 | '@jupyterlab/', 5 | 'lib0', 6 | 'nanoid', 7 | 'y\\-protocols', 8 | 'y\\-websocket', 9 | 'yjs' 10 | ].join('|'); 11 | 12 | const jlabConfig = jestJupyterLab(__dirname); 13 | 14 | const { 15 | moduleFileExtensions, 16 | moduleNameMapper, 17 | preset, 18 | setupFilesAfterEnv, 19 | setupFiles, 20 | testPathIgnorePatterns, 21 | transform 22 | } = jlabConfig; 23 | 24 | module.exports = { 25 | moduleFileExtensions, 26 | moduleNameMapper, 27 | preset, 28 | setupFilesAfterEnv, 29 | setupFiles, 30 | testPathIgnorePatterns, 31 | transform, 32 | automock: false, 33 | collectCoverageFrom: [ 34 | 'src/**/*.{ts,tsx}', 35 | '!src/**/*.d.ts', 36 | '!src/**/.ipynb_checkpoints/*' 37 | ], 38 | coverageDirectory: 'coverage', 39 | coverageReporters: ['lcov', 'text'], 40 | testRegex: 'src/.*/.*.spec.ts[x]?$', 41 | transformIgnorePatterns: [`/node_modules/(?!${esModules}).+`], 42 | testEnvironment: 'jsdom' 43 | }; 44 | -------------------------------------------------------------------------------- /ui-tests/tests/utils.ts: -------------------------------------------------------------------------------- 1 | async function createNewNotebook(page) { 2 | await page.notebook.createNew("notebok.ipynb"); 3 | await page.notebook.openByPath("notebok.ipynb"); 4 | await page.notebook.activate("notebok.ipynb"); 5 | } 6 | 7 | async function displayWidget(page) { 8 | // create notebook 9 | await createNewNotebook(page); 10 | 11 | // render widget 12 | await page.notebook.enterCellEditingMode(0); 13 | const cell = await page.notebook.getCell(0) 14 | await cell?.type(` 15 | %load_ext sql 16 | %config SqlMagic.dsn_filename = 'connections.ini' 17 | from jupysql_plugin.widgets import ConnectorWidget 18 | ConnectorWidget()`) 19 | await page.notebook.run() 20 | } 21 | 22 | 23 | async function createDefaultConnection(page) { 24 | await displayWidget(page); 25 | 26 | // click on create new connection button and create a new connection 27 | await page.locator('#createNewConnection').click(); 28 | await page.locator('#createConnectionFormButton').click(); 29 | } 30 | 31 | 32 | export { createNewNotebook, displayWidget, createDefaultConnection } -------------------------------------------------------------------------------- /RELEASE.md: -------------------------------------------------------------------------------- 1 | # Making a new release of jupysql-plugin 2 | 3 | Create conda environment: 4 | 5 | ```bash 6 | conda env create -f environment.yml -y 7 | ``` 8 | 9 | Bump the version using `hatch`. See the docs on [hatch-nodejs-version](https://github.com/agoose77/hatch-nodejs-version#semver) for details. 10 | 11 | ```bash 12 | NEW_VERSION='NEW_VERSION' 13 | hatch version $NEW_VERSION 14 | git add --all 15 | git commit -m "Version $NEW_VERSION" 16 | ``` 17 | 18 | The previous command will update the version in the `package.json` file. You have to manually commit and create the tag: 19 | 20 | ```bash 21 | git tag -a $NEW_VERSION -m "Version $NEW_VERSION" 22 | git push 23 | git push --tag 24 | ``` 25 | 26 | To create a Python source package (`.tar.gz`) and the binary package (`.whl`) in the `dist/` directory, do: 27 | 28 | *Note:* The following command needs NodeJS: 29 | 30 | 31 | ```bash 32 | # clean files before building 33 | rm -rf dist 34 | jlpm clean:all 35 | 36 | # build the package 37 | python -m build 38 | ``` 39 | 40 | Then to upload the package to PyPI, do: 41 | 42 | ```bash 43 | twine upload dist/* 44 | ``` 45 | -------------------------------------------------------------------------------- /doc/README.md: -------------------------------------------------------------------------------- 1 | # Learning JupyterLab extensions + widgets 2 | 3 | `jupysql-plugin` is both a JupyterLab extension and a package with custom widgets. 4 | 5 | ## Jupyter extensions 6 | 7 | 8 | To familiarize yourself with how JupyterLab extensions are built. Follow the official [tutorial.](https://jupyterlab.readthedocs.io/en/stable/extension/extension_tutorial.html) 9 | 10 | There is also a repository with [examples](https://github.com/jupyterlab/extension-examples). **Important:** ensure you move to the 3.0 branch because JupyterLab 4.0 was just release it. Make it work with 3.0, and then we can decide if we also support 4.0. 11 | 12 | Some notes from following the tutorial (April 4th, 2023): 13 | 14 | - Install the extension with `jupyter labextension develop --overwrite .`, because using `pip install` won't update the extension 15 | - When installing the widgets library, run `jlpm add @lumino/widgets@'<2.0.0'` 16 | 17 | ## Jupyter widgets 18 | 19 | Here's an exaplanation of how [widgets work](https://ipywidgets.readthedocs.io/en/stable/examples/Widget%20Low%20Level.html), and here's a [template](https://github.com/jupyter-widgets/widget-ts-cookiecutter) you can use to experiment with. 20 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "skipLibCheck": true, 4 | "allowSyntheticDefaultImports": true, 5 | "composite": true, 6 | "declaration": true, 7 | "esModuleInterop": true, 8 | "incremental": true, 9 | "jsx": "react", 10 | "module": "esnext", 11 | "moduleResolution": "node", 12 | "noEmitOnError": true, 13 | "noImplicitAny": true, 14 | "noUnusedLocals": true, 15 | "preserveWatchOutput": true, 16 | "resolveJsonModule": true, 17 | "outDir": "lib", 18 | "rootDir": "src", 19 | "strict": true, 20 | "strictNullChecks": false, 21 | "target": "ES2018", 22 | "types": [ 23 | "jest" 24 | ] 25 | }, 26 | "include": [ 27 | "src/*", 28 | "src/utils/*", 29 | "src/const/*", 30 | "src/completer/keywords.json", 31 | "src/widgets/connector.ts", 32 | "src/widgets/index.ts", 33 | "src/formatter/formatter.ts", 34 | "src/syntax-highlight/index.ts", 35 | "src/completer/index.ts", 36 | "src/formatter/index.ts", 37 | "src/settings/index.ts", 38 | "src/completer/connector.ts", 39 | "src/completer/customconnector.ts", 40 | "src/deploy-notebook/index.ts", 41 | "src/editor/editor.ts", 42 | "src/editor/index.ts", 43 | ] 44 | } 45 | -------------------------------------------------------------------------------- /src/editor/index.ts: -------------------------------------------------------------------------------- 1 | import { 2 | JupyterFrontEnd, 3 | JupyterFrontEndPlugin 4 | } from '@jupyterlab/application'; 5 | import { 6 | EditorExtensionRegistry, 7 | IEditorExtensionRegistry 8 | } from '@jupyterlab/codemirror'; 9 | 10 | import { languageSelection } from './editor'; 11 | 12 | /** 13 | * Initialization data for the @jupyterlab-examples/codemirror-extension extension. 14 | */ 15 | const plugin_editor: JupyterFrontEndPlugin = { 16 | id: 'jupysql-plugin:syntax-highlighting', 17 | description: 'A minimal JupyterLab extension adding a CodeMirror extension.', 18 | autoStart: true, 19 | requires: [IEditorExtensionRegistry], 20 | activate: (app: JupyterFrontEnd, extensions: IEditorExtensionRegistry) => { 21 | // Register a new editor configurable extension 22 | extensions.addExtension( 23 | Object.freeze({ 24 | name: 'jupysql-plugin:syntax-highlighting', 25 | // Default CodeMirror extension parameters 26 | default: 2, 27 | factory: () => 28 | // The factory will be called for every new CodeMirror editor 29 | EditorExtensionRegistry.createConfigurableExtension(() => 30 | languageSelection() 31 | ) 32 | }) 33 | ); 34 | } 35 | }; 36 | 37 | export { plugin_editor }; 38 | -------------------------------------------------------------------------------- /style/icons/edit_outline_black.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /noxfile.py: -------------------------------------------------------------------------------- 1 | import nox 2 | 3 | from os import environ 4 | 5 | 6 | def _setup(session): 7 | session.run("python", "--version") 8 | session.install("-r", "requirements.txt") 9 | session.install("-r", "requirements.dev.txt") 10 | 11 | # our tests assume that the cell toolbar is hidden 12 | session.run( 13 | "jupyter", "labextension", "disable", "@jupyterlab/cell-toolbar-extension" 14 | ) 15 | 16 | # on github actions, we often get a timeout when installing the dependencies 17 | session.run("jlpm", "config", "set", "httpTimeout", "600000") 18 | 19 | session.run("jlpm", "install") 20 | session.install("-e", ".") 21 | session.run("python", "-c", "import jupysql_plugin") 22 | 23 | 24 | @nox.session( 25 | python=environ.get("PYTHON_VERSION", "3.11"), 26 | ) 27 | def test(session): 28 | _setup(session) 29 | 30 | # unit tests 31 | session.run("pytest", "tests") 32 | session.run("jlpm", "test") 33 | 34 | 35 | @nox.session( 36 | python=environ.get("PYTHON_VERSION", "3.11"), 37 | ) 38 | def ui_test(session): 39 | _setup(session) 40 | 41 | with session.chdir("ui-tests"): 42 | session.run("jlpm", "install") 43 | # TODO: this will install all playwright browsers, but we only need one 44 | session.run("jlpm", "playwright", "install") 45 | session.run("jlpm", "test", *session.posargs) 46 | -------------------------------------------------------------------------------- /src/utils/util.ts: -------------------------------------------------------------------------------- 1 | import { URLExt } from '@jupyterlab/coreutils'; 2 | 3 | import { ServerConnection } from '@jupyterlab/services'; 4 | 5 | /** 6 | * Call the API extension 7 | * 8 | * @param endPoint API REST end point for the extension 9 | * @param init Initial values for the request 10 | * @returns The response body interpreted as JSON 11 | */ 12 | export async function requestAPI( 13 | endPoint = '', 14 | init: RequestInit = {} 15 | ): Promise { 16 | // Make request to Jupyter API 17 | const settings = ServerConnection.makeSettings(); 18 | const requestUrl = URLExt.join( 19 | settings.baseUrl, 20 | 'ploomber', // API Namespace 21 | endPoint 22 | ); 23 | 24 | let response: Response; 25 | try { 26 | response = await ServerConnection.makeRequest(requestUrl, init, settings); 27 | } catch (error) { 28 | throw new ServerConnection.NetworkError(error as any); 29 | } 30 | 31 | let data: any = await response.text(); 32 | 33 | if (data.length > 0) { 34 | try { 35 | data = JSON.parse(data); 36 | } catch (error) { 37 | console.log('Not a JSON response body.', response); 38 | } 39 | } 40 | 41 | if (!response.ok) { 42 | throw new ServerConnection.ResponseError(response, data.message || data); 43 | } 44 | 45 | return data; 46 | } -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | BSD 3-Clause License 2 | 3 | Copyright (c) 2023, Ploomber 4 | All rights reserved. 5 | 6 | Redistribution and use in source and binary forms, with or without 7 | modification, are permitted provided that the following conditions are met: 8 | 9 | 1. Redistributions of source code must retain the above copyright notice, this 10 | list of conditions and the following disclaimer. 11 | 12 | 2. Redistributions in binary form must reproduce the above copyright notice, 13 | this list of conditions and the following disclaimer in the documentation 14 | and/or other materials provided with the distribution. 15 | 16 | 3. Neither the name of the copyright holder nor the names of its 17 | contributors may be used to endorse or promote products derived from 18 | this software without specific prior written permission. 19 | 20 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 21 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 22 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 23 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 24 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 25 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 26 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 27 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 28 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 29 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 30 | -------------------------------------------------------------------------------- /style/icons/delete_outline_black.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/settings/index.ts: -------------------------------------------------------------------------------- 1 | import { 2 | JupyterFrontEnd, 3 | JupyterFrontEndPlugin, 4 | } from '@jupyterlab/application'; 5 | import { NotebookPanel } from '@jupyterlab/notebook'; 6 | import { ISettingRegistry } from '@jupyterlab/settingregistry'; 7 | import { Signal } from '@lumino/signaling'; // Import the Signal class 8 | 9 | 10 | const PLUGIN_ID = 'jupysql-plugin:settings'; 11 | 12 | export interface JupySQLSettings { 13 | showDeployNotebook: boolean; 14 | showFormatSQL: boolean; 15 | 16 | } 17 | 18 | export const settingsChanged = new Signal({}); 19 | 20 | 21 | /** 22 | * Initialization data for the settings extension. 23 | */ 24 | const plugin_settings: JupyterFrontEndPlugin = { 25 | id: PLUGIN_ID, 26 | autoStart: true, 27 | requires: [ISettingRegistry], 28 | activate: (app: JupyterFrontEnd, settings: ISettingRegistry, panel: NotebookPanel) => { 29 | /** 30 | * Load the settings for this extension 31 | * 32 | * @param setting Extension settings 33 | */ 34 | function loadSetting(setting: ISettingRegistry.ISettings): void { 35 | const showDeployNotebook = setting.get('showDeployNotebook').composite as boolean; 36 | const showFormatSQL = setting.get('showFormatSQL').composite as boolean; 37 | 38 | settingsChanged.emit({ showDeployNotebook, showFormatSQL }); 39 | 40 | } 41 | 42 | // Wait for the application to be restored and 43 | // for the settings for this plugin to be loaded 44 | Promise.all([app.restored, settings.load(PLUGIN_ID)]) 45 | .then(([, setting]) => { 46 | // Read the settings 47 | loadSetting(setting); 48 | // Listen for your plugin setting changes using Signal 49 | setting.changed.connect(loadSetting); 50 | }) 51 | .catch((reason) => { 52 | console.error( 53 | `Something went wrong when reading the settings.\n${reason}` 54 | ); 55 | }); 56 | }, 57 | }; 58 | 59 | export { plugin_settings } -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | import os 2 | from pathlib import Path 3 | 4 | import pytest 5 | from IPython import InteractiveShell 6 | from traitlets.config import Config 7 | 8 | 9 | from sql.magic import SqlMagic, _set_sql_magic 10 | from sql import connection 11 | 12 | 13 | # https://github.com/jupyter-server/pytest-jupyter 14 | pytest_plugins = ["pytest_jupyter.jupyter_server"] 15 | 16 | 17 | @pytest.fixture() 18 | def jp_server_config(): 19 | """Allows tests to setup their specific configuration values.""" 20 | return Config( 21 | { 22 | "ServerApp": { 23 | "jpserver_extensions": {"jupysql_plugin": True}, 24 | } 25 | } 26 | ) 27 | 28 | 29 | def _init_sql_magic(): 30 | # currently the connection widget loads the config when importing the module 31 | # so we need to ensure the magic is initialized before importing the widget 32 | shell = InteractiveShell() 33 | sql_magic = SqlMagic(shell) 34 | 35 | # change the default dsn filename so we don't read from the home directory 36 | sql_magic.dsn_filename = "jupysql-plugin.ini" 37 | _set_sql_magic(sql_magic) 38 | 39 | return sql_magic 40 | 41 | 42 | @pytest.fixture(scope="function", autouse=True) 43 | def isolate_tests(monkeypatch): 44 | """ 45 | Fixture to ensure connections are isolated between tests, preventing tests 46 | from accidentally closing connections created by other tests. 47 | 48 | Also clear up any stored snippets. 49 | """ 50 | _init_sql_magic() 51 | 52 | # reset connections 53 | connections = {} 54 | monkeypatch.setattr(connection.ConnectionManager, "connections", connections) 55 | monkeypatch.setattr(connection.ConnectionManager, "current", None) 56 | 57 | yield 58 | 59 | # close connections 60 | connection.ConnectionManager.close_all() 61 | 62 | 63 | @pytest.fixture 64 | def tmp_empty(tmp_path): 65 | """ 66 | Create temporary path using pytest native fixture, 67 | them move it, yield, and restore the original path 68 | """ 69 | 70 | old = os.getcwd() 71 | os.chdir(str(tmp_path)) 72 | yield str(Path(tmp_path).resolve()) 73 | os.chdir(old) 74 | 75 | 76 | @pytest.fixture 77 | def override_sql_magic(): 78 | yield _init_sql_magic() 79 | -------------------------------------------------------------------------------- /src/comm.ts: -------------------------------------------------------------------------------- 1 | // Opens a comm from the frontend to the kernel 2 | import { NotebookPanel, INotebookModel } from '@jupyterlab/notebook'; 3 | import { IDisposable, DisposableDelegate } from '@lumino/disposable'; 4 | import { DocumentRegistry } from '@jupyterlab/docregistry'; 5 | 6 | 7 | export const registerCommTargets = (context: DocumentRegistry.IContext): void => { 8 | const sessionContext = context.sessionContext; 9 | const kernel = sessionContext.session?.kernel; 10 | 11 | if (!kernel) 12 | return 13 | 14 | // Listen to updateTableWidget event 15 | document.addEventListener("onUpdateTableWidget", async (event: Event) => { 16 | const customEvent = event 17 | const data = customEvent.detail.data 18 | 19 | // Register to table_widget handler in the JupySQL kernel 20 | const comm = kernel.createComm("comm_target_handle_table_widget"); 21 | 22 | await comm.open('initializing connection').done; 23 | 24 | // Send data to the Kernel to recevice rows to display 25 | comm.send(data); 26 | 27 | // Handle recevied rows 28 | comm.onMsg = (msg) => { 29 | const content = msg.content; 30 | const data = <{ rows: any }>content.data; 31 | 32 | // Raise event to update table with new rows 33 | let customEvent = new CustomEvent('onTableWidgetRowsReady', { 34 | bubbles: true, 35 | cancelable: true, 36 | composed: false, 37 | detail: { 38 | data: data 39 | } 40 | }); 41 | document.body.dispatchEvent(customEvent); 42 | } 43 | }) 44 | 45 | }; 46 | 47 | 48 | 49 | // comm listener required for JupySQL table widget 50 | export class RegisterNotebookCommListener 51 | implements DocumentRegistry.IWidgetExtension 52 | { 53 | /** 54 | * Register notebook comm 55 | * 56 | * @param panel Notebook panel 57 | * @param context Notebook context 58 | * @returns Disposable on the added button 59 | */ 60 | createNew( 61 | panel: NotebookPanel, 62 | context: DocumentRegistry.IContext 63 | ): IDisposable { 64 | 65 | setTimeout(() => { 66 | registerCommTargets(context) 67 | }, 5000) 68 | 69 | return new DisposableDelegate(() => { 70 | 71 | }); 72 | } 73 | } 74 | 75 | -------------------------------------------------------------------------------- /.github/workflows/ci.yaml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | - 'dev/**' 8 | - jupyterlab3 # version compatible with JupyterLab 3, 0.3.x releases 9 | pull_request: 10 | 11 | defaults: 12 | run: 13 | shell: bash -l {0} 14 | 15 | jobs: 16 | unit-test: 17 | runs-on: ${{ matrix.os }} 18 | 19 | strategy: 20 | matrix: 21 | python-version: [3.8, 3.9, '3.10', '3.11'] 22 | os: [macos-latest] 23 | 24 | env: 25 | PYTHON_VERSION: ${{ matrix.python-version }} 26 | 27 | steps: 28 | - name: Checkout 29 | uses: actions/checkout@v3 30 | 31 | - name: Set up Python ${{ matrix.python-version }} 32 | uses: actions/setup-python@v5 33 | with: 34 | python-version: ${{ matrix.python-version }} 35 | cache: pip 36 | 37 | - name: Lint 38 | run: | 39 | pip install --upgrade pip 40 | pip install --upgrade pkgmt nox 41 | pkgmt lint 42 | 43 | - name: Setup node 44 | uses: actions/setup-node@v3 45 | with: 46 | node-version: 20 47 | cache: yarn 48 | 49 | - name: Test 50 | run: | 51 | nox --session test --verbose 52 | 53 | ui-test: 54 | runs-on: ${{ matrix.os }} 55 | 56 | strategy: 57 | matrix: 58 | python-version: ['3.11'] 59 | shard: [1-of-3, 2-of-3, 3-of-3] 60 | # we're creating macos snapshots 61 | os: [macos-latest] 62 | 63 | env: 64 | PYTHON_VERSION: ${{ matrix.python-version }} 65 | 66 | steps: 67 | - name: Checkout 68 | uses: actions/checkout@v3 69 | 70 | - name: Set up Python ${{ matrix.python-version }} 71 | uses: actions/setup-python@v5 72 | with: 73 | python-version: ${{ matrix.python-version }} 74 | cache: pip 75 | 76 | - name: Setup node 77 | uses: actions/setup-node@v3 78 | with: 79 | node-version: 20 80 | cache: yarn 81 | 82 | - name: Install and test 83 | run: | 84 | pip install --upgrade pip 85 | pip install --upgrade nox 86 | SHARD=${{ matrix.shard }} 87 | # convert 1-of-4 to 1/4 88 | SHARD_ARG=$(echo $SHARD | sed 's/-of-/\//g') 89 | nox --session ui_test --verbose -- --shard $SHARD_ARG --reporter list 90 | 91 | - name: Upload test snapshots 92 | uses: actions/upload-artifact@v3 93 | if: failure() 94 | with: 95 | name: test-results-${{ matrix.os }}-${{ matrix.python-version }}-${{ matrix.shard }} 96 | path: ui-tests/test-results 97 | -------------------------------------------------------------------------------- /src/completer/keywords.json: -------------------------------------------------------------------------------- 1 | { 2 | "keywords": [ 3 | { "value": "ADD" }, 4 | { "value": "ADD CONSTRAINT" }, 5 | { "value": "ALL" }, 6 | { "value": "ALTER" }, 7 | { "value": "ALTER COLUMN" }, 8 | { "value": "ALTER TABLE" }, 9 | { "value": "AND" }, 10 | { "value": "ANY" }, 11 | { "value": "AS" }, 12 | { "value": "ASC" }, 13 | { "value": "BACKUP DATABASE" }, 14 | { "value": "BETWEEN" }, 15 | { "value": "CASE" }, 16 | { "value": "CHECK" }, 17 | { "value": "COLUMN" }, 18 | { "value": "CONSTRAINT" }, 19 | { "value": "CREATE" }, 20 | { "value": "CREATE DATABASE" }, 21 | { "value": "CREATE INDEX" }, 22 | { "value": "CREATE OR REPLACE VIEW" }, 23 | { "value": "CREATE TABLE" }, 24 | { "value": "CREATE PROCEDURE" }, 25 | { "value": "CREATE UNIQUE INDEX" }, 26 | { "value": "CREATE VIEW" }, 27 | { "value": "DATABASE" }, 28 | { "value": "DEFAULT" }, 29 | { "value": "DELETE" }, 30 | { "value": "DESC" }, 31 | { "value": "DISTINCT" }, 32 | { "value": "DROP" }, 33 | { "value": "DROP COLUMN" }, 34 | { "value": "DROP CONSTRAINT" }, 35 | { "value": "DROP DATABASE" }, 36 | { "value": "DROP DEFAULT" }, 37 | { "value": "DROP INDEX" }, 38 | { "value": "DROP TABLE" }, 39 | { "value": "DROP VIEW" }, 40 | { "value": "EXEC" }, 41 | { "value": "EXISTS" }, 42 | { "value": "FOREIGN KEY" }, 43 | { "value": "FROM" }, 44 | { "value": "FULL OUTER JOIN" }, 45 | { "value": "GROUP BY" }, 46 | { "value": "HAVING" }, 47 | { "value": "IN" }, 48 | { "value": "INDEX" }, 49 | { "value": "INNER JOIN" }, 50 | { "value": "INSERT INTO" }, 51 | { "value": "INSERT INTO SELECT" }, 52 | { "value": "IS NULL" }, 53 | { "value": "IS NOT NULL" }, 54 | { "value": "JOIN" }, 55 | { "value": "LEFT JOIN" }, 56 | { "value": "LIKE" }, 57 | { "value": "LIMIT" }, 58 | { "value": "NOT" }, 59 | { "value": "NOT NULL" }, 60 | { "value": "OR" }, 61 | { "value": "ORDER BY" }, 62 | { "value": "OUTER JOIN" }, 63 | { "value": "PRIMARY KEY" }, 64 | { "value": "PROCEDURE" }, 65 | { "value": "RIGHT JOIN" }, 66 | { "value": "ROWNUM" }, 67 | { "value": "SELECT" }, 68 | { "value": "SELECT DISTINCT" }, 69 | { "value": "SELECT INTO" }, 70 | { "value": "SELECT TOP" }, 71 | { "value": "SET" }, 72 | { "value": "TABLE" }, 73 | { "value": "TOP" }, 74 | { "value": "TRUNCATE TABLE" }, 75 | { "value": "UNION" }, 76 | { "value": "UNION ALL" }, 77 | { "value": "UNIQUE" }, 78 | { "value": "UPDATE" }, 79 | { "value": "VALUES" }, 80 | { "value": "VIEW" }, 81 | { "value": "WHERE" } 82 | ] 83 | } 84 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # CHANGELOG 2 | 3 | ## 0.4.5 4 | 5 | * Removes bootstrap since it was breaking JupyterLab UI 6 | 7 | ## 0.4.4 8 | 9 | * Fixes error that caused the `Format SQL` bitton to appear even when disabled in the settings 10 | 11 | ## 0.4.3 12 | 13 | * Trigger autocomplete only when the cell begins with `%sql` or `%%sql` 14 | 15 | ## 0.4.2 16 | 17 | * Removed `Share notebook` button 18 | 19 | ## 0.4.1 20 | 21 | * Fix `Share notebook` bug not showing URL (#95) 22 | 23 | ## 0.4.0 24 | 25 | * Support for JupyterLab 4 (for JupyterLab 3, install `pip install jupysql-plugin<0.4`) 26 | 27 | ## 0.3.1 28 | 29 | * Changes API header from `access_token` to `api_key` 30 | 31 | ## 0.3.0 32 | 33 | * Removes `Deploy notebook` 34 | * Adds `Share notebook` button which uploads a notebook to render it as a static file 35 | 36 | ## 0.2.7 37 | 38 | * Fix `Deploy notebook` due to breaking change in API 39 | 40 | ## 0.2.6 41 | 42 | * Fix `Deploy notebook` functionality 43 | 44 | ## 0.2.5 45 | 46 | * Automatically activate server extension 47 | 48 | ## 0.2.4 49 | 50 | * Re-release due to error in the release process 51 | 52 | ## 0.2.3 53 | 54 | * Fixed error that caused the `Deploy notebook` and `Format SQL` buttons not to appear on new installations 55 | 56 | ## 0.2.2 57 | 58 | * Added Oracle, Microsoft SQLServer, Redshift in DB templates (#72) 59 | * Auto save form fields when switching connection labels (#71) 60 | * Add configuration settings to hide `Format SQL` and `Deploy Notebook` buttons 61 | * `ipywidgets` no longer a hard requirement 62 | 63 | ## 0.2.1 64 | 65 | * Connector widget creates parent directories if needed 66 | * Connector widget sets the default alias as "default" if the `.ini` file has no connections 67 | * Connector widget does not modify `.ini` file if the connection fails (#68) 68 | * Connector widget allows editing connections (#61) 69 | 70 | 71 | ## 0.2.0 72 | 73 | * Updates "Deploy Notebook" endpoint 74 | * jupysql-plugin now requires `jupysql>=0.10` 75 | 76 | ## 0.1.9 77 | 78 | * Added support for `jupysql>=0.9` 79 | 80 | ## 0.1.8 81 | 82 | * Improved `Deploy notebook` workflow 83 | 84 | ## 0.1.7 85 | 86 | * No changes, fixing build 87 | 88 | ## 0.1.6 89 | 90 | * Adds `Deploy notebook` button 91 | 92 | ## 0.1.5 93 | 94 | * Adds connection helper widget 95 | 96 | ## 0.1.4 97 | 98 | * Comm listeners added for table_widget 99 | 100 | ## 0.1.3 101 | 102 | * Formatting via `Format SQL` button 103 | 104 | ## 0.1.2 105 | 106 | * SQL highlighting for `%%sql` cells 107 | 108 | ## 0.1.1 109 | 110 | * No changes (testing release process) 111 | 112 | ## 0.1.0 113 | 114 | * Basic SQL code completition -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # yarn 2 | .yarn 3 | 4 | # testing data 5 | .ini 6 | .nox 7 | coverage 8 | ui-tests/playwright-report 9 | ui-tests/test-results 10 | 11 | # extension artifacts 12 | jupysql_plugin/labextension 13 | tsconfig.tsbuildinfo 14 | yarn.lock 15 | 16 | # hatch version file 17 | jupysql_plugin/_version.py 18 | 19 | # node 20 | node_modules 21 | 22 | # documentation builds 23 | doc/_build 24 | 25 | # vscode stuff 26 | .vscode 27 | .virtual_documents 28 | 29 | # macOS files 30 | .DS_Store 31 | 32 | # Byte-compiled / optimized / DLL files 33 | __pycache__/ 34 | *.py[cod] 35 | *$py.class 36 | 37 | # C extensions 38 | *.so 39 | 40 | # Distribution / packaging 41 | .Python 42 | build/ 43 | develop-eggs/ 44 | dist/ 45 | downloads/ 46 | eggs/ 47 | .eggs/ 48 | lib/ 49 | lib64/ 50 | parts/ 51 | sdist/ 52 | var/ 53 | wheels/ 54 | pip-wheel-metadata/ 55 | share/python-wheels/ 56 | *.egg-info/ 57 | .installed.cfg 58 | *.egg 59 | MANIFEST 60 | 61 | # PyInstaller 62 | # Usually these files are written by a python script from a template 63 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 64 | *.manifest 65 | *.spec 66 | 67 | # Installer logs 68 | pip-log.txt 69 | pip-delete-this-directory.txt 70 | 71 | # Unit test / coverage reports 72 | htmlcov/ 73 | .tox/ 74 | .nox/ 75 | .coverage 76 | .coverage.* 77 | .cache 78 | nosetests.xml 79 | coverage.xml 80 | *.cover 81 | *.py,cover 82 | .hypothesis/ 83 | .pytest_cache/ 84 | 85 | # Translations 86 | *.mo 87 | *.pot 88 | 89 | # Django stuff: 90 | *.log 91 | local_settings.py 92 | db.sqlite3 93 | db.sqlite3-journal 94 | 95 | # Flask stuff: 96 | instance/ 97 | .webassets-cache 98 | 99 | # Scrapy stuff: 100 | .scrapy 101 | 102 | # Sphinx documentation 103 | docs/_build/ 104 | 105 | # PyBuilder 106 | target/ 107 | 108 | # Jupyter Notebook 109 | .ipynb_checkpoints 110 | 111 | # IPython 112 | profile_default/ 113 | ipython_config.py 114 | 115 | # pyenv 116 | .python-version 117 | 118 | # pipenv 119 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 120 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 121 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 122 | # install all needed dependencies. 123 | #Pipfile.lock 124 | 125 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 126 | __pypackages__/ 127 | 128 | # Celery stuff 129 | celerybeat-schedule 130 | celerybeat.pid 131 | 132 | # SageMath parsed files 133 | *.sage.py 134 | 135 | # Environments 136 | .env 137 | .venv 138 | env/ 139 | venv/ 140 | ENV/ 141 | env.bak/ 142 | venv.bak/ 143 | 144 | # Spyder project settings 145 | .spyderproject 146 | .spyproject 147 | 148 | # Rope project settings 149 | .ropeproject 150 | 151 | # mkdocs documentation 152 | /site 153 | 154 | # mypy 155 | .mypy_cache/ 156 | .dmypy.json 157 | dmypy.json 158 | 159 | # Pyre type checker 160 | .pyre/ 161 | -------------------------------------------------------------------------------- /ui-tests/tests/jupysql_plugin.test.ts: -------------------------------------------------------------------------------- 1 | // based on https://github.com/bqplot/bqplot/blob/master/ui-tests/tests/bqplot.test.ts 2 | import { IJupyterLabPageFixture, test } from '@jupyterlab/galata'; 3 | import { expect } from '@playwright/test'; 4 | import * as path from 'path'; 5 | const klaw = require('klaw-sync'); 6 | 7 | 8 | /** 9 | Returns .ipynb files that are not in hidden directories 10 | */ 11 | const filterNotebooks = item => { 12 | return ( 13 | item.path.includes(".ipynb") && 14 | !item.path.split(path.sep).some(component => component.startsWith('.')) 15 | ); 16 | }; 17 | 18 | const testCells = async (page: IJupyterLabPageFixture, notebook: string, theme: 'JupyterLab Light' | 'JupyterLab Dark', check_output: boolean = true) => { 19 | const contextPrefix = theme == 'JupyterLab Light' ? 'light' : 'dark'; 20 | page.theme.setTheme(theme); 21 | 22 | let results = []; 23 | console.log(`Testing notebook: ${notebook}`) 24 | 25 | await page.notebook.openByPath(notebook); 26 | await page.notebook.activate(notebook); 27 | 28 | let numCellImages = 0; 29 | 30 | const getCaptureImageName = (contextPrefix: string, notebook: string, id: number): string => { 31 | return `${contextPrefix}-${notebook}-cell-${id}.png`; 32 | }; 33 | 34 | await page.notebook.runCellByCell({ 35 | onAfterCellRun: async (cellIndex: number) => { 36 | let cell 37 | 38 | if (check_output) { 39 | cell = await page.notebook.getCellOutput(cellIndex); 40 | } else { 41 | cell = await page.notebook.getCellInput(cellIndex); 42 | } 43 | 44 | if (cell) { 45 | results.push(await cell.screenshot()); 46 | numCellImages++; 47 | } 48 | } 49 | }); 50 | 51 | await page.notebook.save(); 52 | 53 | for (let c = 0; c < numCellImages; ++c) { 54 | expect(results[c]).toMatchSnapshot(getCaptureImageName(contextPrefix, notebook, c)); 55 | } 56 | 57 | await page.notebook.close(true); 58 | } 59 | 60 | 61 | 62 | test.describe('jupysql-plugin ui-test', () => { 63 | test.beforeEach(async ({ page, tmpPath }) => { 64 | page.on("console", (message) => { 65 | console.log('CONSOLE MSG ---', message.text()); 66 | }); 67 | 68 | await page.contents.uploadDirectory( 69 | path.resolve(__dirname, '../notebooks'), 70 | tmpPath 71 | ); 72 | await page.filebrowser.openDirectory(tmpPath); 73 | }); 74 | 75 | const paths = klaw(path.resolve(__dirname, '../notebooks'), { filter: item => filterNotebooks(item), nodir: true }); 76 | const notebooks = paths.map(item => path.basename(item.path)); 77 | 78 | notebooks.forEach(notebook => { 79 | test(`jupysql-plugin ui-test for notebook: "${notebook}"`, async ({ 80 | page, 81 | }) => { 82 | if (notebook.startsWith('input')) { 83 | await testCells(page, notebook, 'JupyterLab Light', false); 84 | } else { 85 | await testCells(page, notebook, 'JupyterLab Light', true); 86 | } 87 | 88 | }); 89 | }); 90 | 91 | 92 | }); -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = [ 3 | "hatchling>=1.10.0", 4 | "jupyterlab>=4.0.0", 5 | "hatch-nodejs-version", 6 | ] 7 | build-backend = "hatchling.build" 8 | 9 | [project] 10 | name = "jupysql-plugin" 11 | readme = "README.md" 12 | license = { file = "LICENSE" } 13 | requires-python = ">=3.7" 14 | classifiers = [ 15 | "Framework :: Jupyter", 16 | "Framework :: Jupyter :: JupyterLab", 17 | "Framework :: Jupyter :: JupyterLab :: 4", 18 | "Framework :: Jupyter :: JupyterLab :: Extensions", 19 | "Framework :: Jupyter :: JupyterLab :: Extensions :: Prebuilt", 20 | "License :: OSI Approved :: BSD License", 21 | "Programming Language :: Python", 22 | "Programming Language :: Python :: 3", 23 | "Programming Language :: Python :: 3.7", 24 | "Programming Language :: Python :: 3.8", 25 | "Programming Language :: Python :: 3.9", 26 | "Programming Language :: Python :: 3.10", 27 | "Programming Language :: Python :: 3.11", 28 | ] 29 | dependencies = ["ploomber-core"] 30 | 31 | dynamic = ["version", "description", "authors", "urls", "keywords"] 32 | 33 | [tool.hatch.version] 34 | source = "nodejs" 35 | 36 | [tool.hatch.metadata.hooks.nodejs] 37 | fields = ["description", "authors", "urls"] 38 | 39 | [tool.hatch.build.targets.sdist] 40 | artifacts = ["jupysql_plugin/labextension"] 41 | exclude = [".github", "binder"] 42 | 43 | [tool.hatch.build.targets.wheel.shared-data] 44 | "jupysql_plugin/labextension" = "share/jupyter/labextensions/jupysql-plugin" 45 | "install.json" = "share/jupyter/labextensions/jupysql-plugin/install.json" 46 | "jupyter-config/jupyter_server_config.d" = "etc/jupyter/jupyter_server_config.d" 47 | 48 | [tool.hatch.build.hooks.version] 49 | path = "jupysql_plugin/_version.py" 50 | 51 | [tool.hatch.build.hooks.jupyter-builder] 52 | dependencies = ["hatch-jupyter-builder>=0.5"] 53 | build-function = "hatch_jupyter_builder.npm_builder" 54 | ensured-targets = [ 55 | "jupysql_plugin/labextension/static/style.js", 56 | "jupysql_plugin/labextension/package.json", 57 | ] 58 | skip-if-exists = ["jupysql_plugin/labextension/static/style.js"] 59 | 60 | [tool.hatch.build.hooks.jupyter-builder.build-kwargs] 61 | build_cmd = "build:prod" 62 | npm = ["jlpm"] 63 | 64 | [tool.hatch.build.hooks.jupyter-builder.editable-build-kwargs] 65 | build_cmd = "install:extension" 66 | npm = ["jlpm"] 67 | source_dir = "src" 68 | build_dir = "jupysql_plugin/labextension" 69 | 70 | [tool.jupyter-releaser.options] 71 | version_cmd = "hatch version" 72 | 73 | [tool.jupyter-releaser.hooks] 74 | before-build-npm = [ 75 | "python -m pip install jupyterlab~=4.0", 76 | "jlpm", 77 | "jlpm build:prod", 78 | ] 79 | before-build-python = ["jlpm clean:all"] 80 | 81 | [tool.check-wheel-contents] 82 | ignore = ["W002"] 83 | 84 | [tool.nbqa.addopts] 85 | flake8 = [ 86 | # notebooks allow non-top imports 87 | "--extend-ignore=E402", 88 | # jupysql notebooks might have "undefined name" errors 89 | # due to the << operator 90 | # W503, W504 ignore line break after/before 91 | # binary operator since they are conflicting 92 | "--ignore=F821, W503, W504", 93 | ] 94 | 95 | [tool.pkgmt] 96 | github = "ploomber/jupysql-plugin" 97 | env_name = "jupysql-plugin" 98 | package_name = "jupysql_plugin" 99 | -------------------------------------------------------------------------------- /src/formatter/formatter.ts: -------------------------------------------------------------------------------- 1 | // inspired by: https://github.com/ryantam626/jupyterlab_code_formatter 2 | import { Cell, CodeCell } from '@jupyterlab/cells'; 3 | import { INotebookTracker, Notebook } from '@jupyterlab/notebook'; 4 | import { Widget } from '@lumino/widgets'; 5 | import { showErrorMessage } from '@jupyterlab/apputils'; 6 | import { format } from 'sql-formatter'; 7 | 8 | export class JupyterlabNotebookCodeFormatter { 9 | protected working: boolean; 10 | protected notebookTracker: INotebookTracker; 11 | 12 | constructor( 13 | notebookTracker: INotebookTracker 14 | ) { 15 | this.notebookTracker = notebookTracker; 16 | } 17 | 18 | 19 | public async formatAllCodeCells( 20 | config: any, 21 | formatter?: string, 22 | notebook?: Notebook 23 | ) { 24 | return this.formatCells(false, config, formatter, notebook); 25 | } 26 | 27 | private getCodeCells(selectedOnly = true, notebook?: Notebook): CodeCell[] { 28 | if (!this.notebookTracker.currentWidget) { 29 | return []; 30 | } 31 | const codeCells: CodeCell[] = []; 32 | notebook = notebook || this.notebookTracker.currentWidget.content; 33 | notebook.widgets.forEach((cell: Cell) => { 34 | if (cell.model.type === 'code') { 35 | if (!selectedOnly || notebook.isSelectedOrActive(cell)) { 36 | codeCells.push(cell as CodeCell); 37 | } 38 | } 39 | }); 40 | return codeCells; 41 | } 42 | 43 | 44 | private async formatCells( 45 | selectedOnly: boolean, 46 | config: any, 47 | formatter?: string, 48 | notebook?: Notebook 49 | ) { 50 | 51 | if (this.working) { 52 | return; 53 | } 54 | try { 55 | this.working = true; 56 | const selectedCells = this.getCodeCells(selectedOnly, notebook); 57 | if (selectedCells.length === 0) { 58 | this.working = false; 59 | return; 60 | } 61 | 62 | for (let i = 0; i < selectedCells.length; ++i) { 63 | const cell = selectedCells[i]; 64 | const text = cell.model.sharedModel.source; 65 | 66 | if (text.startsWith("%%sql")) { 67 | const lines = text.split("\n"); 68 | const sqlCommand = lines.shift(); 69 | 70 | try { 71 | const query = format(lines.join("\n"), { language: 'sql', keywordCase: 'upper' }) 72 | cell.model.sharedModel.source = sqlCommand + "\n" + query; 73 | } catch (error) { 74 | } 75 | 76 | 77 | } 78 | } 79 | } catch (error: any) { 80 | await showErrorMessage('Jupysql plugin formatting', error); 81 | } 82 | this.working = false; 83 | } 84 | 85 | applicable(formatter: string, currentWidget: Widget) { 86 | const currentNotebookWidget = this.notebookTracker.currentWidget; 87 | // TODO: Handle showing just the correct formatter for the language later 88 | return currentNotebookWidget && currentWidget === currentNotebookWidget; 89 | } 90 | } -------------------------------------------------------------------------------- /src/formatter/index.ts: -------------------------------------------------------------------------------- 1 | import { INotebookTracker, NotebookPanel, INotebookModel } from '@jupyterlab/notebook'; 2 | import { 3 | JupyterFrontEnd, 4 | JupyterFrontEndPlugin, 5 | } from '@jupyterlab/application'; 6 | import { IDisposable, DisposableDelegate } from '@lumino/disposable'; 7 | import { ToolbarButton } from '@jupyterlab/apputils'; 8 | import { DocumentRegistry } from '@jupyterlab/docregistry'; 9 | 10 | import { JupyterlabNotebookCodeFormatter } from './formatter'; 11 | import { RegisterNotebookCommListener } from '../comm'; 12 | import { settingsChanged, JupySQLSettings } from '../settings'; 13 | 14 | /** 15 | * A notebook widget extension that adds a format button to the toolbar. 16 | */ 17 | export class FormattingExtension 18 | implements DocumentRegistry.IWidgetExtension 19 | { 20 | /** 21 | * Create a new extension for the notebook panel widget. 22 | * 23 | * @param panel Notebook panel 24 | * @param context Notebook context 25 | * @returns Disposable on the added button 26 | */ 27 | 28 | private notebookCodeFormatter: JupyterlabNotebookCodeFormatter; 29 | private formatSQLButton: ToolbarButton; 30 | private panel: NotebookPanel; 31 | private extensionSettings: boolean; 32 | 33 | 34 | constructor( 35 | tracker: INotebookTracker 36 | ) { 37 | this.notebookCodeFormatter = new JupyterlabNotebookCodeFormatter( 38 | tracker 39 | ); 40 | settingsChanged.connect(this._onSettingsChanged); 41 | } 42 | 43 | private _onSettingsChanged = (sender: any, settings: JupySQLSettings) => { 44 | this.extensionSettings = settings.showFormatSQL; 45 | if (!settings.showFormatSQL) { 46 | this.formatSQLButton.parent = null; 47 | } else { 48 | this.panel.toolbar.insertItem(10, 'formatSQL', this.formatSQLButton); 49 | } 50 | } 51 | 52 | createNew( 53 | panel: NotebookPanel, 54 | context: DocumentRegistry.IContext 55 | ): IDisposable { 56 | const clearOutput = () => { 57 | this.notebookCodeFormatter.formatAllCodeCells(undefined, undefined, panel.content) 58 | }; 59 | 60 | this.panel = panel; 61 | 62 | this.formatSQLButton = new ToolbarButton({ 63 | className: 'format-sql-button', 64 | label: 'Format SQL', 65 | onClick: clearOutput, 66 | tooltip: 'Format all %%sql cells', 67 | }); 68 | this.formatSQLButton.node.setAttribute("data-testid", "format-btn"); 69 | 70 | panel.toolbar.insertItem(10, 'formatSQL', this.formatSQLButton); 71 | if (!this.extensionSettings) { 72 | this.formatSQLButton.parent = null; 73 | } else { 74 | this.panel.toolbar.insertItem(10, 'formatSQL', this.formatSQLButton); 75 | } 76 | 77 | return new DisposableDelegate(() => { 78 | this.formatSQLButton.dispose(); 79 | }); 80 | } 81 | } 82 | 83 | /** 84 | * Activate the extension. 85 | * 86 | * @param app Main application object 87 | */ 88 | const plugin_formatting: JupyterFrontEndPlugin = { 89 | activate: ( 90 | app: JupyterFrontEnd, 91 | tracker: INotebookTracker, 92 | ) => { 93 | 94 | app.docRegistry.addWidgetExtension('Notebook', new FormattingExtension( 95 | tracker, 96 | )); 97 | app.docRegistry.addWidgetExtension('Notebook', new RegisterNotebookCommListener()); 98 | 99 | }, 100 | autoStart: true, 101 | id: "formatting", 102 | requires: [ 103 | INotebookTracker, 104 | ] 105 | }; 106 | 107 | 108 | export { plugin_formatting } -------------------------------------------------------------------------------- /style/connector.css: -------------------------------------------------------------------------------- 1 | :root { 2 | --danger: #f53649; 3 | --white: #ffffff; 4 | --margin: 10px; 5 | --primary: #206eef; 6 | } 7 | 8 | .connector-widget .connection-button-container .connection-button-actions { 9 | display: inline-flex; 10 | width: 100%; 11 | } 12 | 13 | .connector-widget #connectionsButtonsContainer { 14 | display: grid; 15 | } 16 | 17 | .connector-widget .connection-button-container .delete-connection-button { 18 | margin-left: 10px; 19 | background-repeat: no-repeat; 20 | background-size: 24px; 21 | background-position: center; 22 | background-image: url('icons/delete_outline_black.svg'); 23 | background-color: transparent; 24 | border: none; 25 | width: 24px; 26 | } 27 | 28 | .connector-widget .connection-button-container .edit-connection-button { 29 | margin-left: 10px; 30 | background-repeat: no-repeat; 31 | background-size: 24px; 32 | background-position: center; 33 | background-image: url('icons/edit_outline_black.svg'); 34 | background-color: transparent; 35 | border: none; 36 | width: 26px; 37 | } 38 | 39 | .create-new-connection { 40 | display: inline-flex; 41 | color: var(--primary) 42 | } 43 | 44 | .create-new-connection:hover { 45 | cursor: pointer; 46 | } 47 | 48 | .create-new-connection .icon { 49 | background-image: url('icons/add_primary.svg'); 50 | min-width: 30px; 51 | height: 30px; 52 | background-repeat: no-repeat; 53 | background-size: 15px; 54 | background-position: center; 55 | background-color: transparent; 56 | margin: 0; 57 | } 58 | 59 | .create-new-connection div { 60 | margin: auto var(--margin); 61 | margin-top: 2px; 62 | } 63 | 64 | .create-new-connection div:nth-child(2) { 65 | margin-left: 2px; 66 | } 67 | 68 | .connector-widget .connection-button-container { 69 | margin: var(--margin) 0; 70 | } 71 | 72 | .connector-widget hr.divider { 73 | margin: 0; 74 | margin-top: 20px; 75 | } 76 | 77 | .connector-widget button { 78 | width: fit-content; 79 | border: none; 80 | padding: 5px 10px; 81 | border: 1px solid transparent; 82 | border-radius: 5px; 83 | } 84 | 85 | .connector-widget button.secondary { 86 | background-color: transparent; 87 | border: 1px solid var(--primary); 88 | color: var(--primary); 89 | } 90 | 91 | .connector-widget button.primary { 92 | background-color: var(--primary); 93 | color: #fff; 94 | border: 1px solid var(--primary); 95 | } 96 | 97 | .connector-widget .connection-button-actions .connection-name { 98 | margin: auto 30px; 99 | margin-left: 0; 100 | width: 100%; 101 | font-weight: 500; 102 | } 103 | 104 | button.danger { 105 | background-color: var(--danger); 106 | color: var(--white); 107 | margin-left: var(--margin); 108 | } 109 | 110 | form#connectionForm .field-container { 111 | display: inline-flex; 112 | width: 100%; 113 | margin: var(--margin) 0; 114 | } 115 | 116 | form#connectionForm label { 117 | width: 50%; 118 | margin: auto 0; 119 | } 120 | 121 | form#connectionForm .field { 122 | width: -webkit-fill-available; 123 | margin-left: var(--margin); 124 | padding: 5px 10px; 125 | } 126 | 127 | form#connectionForm input:not(input[type='submit']) { 128 | /* margin: 10px; 129 | padding: 5px 10px; */ 130 | } 131 | 132 | #selectConnection { 133 | width: 100%; 134 | padding: 6px 10px; 135 | margin-bottom: var(--margin); 136 | } 137 | 138 | .warning-message { 139 | margin-bottom: var(--margin); 140 | } 141 | 142 | .block { 143 | margin: 30px 0px; 144 | /* border: 1px solid #000; */ 145 | width: 600px; 146 | } 147 | 148 | form#connectionForm .buttons-container { 149 | text-align: right; 150 | margin: var(--margin) 0; 151 | } 152 | 153 | form#connectionForm .buttons-container button:first-child { 154 | margin-right: var(--margin); 155 | } -------------------------------------------------------------------------------- /ui-tests/tests/completer.test.ts: -------------------------------------------------------------------------------- 1 | import { IJupyterLabPageFixture, test } from '@jupyterlab/galata'; 2 | import { expect } from '@playwright/test'; 3 | 4 | async function createNotebook(page: IJupyterLabPageFixture) { 5 | // Create a new Notebook 6 | await page.menu.clickMenuItem('File>New>Notebook'); 7 | await page.click('button:has-text("Select")'); 8 | 9 | // Wait until kernel is ready 10 | await page.waitForSelector( 11 | '#jp-main-statusbar >> text=Python 3 (ipykernel) | Idle' 12 | ); 13 | } 14 | 15 | const samples = { 16 | 'upper case': { 17 | input: '%sql SEL', 18 | expected: ['SELECT', 'SELECT DISTINCT', 'SELECT INTO', 'SELECT TOP'], 19 | unexpected: ['INSERT'] 20 | }, 21 | 'lower case': { 22 | input: '%sql sel', 23 | expected: ['SELECT', 'SELECT DISTINCT', 'SELECT INTO', 'SELECT TOP'], 24 | unexpected: ['INSERT'] 25 | }, 26 | 'in-word': { 27 | input: '%sql se', 28 | expected: ['SELECT', 'SELECT DISTINCT', 'SELECT INTO', 'SELECT TOP', 'INSERT INTO'], 29 | unexpected: [] 30 | } 31 | }; 32 | 33 | for (let sample_name in samples) { 34 | const sample = samples[sample_name]; 35 | test(`${sample_name} completion`, async ({ page }) => { 36 | await createNotebook(page); 37 | 38 | // type 'SEL' in the first cell 39 | await page.notebook.enterCellEditingMode(0); 40 | await page.keyboard.type(sample.input); 41 | 42 | await page.keyboard.press('Tab'); 43 | const suggestions = page.locator('.jp-Completer'); 44 | await expect(suggestions).toBeVisible(); 45 | 46 | for (let suggestion of sample.expected) { 47 | await expect(suggestions.locator(`code:text-is("${suggestion}")`)).toHaveCount(1); 48 | } 49 | for (let suggestion of sample.unexpected) { 50 | await expect(suggestions.locator(`code:text-is("${suggestion}")`)).toHaveCount(0); 51 | } 52 | }) 53 | } 54 | 55 | test('test complete updates cell', async ({ page }) => { 56 | await createNotebook(page); 57 | 58 | await page.notebook.enterCellEditingMode(0); 59 | await page.keyboard.type('%sql SEL'); 60 | 61 | await page.keyboard.press('Tab'); 62 | 63 | // delay to ensure the autocompletion options are displayed 64 | await page.waitForSelector(".jp-Completer-list"); 65 | await page.keyboard.press('Enter'); 66 | 67 | const cell_updated = await page.notebook.getCell(0); 68 | cell_updated?.innerText().then((text) => { 69 | expect(text).toContain('SELECT'); 70 | }); 71 | 72 | }); 73 | 74 | const contexts = { 75 | 'valid cell magic': { 76 | input: '%%sql\nSEL', 77 | completion: true 78 | }, 79 | 'invalid cell magic': { 80 | input: ' %%sql\nSEL', 81 | completion: false 82 | }, 83 | 'line magic': { 84 | input: '%sql SEL', 85 | completion: true 86 | }, 87 | 'line magic with python': { 88 | input: 'result = %sql SEL', 89 | completion: true 90 | }, 91 | 'line magic new line': { 92 | input: '%sql SEL\nSEL', 93 | completion: false 94 | }, 95 | 'no magic': { 96 | input: 'SEL', 97 | completion: false 98 | } 99 | } 100 | 101 | for (const [ name, { input, completion} ] of Object.entries(contexts)) 102 | test(`test ${name} ${completion ? 'does' : 'does not'} complete`, async ({ page }) => { 103 | await createNotebook(page); 104 | 105 | await page.notebook.enterCellEditingMode(0); 106 | await page.keyboard.type(input); 107 | 108 | await page.keyboard.press('Tab'); 109 | const suggestions = page.locator('.jp-Completer'); 110 | if (completion) 111 | await expect(suggestions).toBeVisible(); 112 | else 113 | await expect(suggestions).not.toBeVisible(); 114 | }); 115 | 116 | test('test no completion before line magic', async ({ page }) => { 117 | await createNotebook(page); 118 | 119 | await page.notebook.enterCellEditingMode(0); 120 | await page.keyboard.type('SEL = %sql SEL'); 121 | for (let i=0; i<11; i++) 122 | await page.keyboard.press('ArrowLeft'); 123 | 124 | await page.keyboard.press('Tab'); 125 | const suggestions = page.locator('.jp-Completer'); 126 | await expect(suggestions).not.toBeVisible(); 127 | }); 128 | -------------------------------------------------------------------------------- /tests/test_connector_widget.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | 3 | import pytest 4 | from sql.connection import ConnectionManager 5 | from IPython.core.error import UsageError 6 | 7 | from jupysql_plugin.widgets.connector_widget import ConnectorWidget 8 | 9 | 10 | class ConnectorWidgetTesting(ConnectorWidget): 11 | """A class to test ConnectorWidget methods""" 12 | 13 | def send_error_message_to_frontend(self, *, method, error): 14 | """The original implementation sends a message to the frontend, here, 15 | we raise an error instead 16 | """ 17 | raise error 18 | 19 | 20 | def test_method_missing(): 21 | with pytest.raises(ValueError) as excinfo: 22 | ConnectorWidgetTesting()._handle_message(None, {}, None) 23 | 24 | assert "Method is not specified" == str(excinfo.value) 25 | 26 | 27 | def test_method_unknown(): 28 | with pytest.raises(ValueError) as excinfo: 29 | ConnectorWidgetTesting()._handle_message(None, {"method": "not-a-method"}, None) 30 | 31 | assert "Method not-a-method is not supported" == str(excinfo.value) 32 | 33 | 34 | def test_method_submit_new_connection(tmp_empty): 35 | ConnectorWidgetTesting()._handle_message( 36 | None, 37 | { 38 | "method": "submit_new_connection", 39 | "data": {"connectionName": "duck", "driver": "duckdb"}, 40 | }, 41 | None, 42 | ) 43 | 44 | assert set(ConnectionManager.connections) == {"duck"} 45 | 46 | 47 | @pytest.mark.parametrize( 48 | "data, expected", 49 | [ 50 | ( 51 | { 52 | "connectionName": "duck", 53 | "driver": "duckdb", 54 | "database": "duck.db", 55 | }, 56 | """\ 57 | [duck] 58 | database = duck.db 59 | drivername = duckdb 60 | 61 | """, 62 | ), 63 | ], 64 | ) 65 | def test_method_submit_new_connection_path(tmp_empty, data, expected): 66 | ConnectorWidgetTesting()._handle_message( 67 | None, 68 | { 69 | "method": "submit_new_connection", 70 | "data": data, 71 | }, 72 | None, 73 | ) 74 | 75 | config = Path("jupysql-plugin.ini").read_text() 76 | 77 | assert config == expected 78 | assert set(ConnectionManager.connections) == {"duck"} 79 | 80 | 81 | def test_submit_new_connection_doesnt_modify_ini_file_if_fails_to_connect(tmp_empty): 82 | widget = ConnectorWidgetTesting() 83 | 84 | with pytest.raises(UsageError): 85 | widget._handle_message( 86 | None, 87 | { 88 | "method": "submit_new_connection", 89 | "data": { 90 | "connectionName": "pg", 91 | "driver": "postgresql", 92 | "database": "mypgdb", 93 | }, 94 | }, 95 | None, 96 | ) 97 | 98 | assert not Path("jupysql-plugin.ini").exists() 99 | 100 | 101 | def test_method_connect(tmp_empty): 102 | Path("jupysql-plugin.ini").write_text( 103 | """ 104 | [duck] 105 | drivername = duckdb 106 | """ 107 | ) 108 | 109 | ConnectorWidgetTesting()._handle_message( 110 | None, 111 | { 112 | "method": "connect", 113 | "data": {"name": "duck"}, 114 | }, 115 | None, 116 | ) 117 | 118 | assert set(ConnectionManager.connections) == {"duck"} 119 | 120 | 121 | def test_loads_stored_connections_upon_init(tmp_empty): 122 | Path("jupysql-plugin.ini").write_text( 123 | """ 124 | [myduckdbconn] 125 | drivername = duckdb 126 | """ 127 | ) 128 | 129 | assert ConnectorWidget().stored_connections == [ 130 | {"driver": "duckdb", "name": "myduckdbconn"} 131 | ] 132 | 133 | Path("jupysql-plugin.ini").write_text( 134 | """ 135 | [myduckdbconn] 136 | drivername = duckdb 137 | 138 | [sqlite] 139 | drivername = sqlite 140 | """ 141 | ) 142 | 143 | assert ConnectorWidget().stored_connections == [ 144 | {"driver": "duckdb", "name": "myduckdbconn"}, 145 | {"driver": "sqlite", "name": "sqlite"}, 146 | ] 147 | -------------------------------------------------------------------------------- /src/completer/customconnector.ts: -------------------------------------------------------------------------------- 1 | // Copyright (c) Jupyter Development Team. 2 | // Distributed under the terms of the Modified BSD License. 3 | 4 | // Modified from jupyterlab/packages/completer/src/contextconnector.ts 5 | 6 | import { CodeEditor } from '@jupyterlab/codeeditor'; 7 | import { 8 | CompletionHandler, 9 | ICompletionContext, 10 | ICompletionProvider 11 | } from '@jupyterlab/completer'; 12 | 13 | import { keywords } from './keywords.json'; 14 | 15 | const CELL_MAGIC = '%%sql'; 16 | const LINE_MAGIC = '%sql'; 17 | 18 | /** 19 | * A custom connector for completion handlers. 20 | */ 21 | export class SQLCompleterProvider implements ICompletionProvider { 22 | constructor() { 23 | // Build the completion item from the JSON file. 24 | this._items = keywords.map(item => { 25 | return { 26 | label: item.value, 27 | type: 'keyword' 28 | } 29 | }) 30 | } 31 | 32 | /** 33 | * The context completion provider is applicable on all cases. 34 | * @param context - additional information about context of completion request 35 | */ 36 | async isApplicable(context: ICompletionContext): Promise { 37 | const editor = context.editor; 38 | if (editor === undefined) 39 | return false; 40 | 41 | // If this is a SQL magic cell, then we can complete 42 | const firstLine = editor.getLine(0); 43 | if (firstLine.slice(0, CELL_MAGIC.length) === CELL_MAGIC) 44 | return true; 45 | 46 | // Otherwise, if we're to the right of a line magic, we can complete 47 | const currPos = editor.getCursorPosition(); 48 | const lineMagicPos = editor.getLine(currPos.line).indexOf(LINE_MAGIC); 49 | return (lineMagicPos > -1 && lineMagicPos + LINE_MAGIC.length < currPos.column); 50 | } 51 | 52 | /** 53 | * Fetch completion requests. 54 | * 55 | * @param request - The completion request text and details. 56 | * @returns Completion reply 57 | */ 58 | fetch( 59 | request: CompletionHandler.IRequest, 60 | context: ICompletionContext 61 | ): Promise { 62 | const editor = context.editor; 63 | if (!editor) { 64 | return Promise.reject('No editor'); 65 | } 66 | return new Promise(resolve => { 67 | resolve(Private.completionHint(editor!, this._items)); 68 | }); 69 | } 70 | 71 | readonly identifier = 'CompletionProvider:custom'; 72 | readonly renderer: any = null; 73 | private _items: CompletionHandler.ICompletionItem[]; 74 | } 75 | 76 | /** 77 | * A namespace for Private functionality. 78 | */ 79 | namespace Private { 80 | /** 81 | * Get a list of completion hints. 82 | * 83 | * @param editor Editor 84 | * @returns Completion reply 85 | */ 86 | export function completionHint( 87 | editor: CodeEditor.IEditor, 88 | baseItems: CompletionHandler.ICompletionItem[] 89 | ): CompletionHandler.ICompletionItemsReply { 90 | // Find the token at the cursor 91 | const token = editor.getTokenAtCursor(); 92 | 93 | // Find all the items containing the token value. 94 | let items = baseItems.filter( 95 | item => item.label.toLowerCase().includes(token.value.toLowerCase()) 96 | ); 97 | 98 | // Sort the items. 99 | items = items.sort((a, b) => { 100 | return sortItems( 101 | token.value.toLowerCase(), 102 | a.label.toLowerCase(), 103 | b.label.toLowerCase() 104 | ); 105 | }); 106 | 107 | return { 108 | start: token.offset, 109 | end: token.offset + token.value.length, 110 | items: items 111 | }; 112 | } 113 | 114 | /** 115 | * Compare function to sort items. 116 | * The comparison is based on the position of the token in the label. If the positions 117 | * are the same, it is sorted alphabetically, starting at the token. 118 | * 119 | * @param token - the value of the token in lower case. 120 | * @param a - the label of the first item in lower case. 121 | * @param b - the label of the second item in lower case. 122 | */ 123 | function sortItems( 124 | token: string, 125 | a: string, 126 | b: string 127 | ): number { 128 | const ind1 = a.indexOf(token); 129 | const ind2 = b.indexOf(token); 130 | if (ind1 < ind2) { 131 | return -1; 132 | } else if (ind1 > ind2) { 133 | return 1; 134 | } else { 135 | const end1 = a.slice(ind1); 136 | const end2 = b.slice(ind1); 137 | return end1 <= end2 ? -1 : 1; 138 | } 139 | } 140 | } 141 | -------------------------------------------------------------------------------- /ui-tests/README.md: -------------------------------------------------------------------------------- 1 | # Integration Testing 2 | 3 | This folder contains the integration tests of the extension. 4 | 5 | They are defined using [Playwright](https://playwright.dev/docs/intro) test runner 6 | and [Galata](https://github.com/jupyterlab/jupyterlab/tree/master/galata) helper. 7 | 8 | The Playwright configuration is defined in [playwright.config.js](./playwright.config.js). 9 | 10 | The JupyterLab server configuration to use for the integration test is defined 11 | in [jupyter_server_test_config.py](./jupyter_server_test_config.py). 12 | 13 | The default configuration will produce video for failing tests and an HTML report. 14 | 15 | ## Run the tests 16 | 17 | > All commands are assumed to be executed from the root directory 18 | 19 | To run the tests, you need to: 20 | 21 | 1. Compile the extension: 22 | 23 | ```sh 24 | # disable the floating cell toolbar since our tests assume it's hidden 25 | jupyter labextension disable @jupyterlab/cell-toolbar-extension 26 | 27 | jlpm install 28 | jlpm build:prod 29 | ``` 30 | 31 | > Check the extension is installed in JupyterLab. 32 | 33 | 2. Install test dependencies (needed only once): 34 | 35 | ```sh 36 | cd ./ui-tests 37 | jlpm install 38 | jlpm playwright install 39 | cd .. 40 | ``` 41 | 42 | 3. Execute the [Playwright](https://playwright.dev/docs/intro) tests: 43 | 44 | ```sh 45 | cd ./ui-tests 46 | jlpm test 47 | 48 | # to add a timeout (in ms) and execute specific tests 49 | jlpm playwright test --timeout 5000 --grep 'somename' 50 | ``` 51 | 52 | Test results will be shown in the terminal. In case of any test failures, the test report 53 | will be opened in your browser at the end of the tests execution; see 54 | [Playwright documentation](https://playwright.dev/docs/test-reporters#html-reporter) 55 | for configuring that behavior. 56 | 57 | ### Noes for writing UI tests 58 | 59 | - If the UI tests fail, check the recording. If the video shows that no notebook is opened, it might be that the tests are trying to open a notebook that doesn't exist, this might happen with notebooks in hidden folders 60 | - If a cell doesn't produce an output and a screenshot is taken, an error is raised 61 | 62 | 63 | ## Update the tests snapshots 64 | 65 | > All commands are assumed to be executed from the root directory 66 | 67 | If you are comparing snapshots to validate your tests, you may need to update 68 | the reference snapshots stored in the repository. To do that, you need to: 69 | 70 | 1. Compile the extension: 71 | 72 | ```sh 73 | jlpm install 74 | jlpm build:prod 75 | ``` 76 | 77 | > Check the extension is installed in JupyterLab. 78 | 79 | 2. Install test dependencies (needed only once): 80 | 81 | ```sh 82 | cd ./ui-tests 83 | jlpm install 84 | jlpm playwright install 85 | cd .. 86 | ``` 87 | 88 | 3. Execute the [Playwright](https://playwright.dev/docs/intro) command: 89 | 90 | ```sh 91 | cd ./ui-tests 92 | jlpm playwright test --update-snapshots 93 | ``` 94 | 95 | > Some discrepancy may occurs between the snapshots generated on your computer and 96 | > the one generated on the CI. To ease updating the snapshots on a PR, you can 97 | > type `please update playwright snapshots` to trigger the update by a bot on the CI. 98 | > Once the bot has computed new snapshots, it will commit them to the PR branch. 99 | 100 | ## Create tests 101 | 102 | > All commands are assumed to be executed from the root directory 103 | 104 | To create tests, the easiest way is to use the code generator tool of playwright: 105 | 106 | 1. Compile the extension: 107 | 108 | ```sh 109 | jlpm install 110 | jlpm build:prod 111 | ``` 112 | 113 | > Check the extension is installed in JupyterLab. 114 | 115 | 2. Install test dependencies (needed only once): 116 | 117 | ```sh 118 | cd ./ui-tests 119 | jlpm install 120 | jlpm playwright install 121 | cd .. 122 | ``` 123 | 124 | 3. Execute the [Playwright code generator](https://playwright.dev/docs/codegen): 125 | 126 | ```sh 127 | # NOTE: if you don't have JupyterLab running, start it 128 | cd ./ui-tests 129 | jlpm start 130 | ``` 131 | 132 | ```sh 133 | cd ./ui-tests 134 | jlpm playwright codegen localhost:8888 135 | ``` 136 | 137 | The galata framework exposes several convenient methods to mock user actions, most 138 | of what you need is in the `notebook` object, you can see the [methods here.](https://github.com/jupyterlab/jupyterlab/blob/main/galata/src/helpers/notebook.ts). 139 | 140 | To get a sense of how a test looks like, check out [JupyterLab's tests.](https://github.com/jupyterlab/jupyterlab/tree/7a30f77d9c344a9a750e279bd65ac3d420af01d9/galata/test/jupyterlab) 141 | 142 | ## Debug tests 143 | 144 | > All commands are assumed to be executed from the root directory 145 | 146 | To debug tests, a good way is to use the inspector tool of playwright: 147 | 148 | 1. Compile the extension: 149 | 150 | ```sh 151 | jlpm install 152 | jlpm build:prod 153 | ``` 154 | 155 | > Check the extension is installed in JupyterLab. 156 | 157 | 2. Install test dependencies (needed only once): 158 | 159 | ```sh 160 | cd ./ui-tests 161 | jlpm install 162 | jlpm playwright install 163 | cd .. 164 | ``` 165 | 166 | 3. Execute the Playwright tests in [debug mode](https://playwright.dev/docs/debug): 167 | 168 | ```sh 169 | cd ./ui-tests 170 | PWDEBUG=1 jlpm playwright test 171 | ``` 172 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "jupysql-plugin", 3 | "version": "0.4.5", 4 | "description": "Jupyterlab extension for JupySQL", 5 | "private": true, 6 | "keywords": [ 7 | "jupyter", 8 | "jupyterlab", 9 | "jupyterlab-extension" 10 | ], 11 | "homepage": "https://github.com/ploomber/jupysql-plugin.git", 12 | "bugs": { 13 | "url": "https://github.com/ploomber/jupysql-plugin.git/issues" 14 | }, 15 | "license": "BSD-3-Clause", 16 | "author": { 17 | "name": "Ploomber", 18 | "email": "contact@ploomber.io" 19 | }, 20 | "files": [ 21 | "lib/**/*.{d.ts,eot,gif,html,jpg,js,js.map,json,png,svg,woff2,ttf}", 22 | "style/**/*.{css,js,eot,gif,html,jpg,json,png,svg,woff2,ttf}", 23 | "settings-schema/**/*.json" 24 | ], 25 | "main": "lib/index.js", 26 | "types": "lib/index.d.ts", 27 | "style": "style/index.css", 28 | "repository": { 29 | "type": "git", 30 | "url": "https://github.com/ploomber/jupysql-plugin.git.git" 31 | }, 32 | "workspaces": { 33 | "packages": [ 34 | "jupysql_plugin", 35 | "ui-tests" 36 | ] 37 | }, 38 | "scripts": { 39 | "build": "jlpm build:lib && jlpm build:labextension:dev", 40 | "build:prod": "jlpm clean && jlpm build:lib:prod && jlpm build:labextension", 41 | "build:labextension": "jupyter labextension build .", 42 | "build:labextension:dev": "jupyter labextension build --development True .", 43 | "build:lib": "tsc --sourceMap", 44 | "build:lib:prod": "tsc", 45 | "clean": "jlpm clean:lib", 46 | "clean:lib": "rimraf lib tsconfig.tsbuildinfo", 47 | "clean:lintcache": "rimraf .eslintcache .stylelintcache", 48 | "clean:labextension": "rimraf jupysql_plugin/labextension jupysql_plugin/_version.py", 49 | "clean:all": "jlpm clean:lib && jlpm clean:labextension && jlpm clean:lintcache", 50 | "eslint": "jlpm eslint:check --fix", 51 | "eslint:check": "eslint src/ --cache --ext .ts,.tsx", 52 | "install:extension": "jlpm build", 53 | "lint": "jlpm stylelint && jlpm prettier && jlpm eslint", 54 | "lint:check": "jlpm stylelint:check && jlpm prettier:check && jlpm eslint:check", 55 | "prettier": "jlpm prettier:base --write --list-different", 56 | "prettier:base": "prettier \"**/*{.ts,.tsx,.js,.jsx,.css,.json,.md}\"", 57 | "prettier:check": "jlpm prettier:base --check", 58 | "stylelint": "jlpm stylelint:check --fix", 59 | "stylelint:check": "stylelint --cache \"style/**/*.css\"", 60 | "test": "jest --coverage", 61 | "watch": "run-p watch:src watch:labextension", 62 | "watch:src": "tsc -w", 63 | "watch:labextension": "jupyter labextension watch ." 64 | }, 65 | "@comment": { 66 | "dependencies": { 67 | "@lumino/widgets": "An official library to implement the frontend of the widgets: https://github.com/jupyterlab/lumino" 68 | } 69 | }, 70 | "dependencies": { 71 | "@emotion/react": "^11.11.0", 72 | "@emotion/styled": "^11.11.0", 73 | "@jupyter-widgets/base": "^6.0.4", 74 | "@jupyterlab/application": "^4.0.5", 75 | "@jupyterlab/apputils": "^4.1.5", 76 | "@jupyterlab/cells": "^4.0.5", 77 | "@jupyterlab/codeeditor": "^4.0.5", 78 | "@jupyterlab/codemirror": "^4.0.5", 79 | "@jupyterlab/completer": "^4.0.5", 80 | "@jupyterlab/notebook": "^4.0.5", 81 | "@jupyterlab/settingregistry": "^4.0.5", 82 | "@jupyterlab/statedb": "^4.0.5", 83 | "@lumino/widgets": "^2.3.0", 84 | "@mui/icons-material": "^5.11.16", 85 | "@mui/material": "^5.13.4", 86 | "@types/codemirror": "^5.60.7", 87 | "@types/underscore": "^1.11.4", 88 | "clean": "^4.0.2", 89 | "react": "^17.0.2", 90 | "sql-formatter": "^12.2.0", 91 | "underscore": "^1.13.6" 92 | }, 93 | "devDependencies": { 94 | "@babel/core": "^7.0.0", 95 | "@babel/preset-env": "^7.0.0", 96 | "@jupyterlab/builder": "^4.0.5", 97 | "@jupyterlab/testutils": "^4.0.5", 98 | "@testing-library/jest-dom": "^5.16.5", 99 | "@testing-library/react": "^12.1.2", 100 | "@types/jest": "^29.0.0", 101 | "@types/jest-when": "^3.5.2", 102 | "@typescript-eslint/eslint-plugin": "^4.8.1", 103 | "@typescript-eslint/parser": "^4.8.1", 104 | "eslint": "^7.14.0", 105 | "eslint-config-prettier": "^6.15.0", 106 | "eslint-plugin-prettier": "^3.1.4", 107 | "jest": "^29.0.0", 108 | "jest-when": "^3.5.2", 109 | "npm-run-all": "^4.1.5", 110 | "prettier": "^2.1.1", 111 | "react-dom": "^17.0.2", 112 | "rimraf": "^3.0.2", 113 | "stylelint": "^14.3.0", 114 | "stylelint-config-prettier": "^9.0.4", 115 | "stylelint-config-recommended": "^6.0.0", 116 | "stylelint-config-standard": "~24.0.0", 117 | "stylelint-prettier": "^2.0.0", 118 | "ts-jest": "^29.0.0", 119 | "typescript": "^4.1.3" 120 | }, 121 | "sideEffects": [ 122 | "style/*.css", 123 | "style/index.js" 124 | ], 125 | "styleModule": "style/index.js", 126 | "publishConfig": { 127 | "access": "public" 128 | }, 129 | "jupyterlab": { 130 | "extension": true, 131 | "schemaDir": "settings-schema", 132 | "outputDir": "jupysql_plugin/labextension", 133 | "sharedPackages": {} 134 | } 135 | } 136 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # jupysql-plugin 2 | 3 | > [!TIP] 4 | > Deploy AI apps for free on [Ploomber Cloud!](https://ploomber.io/?utm_medium=github&utm_source=jupysql-plugin) 5 | 6 | > [!NOTE] 7 | > Code in the `main` branch is compatible with JupyterLab 4 (`0.4.x` or higher releases), 8 | > the `jupyterlab3` branch contains code compatible with JupyterLab 3 (`0.3.x` releases). 9 | > We'll keep backporting some features and making `0.3.x` releases for some time, but 10 | > we highly recommend upgrading to JupyterLab 4 for a better experience. 11 | 12 | 13 | ## Install 14 | 15 | ```bash 16 | pip install jupysql-plugin 17 | ``` 18 | 19 | ## Contributing 20 | 21 | ### Development install 22 | 23 | ```sh 24 | conda create --name jupysql-plugin python=3.11 --channel conda-forge --yes 25 | conda activate jupysql-plugin 26 | conda install nodejs=20 --channel conda-forge --yes 27 | pip install -r requirements.txt 28 | pip install -r requirements.dev.txt 29 | 30 | jlpm install 31 | ``` 32 | Note: `pkgmt format` can be used to format and lint Python files before committing code. 33 | To format JavaScript and TypeScript files, use `yarn run eslint`. To lint without formatting, 34 | use `yarn run eslint:check` 35 | 36 | ```bash 37 | # Note: this command will take some time the first time as it has to compile the 38 | # frontend code. If the command fails, see the "troubleshooting setup" section below 39 | pip install -e "." 40 | 41 | # upon installation, both the frontend and backend extensions must be activated 42 | # automatically, you can verify it by ensuring jupysql-plugin appears here: 43 | jupyter labextension list # frontend extension 44 | jupyter server extension list # backend extension 45 | 46 | # if they don't appear, you can activate them manually, but this means that 47 | # the setup is incorrect! see pyproject.toml, under 48 | # tool.hatch.build.targets.wheel.shared-data, and fix any issues 49 | 50 | # activate manually 51 | jupyter server extension enable jupysql_plugin 52 | jupyter labextension enable jupysql_plugin 53 | 54 | 55 | jupyter labextension develop . --overwrite 56 | 57 | # NOTE: the two previous commands will fail if there are missing dependencies 58 | 59 | # rebuild extension Typescript 60 | # important: we had to set skipLibCheck: true 61 | # https://discourse.jupyter.org/t/struggling-with-extensions-and-dependencies-versions/19550 62 | jlpm build 63 | ``` 64 | 65 | To watch for changes and reload: 66 | 67 | ```bash 68 | # in one terminal 69 | jlpm watch 70 | 71 | # another terminal 72 | jupyter lab 73 | ``` 74 | 75 | Refresh JupyterLab to load the change in your browser. 76 | 77 | By default, the `jlpm build` command generates the source maps for this extension to make it easier to debug using the browser dev tools. To also generate source maps for the JupyterLab core extensions, you can run the following command: 78 | 79 | ```bash 80 | jupyter lab build --minimize=False 81 | ``` 82 | 83 | ### Troubleshooting setup 84 | 85 | If you encounter errors when installing the package for development, you can try the 86 | following to configure an environment from scratch: 87 | 88 | ```sh 89 | # remove conda environment 90 | conda env remove --name jupysql-plugin 91 | 92 | # delete yarn.lock 93 | rm yarn.lock 94 | 95 | # delete all temporary files 96 | git clean -fdx 97 | ``` 98 | 99 | Then, create the conda environment again, install dependencies (`jlpm install`), and 100 | build the extension manually (`jupyter labextension build --development True .`). 101 | Finally, verify if `pip install -e "."` works. 102 | 103 | 104 | ### Adding dependencies 105 | 106 | ```bash 107 | jlpm add PACKAGE 108 | 109 | # example 110 | jlpm add @jupyter-widgets/base 111 | ``` 112 | 113 | ### Development uninstall 114 | 115 | ```bash 116 | pip uninstall jupysql-plugin 117 | ``` 118 | 119 | In development mode, you will also need to remove the symlink created by `jupyter labextension develop` 120 | command. To find its location, you can run `jupyter labextension list` to figure out where the `labextensions` 121 | folder is located. Then you can remove the symlink named `jupysql-plugin` within that folder. 122 | 123 | ### Testing the extension 124 | 125 | This extension is using [Jest](https://jestjs.io/) for JavaScript code testing. 126 | 127 | This extension uses [Playwright](https://playwright.dev/docs/intro/) for the integration tests (aka user level tests). 128 | More precisely, the JupyterLab helper [Galata](https://github.com/jupyterlab/jupyterlab/tree/master/galata) is used to handle testing the extension in JupyterLab. More information are provided within the [ui-tests](./ui-tests/README.md) README. 129 | 130 | To run the tests: 131 | 132 | ```sh 133 | pip install nox pyyaml 134 | 135 | # unit tests 136 | nox --session test 137 | 138 | # ui tests 139 | nox --session ui_test 140 | 141 | # to only run the python unit tests 142 | pytest tests 143 | ``` 144 | 145 | ### Releasing the extension 146 | 147 | See [RELEASE](RELEASE.md) 148 | 149 | ### Configuration 150 | 151 | The `package.json` file contains a `jupyterlab` extension. More information about 152 | this section is [here](https://github.com/jupyterlab/jupyterlab/blob/main/docs/source/extension/extension_dev.rst) (you might need to switch the git branch dependin on which version JupyterLab version you're building for). The schema is [here](https://github.com/jupyterlab/jupyterlab/blob/main/builder/metadata_schema.json). 153 | 154 | ### Ploomber Cloud API Endpoint 155 | 156 | You can set the `PLOOMBER_CLOUD_HOST` variable to switch the API endpoint (by default, it's set to our production API endpoint). 157 | 158 | ```sh 159 | export PLOOMBER_CLOUD_HOST=https://cloudapi-dev.ploomber.io 160 | ``` 161 | -------------------------------------------------------------------------------- /jupysql_plugin/widgets/connector_widget.py: -------------------------------------------------------------------------------- 1 | from jupysql_plugin import __version__, _module_name 2 | from jupysql_plugin.widgets.db_templates import CONNECTIONS_TEMPLATES, DRIVER_TO_DBNAME 3 | from jupysql_plugin.widgets.connections import ( 4 | _serialize_connections, 5 | ConnectorWidgetManager, 6 | ) 7 | from jupysql_plugin import exceptions 8 | 9 | from ipywidgets import DOMWidget 10 | from traitlets import Unicode, Dict 11 | import json 12 | 13 | 14 | class ConnectorWidget(DOMWidget): 15 | """ 16 | Manage database connections 17 | """ 18 | 19 | _model_name = Unicode("ConnectorModel").tag(sync=True) 20 | _model_module = Unicode(_module_name).tag(sync=True) 21 | _model_module_version = Unicode(__version__).tag(sync=True) 22 | _view_name = Unicode("ConnectorView").tag(sync=True) 23 | _view_module = Unicode(_module_name).tag(sync=True) 24 | _view_module_version = Unicode(__version__).tag(sync=True) 25 | 26 | connections = Unicode().tag(sync=True) 27 | connections_templates = Unicode().tag(sync=True) 28 | driver_to_dbname = Dict().tag(sync=True) 29 | 30 | def __init__(self, *args, **kwargs): 31 | super().__init__(*args, **kwargs) 32 | 33 | self.widget_manager = ConnectorWidgetManager() 34 | self.stored_connections = self.widget_manager.get_connections_from_config_file() 35 | self.connections = _serialize_connections(self.stored_connections) 36 | self.connections_templates = json.dumps(CONNECTIONS_TEMPLATES) 37 | self.driver_to_dbname = DRIVER_TO_DBNAME 38 | 39 | self.on_msg(self._handle_message) 40 | 41 | def _handle_message(self, widget, content, buffers): 42 | """ 43 | Handles messages from front 44 | """ 45 | if "method" in content: 46 | method = content["method"] 47 | 48 | if method == "check_config_file": 49 | is_exist = self.widget_manager.is_config_exist() 50 | self.send({"method": "check_config_file", "message": is_exist}) 51 | 52 | # user wants to delete connection 53 | elif method == "delete_connection": 54 | connection = content["data"] 55 | self.widget_manager.delete_section_with_name(connection["name"]) 56 | self.send({"method": "deleted", "message": connection["name"]}) 57 | 58 | self.stored_connections = ( 59 | self.widget_manager.get_connections_from_config_file() 60 | ) 61 | connections = _serialize_connections(self.stored_connections) 62 | self.send({"method": "update_connections", "message": connections}) 63 | 64 | # user wants to connect to a database that's been stored in the config file 65 | elif method == "connect": 66 | connection = content["data"] 67 | 68 | try: 69 | self.widget_manager.connect_to_database_in_section( 70 | connection_name=connection["name"] 71 | ) 72 | except Exception as e: 73 | self.send_error_message_to_frontend( 74 | method="connection_error", error=e 75 | ) 76 | else: 77 | self.send({"method": "connected", "message": connection["name"]}) 78 | 79 | # store a new connection in the config file and connect to it 80 | elif method == "submit_new_connection": 81 | new_connection_data = content["data"] 82 | connection_name = new_connection_data.get("connectionName") 83 | 84 | try: 85 | connection_name = ( 86 | self.widget_manager.save_connection_to_config_file_and_connect( 87 | new_connection_data 88 | ) 89 | ) 90 | except exceptions.ConnectionWithNameAlreadyExists as e: 91 | self.send_error_message_to_frontend( 92 | method="connection_name_exists_error", error=e 93 | ) 94 | except Exception as e: 95 | self.send_error_message_to_frontend( 96 | method="connection_error", error=e 97 | ) 98 | else: 99 | self.send({"method": "connected", "message": connection_name}) 100 | self.stored_connections = ( 101 | self.widget_manager.get_connections_from_config_file() 102 | ) 103 | connections = _serialize_connections(self.stored_connections) 104 | self.send({"method": "update_connections", "message": connections}) 105 | 106 | else: 107 | raise ValueError(f"Method {method} is not supported") 108 | else: 109 | raise ValueError("Method is not specified") 110 | 111 | def send_error_message_to_frontend(self, *, method, error): 112 | """Display an error message in the frontend 113 | 114 | Parameters 115 | ---------- 116 | method : str 117 | The method to send to the frontend, this is used to determine how the 118 | frontend should react to the error. 119 | 120 | error : Exception 121 | The error to send to the frontend, the error type and message will be 122 | sent to the frontend. 123 | """ 124 | error_type = error.__class__.__name__ 125 | error_message = f"{error_type}: {str(error)}" 126 | self.send({"method": method, "message": error_message}) 127 | -------------------------------------------------------------------------------- /tests/test_connections.py: -------------------------------------------------------------------------------- 1 | from unittest.mock import ANY 2 | from pathlib import Path 3 | 4 | import pytest 5 | from jupysql_plugin.widgets import connections 6 | from sql.connection import ConnectionManager 7 | 8 | 9 | @pytest.mark.parametrize( 10 | "config_file, expected", 11 | [ 12 | ( 13 | "", 14 | [], 15 | ), 16 | ( 17 | """ 18 | [postgresql] 19 | username = user 20 | password = pass 21 | host = localhost 22 | database = db 23 | drivername = postgresql 24 | port = 5432 25 | """, 26 | [ 27 | { 28 | "name": "postgresql", 29 | "username": "user", 30 | "password": "pass", 31 | "host": "localhost", 32 | "database": "db", 33 | "driver": "postgresql", 34 | "port": "5432", 35 | }, 36 | ], 37 | ), 38 | ( 39 | """ 40 | [duck] 41 | drivername = duckdb 42 | 43 | [sqlite] 44 | drivername = sqlite 45 | """, 46 | [ 47 | {"driver": "duckdb", "name": "duck"}, 48 | {"driver": "sqlite", "name": "sqlite"}, 49 | ], 50 | ), 51 | ], 52 | ids=[ 53 | "empty", 54 | "one", 55 | "two", 56 | ], 57 | ) 58 | def test_get_connections_from_config_file(tmp_empty, config_file, expected): 59 | Path("jupysql-plugin.ini").write_text(config_file) 60 | 61 | assert ( 62 | connections.ConnectorWidgetManager().get_connections_from_config_file() 63 | == expected 64 | ) 65 | 66 | 67 | @pytest.mark.parametrize( 68 | "data, expected", 69 | [ 70 | ( 71 | { 72 | "connectionName": "duck", 73 | "driver": "duckdb", 74 | }, 75 | """\ 76 | [duck] 77 | drivername = duckdb 78 | 79 | """, 80 | ), 81 | ( 82 | { 83 | "connectionName": "pg", 84 | "driver": "postgresql", 85 | "database": "db", 86 | "password": "pass", 87 | "host": "db.corp.com", 88 | "username": "user", 89 | "port": "5432", 90 | }, 91 | """\ 92 | [pg] 93 | username = user 94 | password = pass 95 | host = db.corp.com 96 | database = db 97 | drivername = postgresql 98 | port = 5432 99 | 100 | """, 101 | ), 102 | ], 103 | ) 104 | def test_save_connection_to_config_file_and_connect(tmp_empty, data, expected): 105 | manager = connections.ConnectorWidgetManager() 106 | # do not connect because the db details are invalid 107 | manager.save_connection_to_config_file_and_connect(data, connect=False) 108 | content = Path("jupysql-plugin.ini").read_text() 109 | assert content == expected 110 | 111 | 112 | @pytest.mark.parametrize( 113 | "dsn_filename", 114 | [ 115 | "path-to/jupysql-plugin.ini", 116 | "path/to/jupysql-plugin.ini", 117 | ], 118 | ids=[ 119 | "default", 120 | "nested", 121 | ], 122 | ) 123 | def test_save_connection_to_config_file_and_connect_in_nested_dir( 124 | tmp_empty, override_sql_magic, dsn_filename 125 | ): 126 | override_sql_magic.dsn_filename = dsn_filename 127 | 128 | manager = connections.ConnectorWidgetManager() 129 | manager.save_connection_to_config_file_and_connect( 130 | { 131 | "driver": "duckdb", 132 | "connectionName": "somedb", 133 | "database": ":memory:", 134 | } 135 | ) 136 | 137 | assert ConnectionManager.connections == {"somedb": ANY} 138 | assert "[somedb]" in Path(dsn_filename).read_text() 139 | 140 | 141 | @pytest.mark.parametrize( 142 | "data, expected", 143 | [ 144 | ( 145 | { 146 | "driver": "sqlite", 147 | "connectionName": "mydb", 148 | "database": ":memory:", 149 | "existingConnectionAlias": "mydb", 150 | }, 151 | """ 152 | [mydb] 153 | database = :memory: 154 | drivername = sqlite 155 | """, 156 | ), 157 | ( 158 | { 159 | "driver": "sqlite", 160 | "connectionName": "newdb", 161 | "database": ":memory:", 162 | "existingConnectionAlias": "mydb", 163 | }, 164 | """ 165 | [newdb] 166 | database = :memory: 167 | drivername = sqlite 168 | """, 169 | ), 170 | ], 171 | ids=[ 172 | "change-database", 173 | "change-alias", 174 | ], 175 | ) 176 | def test_save_connection_to_config_file_and_connect_overwrite( 177 | tmp_empty, 178 | override_sql_magic, 179 | data, 180 | expected, 181 | ): 182 | path = Path("jupysql-plugin.ini") 183 | path.write_text( 184 | """ 185 | [mydb] 186 | drivername = sqlite 187 | database = my.db 188 | """ 189 | ) 190 | 191 | manager = connections.ConnectorWidgetManager() 192 | manager.save_connection_to_config_file_and_connect(data) 193 | 194 | assert path.read_text().strip() == expected.strip() 195 | 196 | 197 | def test_delete_section_with_name(tmp_empty): 198 | Path("jupysql-plugin.ini").write_text( 199 | """ 200 | [duck] 201 | drivername = duckdb 202 | 203 | [sqlite] 204 | drivername = sqlite 205 | """ 206 | ) 207 | 208 | manager = connections.ConnectorWidgetManager() 209 | 210 | manager.delete_section_with_name("duck") 211 | 212 | expected = """ 213 | [sqlite] 214 | drivername = sqlite 215 | """.strip() 216 | 217 | assert Path("jupysql-plugin.ini").read_text().strip() == expected 218 | -------------------------------------------------------------------------------- /ui-tests/tests/widget2.test.ts: -------------------------------------------------------------------------------- 1 | import { test } from '@jupyterlab/galata'; 2 | import { expect } from '@playwright/test'; 3 | import { createNewNotebook, displayWidget } from './utils'; 4 | 5 | 6 | const aliasDefaultsWithExistingConnection = [ 7 | { label: 'DuckDB', connectionName: 'duckdb' }, 8 | { label: 'SQLite', connectionName: 'sqlite' }, 9 | { label: 'PostgreSQL', connectionName: 'postgresql' }, 10 | { label: 'MySQL', connectionName: 'mysql' }, 11 | { label: 'MariaDB', connectionName: 'mariadb' }, 12 | { label: 'Snowflake', connectionName: 'snowflake' }, 13 | { label: 'Oracle', connectionName: 'oracle' }, 14 | { label: 'MSSQL', connectionName: 'mssql' }, 15 | { label: 'Redshift', connectionName: 'redshift' } 16 | ] 17 | 18 | for (const { label, connectionName } of aliasDefaultsWithExistingConnection) { 19 | test(`test default connection alias appears if there is an existing connection : ${label}`, async ({ page }) => { 20 | await createNewNotebook(page) 21 | 22 | await page.notebook.enterCellEditingMode(0); 23 | const cell = await page.notebook.getCell(0) 24 | await cell?.type(` 25 | from pathlib import Path 26 | Path('connections.ini').write_text(""" 27 | [first] 28 | drivername = sqlite 29 | database = :memory: 30 | 31 | [second] 32 | drivername = sqlite 33 | database = :memory: 34 | """) 35 | %load_ext sql 36 | %config SqlMagic.dsn_filename = 'connections.ini' 37 | from jupysql_plugin.widgets import ConnectorWidget 38 | ConnectorWidget()`) 39 | await page.notebook.run() 40 | 41 | await page.locator('#createNewConnection').click(); 42 | await page.locator('#selectConnection').selectOption({ label: label }); 43 | 44 | expect(await page.locator(`#connectionName`).evaluate(select => select.value)).toBe(connectionName); 45 | }); 46 | } 47 | 48 | 49 | const autoPopulatedFields = [ 50 | { label: "MySQL", port: "3306" }, 51 | { label: "MariaDB", port: "3306" }, 52 | { label: "Snowflake", port: "443" }, 53 | { label: "Oracle", port: "1521" }, 54 | { label: "MSSQL", port: "1433" }, 55 | { label: "Redshift", port: "5439" } 56 | ]; 57 | 58 | for (const { label, port } of autoPopulatedFields) { 59 | test(`test fields are auto-populated if dropdown selection changes: ${label}`, async ({ 60 | page, 61 | }) => { 62 | await displayWidget(page); 63 | 64 | await page.locator("#createNewConnection").click(); 65 | await page 66 | .locator("#selectConnection") 67 | .selectOption({ label: "PostgreSQL" }); 68 | await page.locator("#username").fill("someuser"); 69 | await page.locator("#password").fill("somepassword"); 70 | await page.locator("#host").fill("localhost"); 71 | await page.locator("#port").fill("5432"); 72 | await page.locator("#database").fill("somedb"); 73 | await page.locator("#selectConnection").selectOption({ label: label }); 74 | 75 | expect(await page.locator(`#port`).evaluate((select) => select.value)).toBe( 76 | port 77 | ); 78 | expect( 79 | await page.locator(`#connectionName`).evaluate((select) => select.value) 80 | ).toBe("default"); 81 | expect( 82 | await page.locator(`#username`).evaluate((select) => select.value) 83 | ).toBe("someuser"); 84 | expect( 85 | await page.locator(`#password`).evaluate((select) => select.value) 86 | ).toBe("somepassword"); 87 | expect(await page.locator(`#host`).evaluate((select) => select.value)).toBe( 88 | "localhost" 89 | ); 90 | expect( 91 | await page.locator(`#database`).evaluate((select) => select.value) 92 | ).toBe("somedb"); 93 | }); 94 | } 95 | 96 | const autoPopulatedFieldsExistingConnection = [ 97 | { label: "MySQL", connectionName: "mysql", port: "3306" }, 98 | { label: "MariaDB", connectionName: "mariadb", port: "3306" }, 99 | { label: "Snowflake", connectionName: "snowflake", port: "443" }, 100 | { label: "Oracle", connectionName: "oracle", port: "1521" }, 101 | { label: "MSSQL", connectionName: "mssql", port: "1433" }, 102 | { label: "Redshift", connectionName: "redshift", port: "5439" }, 103 | ]; 104 | 105 | for (const { label, connectionName, port } of autoPopulatedFieldsExistingConnection) { 106 | test(`test fields are auto-populated if dropdown selection changes, existing connection 107 | present, default DB alias displayed: ${label}`, async ({ 108 | page, 109 | }) => { 110 | await createNewNotebook(page); 111 | 112 | await page.notebook.enterCellEditingMode(0); 113 | const cell = await page.notebook.getCell(0); 114 | await cell?.type(` 115 | from pathlib import Path 116 | Path('connections.ini').write_text(""" 117 | [first] 118 | drivername = sqlite 119 | database = :memory: 120 | 121 | [second] 122 | drivername = sqlite 123 | database = :memory: 124 | """) 125 | %load_ext sql 126 | %config SqlMagic.dsn_filename = 'connections.ini' 127 | from jupysql_plugin.widgets import ConnectorWidget 128 | ConnectorWidget()`); 129 | await page.notebook.run(); 130 | 131 | await page.locator("#createNewConnection").click(); 132 | await page 133 | .locator("#selectConnection") 134 | .selectOption({ label: "PostgreSQL" }); 135 | await page.locator("#username").fill("someuser"); 136 | await page.locator("#password").fill("somepassword"); 137 | await page.locator("#host").fill("localhost"); 138 | await page.locator("#port").fill("5432"); 139 | await page.locator("#database").fill("somedb"); 140 | await page.locator("#selectConnection").selectOption({ label: label }); 141 | 142 | expect( 143 | await page.locator(`#connectionName`).evaluate((select) => select.value) 144 | ).toBe(connectionName); 145 | expect(await page.locator(`#port`).evaluate((select) => select.value)).toBe( 146 | port); 147 | expect( 148 | await page.locator(`#username`).evaluate((select) => select.value) 149 | ).toBe("someuser"); 150 | expect( 151 | await page.locator(`#password`).evaluate((select) => select.value) 152 | ).toBe("somepassword"); 153 | expect(await page.locator(`#host`).evaluate((select) => select.value)).toBe("localhost"); 154 | expect( 155 | await page.locator(`#database`).evaluate((select) => select.value) 156 | ).toBe("somedb"); 157 | }); 158 | } 159 | -------------------------------------------------------------------------------- /tests/test_db_templates.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from jupysql_plugin.widgets.db_templates import CONNECTIONS_TEMPLATES 4 | 5 | 6 | @pytest.mark.parametrize( 7 | "key,expected", 8 | [ 9 | ( 10 | "DuckDB", 11 | { 12 | "driver": "duckdb", 13 | "fields": [ 14 | { 15 | "id": "connectionName", 16 | "label": "Connection alias", 17 | "type": "text", 18 | "default": "duckdb", 19 | }, 20 | { 21 | "id": "database", 22 | "label": "Path to database", 23 | "type": "text", 24 | "default": ":memory:", 25 | }, 26 | ], 27 | }, 28 | ), 29 | ( 30 | "SQLite", 31 | { 32 | "driver": "sqlite", 33 | "fields": [ 34 | { 35 | "id": "connectionName", 36 | "label": "Connection alias", 37 | "type": "text", 38 | "default": "sqlite", 39 | }, 40 | { 41 | "id": "database", 42 | "label": "Path to database", 43 | "type": "text", 44 | "default": ":memory:", 45 | }, 46 | ], 47 | }, 48 | ), 49 | ( 50 | "PostgreSQL", 51 | { 52 | "driver": "postgresql", 53 | "fields": [ 54 | { 55 | "id": "connectionName", 56 | "label": "Connection alias", 57 | "type": "text", 58 | "default": "postgresql", 59 | }, 60 | {"id": "username", "label": "Username", "type": "text"}, 61 | {"id": "password", "label": "Password", "type": "password"}, 62 | {"id": "host", "label": "Host", "type": "text"}, 63 | {"id": "database", "label": "Database", "type": "text"}, 64 | {"id": "port", "label": "Port", "type": "number", "default": 5432}, 65 | ], 66 | }, 67 | ), 68 | ( 69 | "Oracle", 70 | { 71 | "driver": "oracle+oracledb", 72 | "fields": [ 73 | { 74 | "id": "connectionName", 75 | "label": "Connection alias", 76 | "type": "text", 77 | "default": "oracle", 78 | }, 79 | { 80 | "id": "username", 81 | "label": "Username", 82 | "type": "text", 83 | }, 84 | { 85 | "id": "password", 86 | "label": "Password", 87 | "type": "password", 88 | }, 89 | { 90 | "id": "host", 91 | "label": "Host", 92 | "type": "text", 93 | }, 94 | { 95 | "id": "database", 96 | "label": "Database", 97 | "type": "text", 98 | }, 99 | { 100 | "id": "port", 101 | "label": "Port", 102 | "type": "number", 103 | "default": 1521, 104 | }, 105 | ], 106 | }, 107 | ), 108 | ( 109 | "MSSQL", 110 | { 111 | "driver": "mssql+pyodbc", 112 | "fields": [ 113 | { 114 | "id": "connectionName", 115 | "label": "Connection alias", 116 | "type": "text", 117 | "default": "mssql", 118 | }, 119 | { 120 | "id": "username", 121 | "label": "Username", 122 | "type": "text", 123 | }, 124 | { 125 | "id": "password", 126 | "label": "Password", 127 | "type": "password", 128 | }, 129 | { 130 | "id": "host", 131 | "label": "Host", 132 | "type": "text", 133 | }, 134 | { 135 | "id": "database", 136 | "label": "Database", 137 | "type": "text", 138 | }, 139 | { 140 | "id": "port", 141 | "label": "Port", 142 | "type": "number", 143 | "default": 1433, 144 | }, 145 | ], 146 | }, 147 | ), 148 | ( 149 | "Redshift", 150 | { 151 | "driver": "redshift+redshift_connector", 152 | "fields": [ 153 | { 154 | "id": "connectionName", 155 | "label": "Connection alias", 156 | "type": "text", 157 | "default": "redshift", 158 | }, 159 | { 160 | "id": "username", 161 | "label": "Username", 162 | "type": "text", 163 | }, 164 | { 165 | "id": "password", 166 | "label": "Password", 167 | "type": "password", 168 | }, 169 | { 170 | "id": "host", 171 | "label": "Host", 172 | "type": "text", 173 | }, 174 | { 175 | "id": "database", 176 | "label": "Database", 177 | "type": "text", 178 | }, 179 | { 180 | "id": "port", 181 | "label": "Port", 182 | "type": "number", 183 | "default": 5439, 184 | }, 185 | ], 186 | }, 187 | ), 188 | ], 189 | ids=["duckdb", "sqlite", "postgresql", "oracle", "mssql", "redshift"], 190 | ) 191 | def test_templates(key, expected): 192 | assert CONNECTIONS_TEMPLATES[key] == expected 193 | -------------------------------------------------------------------------------- /jupysql_plugin/widgets/connections.py: -------------------------------------------------------------------------------- 1 | import json 2 | from configparser import ConfigParser 3 | from pathlib import Path 4 | 5 | from sqlalchemy.engine.url import URL 6 | 7 | try: 8 | # renamed in jupysql 0.9.0 9 | from sql.connection import ConnectionManager 10 | 11 | # this was added in jupysql 0.10.0 12 | from sql._current import _get_sql_magic 13 | 14 | # this was renamed in jupysql 0.10.0 15 | from sql.parse import connection_str_from_dsn_section 16 | except (ModuleNotFoundError, ImportError) as e: 17 | raise ModuleNotFoundError( 18 | "Your jupysql version isn't compatible with this version of jupysql-plugin. " 19 | "Please update: pip install jupysql --upgrade" 20 | ) from e 21 | 22 | 23 | from jupysql_plugin import exceptions 24 | 25 | 26 | class ConnectorWidgetManager: 27 | """ 28 | Used by the ConnectorWidget to manage database connections and 29 | configuration file 30 | """ 31 | 32 | def get_path_to_config_file(self) -> str: 33 | """ 34 | Returns config file path 35 | """ 36 | return _get_sql_magic().dsn_filename 37 | 38 | def is_config_exist(self) -> bool: 39 | """Returns True if the config file exists, False otherwise""" 40 | return Path(self.get_path_to_config_file()).is_file() 41 | 42 | def _get_config(self) -> ConfigParser: 43 | """ 44 | Returns current config file 45 | """ 46 | config = ConfigParser() 47 | 48 | config.read(self.get_path_to_config_file()) 49 | return config 50 | 51 | def _get_connection_string_from_section_in_config_file( 52 | self, connection_name 53 | ) -> str: 54 | """ 55 | Reads the desired section from the config file and returns the connection 56 | string 57 | """ 58 | 59 | class Config: 60 | dsn_filename = Path(self.get_path_to_config_file()) 61 | 62 | connection_string = connection_str_from_dsn_section( 63 | section=connection_name, config=Config() 64 | ) 65 | 66 | return connection_string 67 | 68 | def section_name_already_exists(self, connection_name) -> bool: 69 | config = ConnectorWidgetManager()._get_config() 70 | return connection_name in config.sections() 71 | 72 | def get_connections_from_config_file(self) -> list: 73 | """ 74 | Return the list of connections (dictionaries) from the configuration file 75 | """ 76 | connections = [] 77 | config = self._get_config() 78 | 79 | def _config_section_to_dict(config, section): 80 | d = dict(config.items(section)) 81 | d["name"] = section 82 | 83 | if "drivername" in d: 84 | d["driver"] = d.pop("drivername") 85 | 86 | return d 87 | 88 | connections = [ 89 | _config_section_to_dict(config, section) for section in config.sections() 90 | ] 91 | 92 | return connections 93 | 94 | def save_connection_to_config_file_and_connect( 95 | self, 96 | connection_data, 97 | *, 98 | connect=True, 99 | ): 100 | """ 101 | Connects to the database specified in the connection_data. If connection 102 | succeeds, saves the connection to the config file. 103 | 104 | Parameters 105 | ---------- 106 | connection_data: dict 107 | Dictionary with connection details 108 | 109 | connect: bool 110 | If True, will attempt to connect to the database 111 | 112 | Returns 113 | ------- 114 | connection_name: str 115 | Name of the connection 116 | 117 | Raises 118 | ------ 119 | Exception 120 | If the connection fails to establish 121 | """ 122 | connection_name = connection_data["connectionName"] 123 | existing_alias = connection_data.get("existingConnectionAlias") 124 | changed_alias = existing_alias != connection_name 125 | 126 | if changed_alias and self.section_name_already_exists(connection_name): 127 | raise exceptions.ConnectionWithNameAlreadyExists(connection_name) 128 | 129 | driver_name = connection_data["driver"] 130 | 131 | database = connection_data.get("database") 132 | password = connection_data.get("password") 133 | host = connection_data.get("host") 134 | user_name = connection_data.get("username") 135 | port = connection_data.get("port") 136 | 137 | url_data = { 138 | "username": user_name, 139 | "password": password, 140 | "host": host, 141 | "database": database, 142 | "drivername": driver_name, 143 | "port": port, 144 | } 145 | 146 | # before updating the config file, we need to make sure that the connection 147 | # details are valid 148 | if connect: 149 | connection_str = str( 150 | URL.create(**url_data).render_as_string(hide_password=False) 151 | ) 152 | 153 | self.connect_to_database(connection_str, connection_name) 154 | 155 | self._save_new_section_to_config_file(connection_name, url_data, existing_alias) 156 | 157 | return connection_name 158 | 159 | def _save_new_section_to_config_file( 160 | self, connection_name, connection_data, existing_alias 161 | ): 162 | """ 163 | Stores connection in the config file 164 | """ 165 | config = ConnectorWidgetManager()._get_config() 166 | 167 | if existing_alias: 168 | del config[existing_alias] 169 | 170 | config[connection_name] = {k: v for k, v in connection_data.items() if v} 171 | 172 | path_to_config_file = Path(ConnectorWidgetManager().get_path_to_config_file()) 173 | 174 | if not path_to_config_file.parent.exists(): 175 | path_to_config_file.parent.mkdir(parents=True) 176 | 177 | with open(path_to_config_file, "w") as config_file: 178 | config.write(config_file) 179 | 180 | def connect_to_database_in_section(self, *, connection_name): 181 | """ 182 | Connect to a database by reading a given section from the connections file. 183 | """ 184 | connection_string = self._get_connection_string_from_section_in_config_file( 185 | connection_name 186 | ) 187 | 188 | self.connect_to_database(connection_string, connection_name) 189 | 190 | def connect_to_database(self, connection_str, connection_name): 191 | """Connect to a database using a connection string and alias""" 192 | # this method contains the error handling logic that helps the user diagnose 193 | # connection errors so we use this instead of the SQLAlchemy/DBAPIConnection 194 | # constructor 195 | ConnectionManager.set(connection_str, alias=connection_name, displaycon=False) 196 | 197 | def delete_section_with_name(self, section_name): 198 | """ 199 | Deletes section from connections file 200 | """ 201 | 202 | config = ConnectorWidgetManager()._get_config() 203 | 204 | with open(ConnectorWidgetManager().get_path_to_config_file(), "r") as f: 205 | config.readfp(f) 206 | 207 | config.remove_section(section_name) 208 | 209 | with open(ConnectorWidgetManager().get_path_to_config_file(), "w") as f: 210 | config.write(f) 211 | 212 | 213 | def _serialize_connections(connections): 214 | """ 215 | Returns connections object as JSON 216 | """ 217 | return json.dumps(connections) 218 | -------------------------------------------------------------------------------- /ui-tests/tests/widget.test.ts: -------------------------------------------------------------------------------- 1 | import { test } from '@jupyterlab/galata'; 2 | import { expect } from '@playwright/test'; 3 | import { createNewNotebook, displayWidget } from './utils'; 4 | 5 | const embeddedDatabaseLabels = ["DuckDB", "SQLite"]; 6 | 7 | for (const label of embeddedDatabaseLabels) { 8 | test(`test only relevant fields are auto-populated in embedded dbs 9 | if dropdown selection changes: ${label}`, async ({ page }) => { 10 | await displayWidget(page); 11 | 12 | await page.locator("#createNewConnection").click(); 13 | await page 14 | .locator("#selectConnection") 15 | .selectOption({ label: "PostgreSQL" }); 16 | 17 | await page.locator("#connectionName").fill("somealias"); 18 | await page.locator("#username").fill("someuser"); 19 | await page.locator("#password").fill("somepassword"); 20 | await page.locator("#host").fill("localhost"); 21 | await page.locator("#port").fill("3308"); 22 | await page.locator("#database").fill("somedb"); 23 | await page.locator("#selectConnection").selectOption({ label: label }); 24 | 25 | expect( 26 | await page.locator(`#connectionName`).evaluate((select) => select.value) 27 | ).toBe("somealias"); 28 | 29 | expect( 30 | await page.locator(`#database`).evaluate((select) => select.value) 31 | ).toBe("somedb"); 32 | 33 | const username = page.locator("#username"); 34 | expect(await username.count()).toBe(0); 35 | 36 | const password = page.locator("#password"); 37 | expect(await password.count()).toBe(0); 38 | 39 | const host = page.locator("#host"); 40 | expect(await host.count()).toBe(0); 41 | 42 | const port = page.locator("#port"); 43 | expect(await port.count()).toBe(0); 44 | }); 45 | } 46 | 47 | 48 | 49 | const autoPopulatedFieldsAliasModified = [ 50 | { label: "MySQL", port: "3306" }, 51 | { label: "MariaDB", port: "3306" }, 52 | { label: "Snowflake", port: "443" }, 53 | { label: "Oracle", port: "1521" }, 54 | { label: "MSSQL", port: "1433" }, 55 | { label: "Redshift", port: "5439" }, 56 | ]; 57 | 58 | for (const { label, port } of autoPopulatedFieldsAliasModified) { 59 | test(`test fields are auto-populated if dropdown selection changes and default 60 | alias modified by user: ${label}`, async ({ 61 | page, 62 | }) => { 63 | await displayWidget(page); 64 | 65 | await page.locator("#createNewConnection").click(); 66 | await page 67 | .locator("#selectConnection") 68 | .selectOption({ label: "PostgreSQL" }); 69 | await page.locator("#connectionName").fill("somealias"); 70 | await page.locator("#username").fill("someuser"); 71 | await page.locator("#password").fill("somepassword"); 72 | await page.locator("#host").fill("localhost"); 73 | await page.locator("#port").fill("5432"); 74 | await page.locator("#database").fill("somedb"); 75 | await page.locator("#selectConnection").selectOption({ label: label }); 76 | 77 | expect( 78 | await page.locator(`#connectionName`).evaluate((select) => select.value) 79 | ).toBe("somealias"); 80 | expect(await page.locator(`#port`).evaluate((select) => select.value)).toBe( 81 | port 82 | ); 83 | expect( 84 | await page.locator(`#username`).evaluate((select) => select.value) 85 | ).toBe("someuser"); 86 | expect( 87 | await page.locator(`#password`).evaluate((select) => select.value) 88 | ).toBe("somepassword"); 89 | expect(await page.locator(`#host`).evaluate((select) => select.value)).toBe( 90 | "localhost" 91 | ); 92 | expect( 93 | await page.locator(`#database`).evaluate((select) => select.value) 94 | ).toBe("somedb"); 95 | }); 96 | } 97 | 98 | const autoPopulatedFieldsAliasModifiedExistingConnection = [ 99 | { label: "MySQL", port: "3306" }, 100 | { label: "MariaDB", port: "3306" }, 101 | { label: "Snowflake", port: "443" }, 102 | { label: "Oracle", port: "1521" }, 103 | { label: "MSSQL", port: "1433" }, 104 | { label: "Redshift", port: "5439" } 105 | ]; 106 | 107 | for (const { label, port } 108 | of autoPopulatedFieldsAliasModifiedExistingConnection) { 109 | test(`test fields are auto-populated if dropdown selection changes, there is an 110 | existing connection and default alias modified by user: ${label}`, async ({ 111 | page, 112 | }) => { 113 | await createNewNotebook(page); 114 | 115 | await page.notebook.enterCellEditingMode(0); 116 | const cell = await page.notebook.getCell(0); 117 | await cell?.type(` 118 | from pathlib import Path 119 | Path('connections.ini').write_text(""" 120 | [first] 121 | drivername = sqlite 122 | database = :memory: 123 | 124 | [second] 125 | drivername = sqlite 126 | database = :memory: 127 | """) 128 | %load_ext sql 129 | %config SqlMagic.dsn_filename = 'connections.ini' 130 | from jupysql_plugin.widgets import ConnectorWidget 131 | ConnectorWidget()`); 132 | await page.notebook.run(); 133 | 134 | await page.locator("#createNewConnection").click(); 135 | await page 136 | .locator("#selectConnection") 137 | .selectOption({ label: "PostgreSQL" }); 138 | await page.locator("#connectionName").fill("somealias"); 139 | await page.locator("#username").fill("someuser"); 140 | await page.locator("#password").fill("somepassword"); 141 | await page.locator("#host").fill("localhost"); 142 | await page.locator("#port").fill("5432"); 143 | await page.locator("#database").fill("somedb"); 144 | await page.locator("#selectConnection").selectOption({ label: label }); 145 | 146 | expect( 147 | await page.locator(`#connectionName`).evaluate((select) => select.value) 148 | ).toBe("somealias"); 149 | expect(await page.locator(`#port`).evaluate((select) => select.value)).toBe( 150 | port 151 | ); 152 | expect( 153 | await page.locator(`#username`).evaluate((select) => select.value) 154 | ).toBe("someuser"); 155 | expect( 156 | await page.locator(`#password`).evaluate((select) => select.value) 157 | ).toBe("somepassword"); 158 | expect(await page.locator(`#host`).evaluate((select) => select.value)).toBe( 159 | "localhost" 160 | ); 161 | expect( 162 | await page.locator(`#database`).evaluate((select) => select.value) 163 | ).toBe("somedb"); 164 | }); 165 | } 166 | 167 | const databaseLabels = ["MySQL", "MariaDB", "Snowflake", "Oracle", "MSSQL", "Redshift"]; 168 | 169 | for (const label of databaseLabels) { 170 | test(`test fields are auto-populated if dropdown selection changes and default 171 | port modified by user: ${label}`, async ({ 172 | page, 173 | }) => { 174 | await displayWidget(page); 175 | 176 | await page.locator("#createNewConnection").click(); 177 | await page 178 | .locator("#selectConnection") 179 | .selectOption({ label: "PostgreSQL" }); 180 | await page.locator("#username").fill("someuser"); 181 | await page.locator("#password").fill("somepassword"); 182 | await page.locator("#host").fill("localhost"); 183 | await page.locator("#port").fill("3308"); 184 | await page.locator("#database").fill("somedb"); 185 | await page.locator("#selectConnection").selectOption({ label: label }); 186 | 187 | expect( 188 | await page.locator(`#connectionName`).evaluate((select) => select.value) 189 | ).toBe("default"); 190 | expect(await page.locator(`#port`).evaluate((select) => select.value)).toBe( 191 | "3308" 192 | ); 193 | expect( 194 | await page.locator(`#username`).evaluate((select) => select.value) 195 | ).toBe("someuser"); 196 | expect( 197 | await page.locator(`#password`).evaluate((select) => select.value) 198 | ).toBe("somepassword"); 199 | expect(await page.locator(`#host`).evaluate((select) => select.value)).toBe( 200 | "localhost" 201 | ); 202 | expect( 203 | await page.locator(`#database`).evaluate((select) => select.value) 204 | ).toBe("somedb"); 205 | }); 206 | } 207 | 208 | 209 | -------------------------------------------------------------------------------- /jupysql_plugin/widgets/db_templates.py: -------------------------------------------------------------------------------- 1 | DRIVER_TO_DBNAME = { 2 | "duckdb": "DuckDB", 3 | "sqlite": "SQLite", 4 | "postgresql": "PostgreSQL", 5 | "mysql+pymysql": "MySQL", 6 | "snowflake": "Snowflake", 7 | "mariadb": "MariaDB", 8 | "oracle+oracledb": "Oracle", 9 | "mssql+pyodbc": "MSSQL", 10 | "redshift+redshift_connector": "Redshift", 11 | } 12 | 13 | CONNECTIONS_TEMPLATES = dict( 14 | { 15 | "DuckDB": { 16 | "driver": "duckdb", 17 | "fields": [ 18 | { 19 | "id": "connectionName", 20 | "label": "Connection alias", 21 | "type": "text", 22 | "default": "duckdb", 23 | }, 24 | { 25 | "id": "database", 26 | "label": "Path to database", 27 | "type": "text", 28 | "default": ":memory:", 29 | }, 30 | ], 31 | }, 32 | "SQLite": { 33 | "driver": "sqlite", 34 | "fields": [ 35 | { 36 | "id": "connectionName", 37 | "label": "Connection alias", 38 | "type": "text", 39 | "default": "sqlite", 40 | }, 41 | { 42 | "id": "database", 43 | "label": "Path to database", 44 | "type": "text", 45 | "default": ":memory:", 46 | }, 47 | ], 48 | }, 49 | "PostgreSQL": { 50 | "driver": "postgresql", 51 | "fields": [ 52 | { 53 | "id": "connectionName", 54 | "label": "Connection alias", 55 | "type": "text", 56 | "default": "postgresql", 57 | }, 58 | { 59 | "id": "username", 60 | "label": "Username", 61 | "type": "text", 62 | }, 63 | { 64 | "id": "password", 65 | "label": "Password", 66 | "type": "password", 67 | }, 68 | { 69 | "id": "host", 70 | "label": "Host", 71 | "type": "text", 72 | }, 73 | { 74 | "id": "database", 75 | "label": "Database", 76 | "type": "text", 77 | }, 78 | { 79 | "id": "port", 80 | "label": "Port", 81 | "type": "number", 82 | "default": 5432, 83 | }, 84 | ], 85 | }, 86 | "MySQL": { 87 | "driver": "mysql+pymysql", 88 | "fields": [ 89 | { 90 | "id": "connectionName", 91 | "label": "Connection alias", 92 | "type": "text", 93 | "default": "mysql", 94 | }, 95 | { 96 | "id": "username", 97 | "label": "Username", 98 | "type": "text", 99 | }, 100 | { 101 | "id": "password", 102 | "label": "Password", 103 | "type": "password", 104 | }, 105 | { 106 | "id": "host", 107 | "label": "Host", 108 | "type": "text", 109 | }, 110 | { 111 | "id": "port", 112 | "label": "Port", 113 | "type": "number", 114 | "default": 3306, 115 | }, 116 | { 117 | "id": "database", 118 | "label": "Database", 119 | "type": "text", 120 | }, 121 | ], 122 | }, 123 | "MariaDB": { 124 | "driver": "mysql+pymysql", 125 | "fields": [ 126 | { 127 | "id": "connectionName", 128 | "label": "Connection alias", 129 | "type": "text", 130 | "default": "mariadb", 131 | }, 132 | { 133 | "id": "username", 134 | "label": "Username", 135 | "type": "text", 136 | }, 137 | { 138 | "id": "password", 139 | "label": "Password", 140 | "type": "password", 141 | }, 142 | { 143 | "id": "host", 144 | "label": "Host", 145 | "type": "text", 146 | }, 147 | { 148 | "id": "port", 149 | "label": "Port", 150 | "type": "number", 151 | "default": 3306, 152 | }, 153 | { 154 | "id": "database", 155 | "label": "Database", 156 | "type": "text", 157 | }, 158 | ], 159 | }, 160 | "Snowflake": { 161 | "driver": "snowflake", 162 | "fields": [ 163 | { 164 | "id": "connectionName", 165 | "label": "Connection alias", 166 | "type": "text", 167 | "default": "snowflake", 168 | }, 169 | { 170 | "id": "username", 171 | "label": "Username", 172 | "type": "text", 173 | }, 174 | { 175 | "id": "password", 176 | "label": "Password", 177 | "type": "password", 178 | }, 179 | { 180 | "id": "host", 181 | "label": "Host", 182 | "type": "text", 183 | }, 184 | { 185 | "id": "port", 186 | "label": "Port", 187 | "type": "number", 188 | "default": 443, 189 | }, 190 | { 191 | "id": "database", 192 | "label": "Database", 193 | "type": "text", 194 | }, 195 | ], 196 | }, 197 | "Oracle": { 198 | "driver": "oracle+oracledb", 199 | "fields": [ 200 | { 201 | "id": "connectionName", 202 | "label": "Connection alias", 203 | "type": "text", 204 | "default": "oracle", 205 | }, 206 | { 207 | "id": "username", 208 | "label": "Username", 209 | "type": "text", 210 | }, 211 | { 212 | "id": "password", 213 | "label": "Password", 214 | "type": "password", 215 | }, 216 | { 217 | "id": "host", 218 | "label": "Host", 219 | "type": "text", 220 | }, 221 | { 222 | "id": "database", 223 | "label": "Database", 224 | "type": "text", 225 | }, 226 | { 227 | "id": "port", 228 | "label": "Port", 229 | "type": "number", 230 | "default": 1521, 231 | }, 232 | ], 233 | }, 234 | "MSSQL": { 235 | "driver": "mssql+pyodbc", 236 | "fields": [ 237 | { 238 | "id": "connectionName", 239 | "label": "Connection alias", 240 | "type": "text", 241 | "default": "mssql", 242 | }, 243 | { 244 | "id": "username", 245 | "label": "Username", 246 | "type": "text", 247 | }, 248 | { 249 | "id": "password", 250 | "label": "Password", 251 | "type": "password", 252 | }, 253 | { 254 | "id": "host", 255 | "label": "Host", 256 | "type": "text", 257 | }, 258 | { 259 | "id": "database", 260 | "label": "Database", 261 | "type": "text", 262 | }, 263 | { 264 | "id": "port", 265 | "label": "Port", 266 | "type": "number", 267 | "default": 1433, 268 | }, 269 | ], 270 | }, 271 | "Redshift": { 272 | "driver": "redshift+redshift_connector", 273 | "fields": [ 274 | { 275 | "id": "connectionName", 276 | "label": "Connection alias", 277 | "type": "text", 278 | "default": "redshift", 279 | }, 280 | { 281 | "id": "username", 282 | "label": "Username", 283 | "type": "text", 284 | }, 285 | { 286 | "id": "password", 287 | "label": "Password", 288 | "type": "password", 289 | }, 290 | { 291 | "id": "host", 292 | "label": "Host", 293 | "type": "text", 294 | }, 295 | { 296 | "id": "database", 297 | "label": "Database", 298 | "type": "text", 299 | }, 300 | { 301 | "id": "port", 302 | "label": "Port", 303 | "type": "number", 304 | "default": 5439, 305 | }, 306 | ], 307 | }, 308 | } 309 | ) 310 | -------------------------------------------------------------------------------- /ui-tests/tests/widget3.test.ts: -------------------------------------------------------------------------------- 1 | import { test } from '@jupyterlab/galata'; 2 | import { expect } from '@playwright/test'; 3 | import { displayWidget, createDefaultConnection, createNewNotebook } from './utils'; 4 | 5 | 6 | 7 | test('test displays existing connections', async ({ page }) => { 8 | await createNewNotebook(page) 9 | 10 | await page.notebook.enterCellEditingMode(0); 11 | const cell = await page.notebook.getCell(0) 12 | await cell?.type(` 13 | from pathlib import Path 14 | Path('connections.ini').write_text(""" 15 | [first] 16 | drivername = sqlite 17 | database = :memory: 18 | 19 | [second] 20 | drivername = sqlite 21 | database = :memory: 22 | """) 23 | %load_ext sql 24 | %config SqlMagic.dsn_filename = 'connections.ini' 25 | from jupysql_plugin.widgets import ConnectorWidget 26 | ConnectorWidget()`) 27 | await page.notebook.run() 28 | 29 | const connectionsDiv = page.locator('#connectionsButtonsContainer'); 30 | await connectionsDiv.waitFor(); 31 | 32 | const childDivs = connectionsDiv.locator('.connection-name'); 33 | expect(await childDivs.count()).toBe(2); 34 | 35 | const firstChildText = await childDivs.nth(0).textContent(); 36 | expect(firstChildText).toBe('first'); 37 | 38 | const secondChildText = await childDivs.nth(1).textContent(); 39 | expect(secondChildText).toBe('second'); 40 | }); 41 | 42 | 43 | 44 | test('test create new connection', async ({ page }) => { 45 | await createDefaultConnection(page); 46 | 47 | // check that connection is created 48 | let connectionButton = page.locator('#connBtn_default') 49 | await connectionButton.waitFor(); 50 | await expect(connectionButton).toContainText('Connected'); 51 | await expect(page.locator('.connection-name')).toContainText('default'); 52 | 53 | }); 54 | 55 | 56 | 57 | 58 | const fieldDefaultsWhenNoExistingConnection = [ 59 | { label: 'PostgreSQL', connectionName: 'default', port: '5432', username: "", password: "", host: "", database: "" }, 60 | { label: 'MySQL', connectionName: 'default', port: '3306', username: "", password: "", host: "", database: "" }, 61 | { label: 'MariaDB', connectionName: 'default', port: '3306', username: "", password: "", host: "", database: "" }, 62 | { label: 'Snowflake', connectionName: 'default', port: '443', username: "", password: "", host: "", database: "" }, 63 | { label: 'Oracle', connectionName: 'default', port: '1521', username: "", password: "", host: "", database: "" }, 64 | { label: 'MSSQL', connectionName: 'default', port: '1433', username: "", password: "", host: "", database: "" }, 65 | { label: 'Redshift', connectionName: 'default', port: '5439', username: "", password: "", host: "", database: "" }, 66 | 67 | ]; 68 | 69 | for (const { label, connectionName, database, port, username, password, host } of fieldDefaultsWhenNoExistingConnection) { 70 | test(`test field defaults appear if there is no existing connection : ${label}`, async ({ page }) => { 71 | await displayWidget(page); 72 | 73 | await page.locator('#createNewConnection').click(); 74 | await page.locator('#selectConnection').selectOption({ label: label }); 75 | 76 | expect(await page.locator(`#connectionName`).evaluate(select => select.value)).toBe(connectionName); 77 | expect(await page.locator(`#port`).evaluate(select => select.value)).toBe(port); 78 | expect(await page.locator(`#username`).evaluate(select => select.value)).toBe(username); 79 | expect(await page.locator(`#password`).evaluate(select => select.value)).toBe(password); 80 | expect(await page.locator(`#host`).evaluate(select => select.value)).toBe(host); 81 | expect(await page.locator(`#database`).evaluate(select => select.value)).toBe(database); 82 | }); 83 | } 84 | 85 | const relevantFieldsEmbeddedDatabases = [ 86 | { label: 'DuckDB', connectionName: 'default', database: ':memory:' }, 87 | { label: 'SQLite', connectionName: 'default', database: ':memory:' } 88 | ] 89 | 90 | for (const { label, connectionName, database } of relevantFieldsEmbeddedDatabases) { 91 | test(`test only relevant fields appear in embedded database ${label}`, async ({ page }) => { 92 | await displayWidget(page); 93 | 94 | await page.locator('#createNewConnection').click(); 95 | await page.locator('#selectConnection').selectOption({ label: label }); 96 | 97 | expect(await page.locator('#connectionName').evaluate(select => select.value)).toBe(connectionName); 98 | 99 | expect(await page.locator('#database').evaluate(select => select.value)).toBe(database); 100 | 101 | const username = page.locator('#username'); 102 | expect(await username.count()).toBe(0); 103 | 104 | const password = page.locator('#password'); 105 | expect(await password.count()).toBe(0); 106 | 107 | const host = page.locator('#host'); 108 | expect(await host.count()).toBe(0); 109 | 110 | }); 111 | } 112 | 113 | test('test user inputs discarded after create button clicked', async ({ page }) => { 114 | await displayWidget(page); 115 | 116 | await page.locator('#createNewConnection').click(); 117 | await page.locator('#selectConnection').selectOption({ label: 'DuckDB' }); 118 | await page.locator('#database').fill('duck.db'); 119 | await page.locator('#createConnectionFormButton').click(); 120 | 121 | // Create new connection again 122 | await page.locator('#createNewConnection').click(); 123 | await page.locator('#selectConnection').selectOption({ label: 'PostgreSQL' }); 124 | 125 | expect( 126 | await page.locator(`#database`).evaluate((select) => select.value) 127 | ).toBe(""); 128 | }); 129 | 130 | test('test create new connection shows error if unable to connect', async ({ page }) => { 131 | await displayWidget(page); 132 | 133 | // create a new connection with invalid credentials (db isn't running in localhost) 134 | await page.locator('#createNewConnection').click(); 135 | await page.locator('#selectConnection').selectOption({ label: 'PostgreSQL' }); 136 | await page.locator('#username').fill('someuser'); 137 | await page.locator('#password').fill('somepassword'); 138 | await page.locator('#host').fill('localhost'); 139 | await page.locator('#port').fill('5432'); 140 | await page.locator('#database').fill('somedb'); 141 | await page.locator('#createConnectionFormButton').click(); 142 | 143 | 144 | // check error message 145 | await expect(page.locator('.user-error-message')).toContainText('UsageError'); 146 | 147 | // ensure connection file isn't created 148 | await page.notebook.addCell("code", "%%sh\ncat connections.ini") 149 | await page.notebook.runCell(1); 150 | 151 | let output 152 | output = await page.notebook.getCellTextOutput(1); 153 | await expect(output[0]).toContain('connections.ini: No such file or directory'); 154 | 155 | }); 156 | 157 | 158 | test('test delete connection', async ({ page }) => { 159 | await createDefaultConnection(page); 160 | 161 | // click on delete connection button and confirm 162 | await page.locator('#deleteConnBtn_default').click(); 163 | await page.locator('#deleteConnectionButton').click(); 164 | 165 | expect(page.locator('#connectionsButtonsContainer')).toBeEmpty(); 166 | 167 | }); 168 | 169 | 170 | test('test edit connection', async ({ page }) => { 171 | await createDefaultConnection(page); 172 | 173 | // click on edit connection button, edit, and confirm 174 | await page.locator('#editConnBtn_default').click(); 175 | await page.locator('#database').fill('duck.db'); 176 | await page.locator('#updateConnectionFormButton').click(); 177 | 178 | 179 | // check that connection is still there 180 | let connectionButton = page.locator('#connBtn_default') 181 | await connectionButton.waitFor(); 182 | await expect(connectionButton).toContainText('Connected'); 183 | await expect(page.locator('.connection-name')).toContainText('default'); 184 | 185 | await page.notebook.addCell("code", "%%sh\ncat connections.ini") 186 | await page.notebook.runCell(1); 187 | 188 | let output 189 | output = await page.notebook.getCellTextOutput(1); 190 | await expect(output[0]).toContain('database = duck.db'); 191 | }); 192 | 193 | 194 | test('test edit connection shows error if unable to connect', async ({ page }) => { 195 | await createDefaultConnection(page); 196 | 197 | // click on edit connection button, edit, and confirm (with invalid credentials) 198 | await page.locator('#editConnBtn_default').click(); 199 | await page.locator('#database').fill('path/to/missing/duck.db'); 200 | await page.locator('#updateConnectionFormButton').click(); 201 | 202 | 203 | // check error message 204 | await expect(page.locator('.user-error-message')).toContainText('duckdb.IOException'); 205 | 206 | // ensure connection file isn't modified 207 | await page.notebook.addCell("code", "%%sh\ncat connections.ini") 208 | await page.notebook.runCell(1); 209 | 210 | let output 211 | output = await page.notebook.getCellTextOutput(1); 212 | // should still be :memory: 213 | await expect(output[0]).toContain('database = :memory:'); 214 | }); 215 | 216 | 217 | 218 | test('test edit connection alias', async ({ page }) => { 219 | await createDefaultConnection(page); 220 | 221 | // click on edit connection button, edit, and confirm 222 | await page.locator('#editConnBtn_default').click(); 223 | await page.locator('#connectionName').fill('duckdb'); 224 | await page.locator('#updateConnectionFormButton').click(); 225 | 226 | 227 | // check that there is only one connection 228 | let connectionsDiv = page.locator('#connectionsButtonsContainer') 229 | await connectionsDiv.waitFor(); 230 | 231 | const childDivs = connectionsDiv.locator('> div'); 232 | const innerDivCount = await childDivs.count(); 233 | expect(innerDivCount).toBe(1); 234 | }); 235 | 236 | 237 | test('test error if creates connection with existing name', async ({ page }) => { 238 | await createDefaultConnection(page); 239 | 240 | await page.locator('#createNewConnection').click(); 241 | await page.locator('#connectionName').fill('default'); 242 | await page.locator('#createConnectionFormButton').click(); 243 | 244 | await expect(page.locator('.user-error-message')).toContainText("A connection named 'default' already exists in your connections file"); 245 | }); 246 | 247 | 248 | test('test error if edit connection with existing name', async ({ page }) => { 249 | // create default connection 250 | await createDefaultConnection(page); 251 | 252 | // create a new connection 253 | await page.locator('#createNewConnection').click(); 254 | await page.locator('#connectionName').fill('duckdb'); 255 | await page.locator('#createConnectionFormButton').click(); 256 | 257 | // try to rename it to default, this should fail 258 | await page.locator('#editConnBtn_duckdb').click(); 259 | await page.locator('#connectionName').fill('default'); 260 | await page.locator('#updateConnectionFormButton').click(); 261 | 262 | 263 | await expect(page.locator('.user-error-message')).toContainText("A connection named 'default' already exists in your connections file"); 264 | }); -------------------------------------------------------------------------------- /src/widgets/connector.ts: -------------------------------------------------------------------------------- 1 | import { 2 | DOMWidgetModel, 3 | DOMWidgetView, 4 | ISerializers, 5 | } from '@jupyter-widgets/base'; 6 | 7 | import { MODULE_NAME, MODULE_VERSION } from '../version'; 8 | 9 | 10 | // Import the CSS 11 | import '../../style/connector.css'; 12 | 13 | 14 | interface Connection { 15 | name: string, 16 | driver: string, 17 | username: string, 18 | password: string, 19 | host: string, 20 | port: string, 21 | database: string, 22 | } 23 | 24 | interface ConnectionTemplate { 25 | fields: Array, 26 | connection_string: string 27 | } 28 | 29 | interface Field { 30 | id: string, 31 | label: string, 32 | type: string, 33 | default: string 34 | } 35 | 36 | export class ConnectorModel extends DOMWidgetModel { 37 | defaults() { 38 | return { 39 | ...super.defaults(), 40 | _model_name: ConnectorModel.model_name, 41 | _model_module: ConnectorModel.model_module, 42 | _model_module_version: ConnectorModel.model_module_version, 43 | _view_name: ConnectorModel.view_name, 44 | _view_module: ConnectorModel.view_module, 45 | _view_module_version: ConnectorModel.view_module_version, 46 | connections: ConnectorModel.connections, 47 | connections_templates: ConnectorModel.connections_templates, 48 | driver_to_dbname: ConnectorModel.driver_to_dbname, 49 | }; 50 | } 51 | 52 | static serializers: ISerializers = { 53 | ...DOMWidgetModel.serializers, 54 | // Add any extra serializers here 55 | }; 56 | 57 | static model_name = 'ConnectorModel'; 58 | static model_module = MODULE_NAME; 59 | static model_module_version = MODULE_VERSION; 60 | static view_name = 'ConnectorModel'; // Set to null if no view 61 | static view_module = MODULE_NAME; // Set to null if no view 62 | static view_module_version = MODULE_VERSION; 63 | static connections: any[] = []; 64 | static connections_templates: any[] = []; 65 | static driver_to_dbname: any[] = []; 66 | } 67 | 68 | export class ConnectorView extends DOMWidgetView { 69 | 70 | // available connections 71 | connections = JSON.parse(this.model.get('connections')); 72 | 73 | // connections templates for creating a new connection 74 | connectionsTemplates = JSON.parse(this.model.get('connections_templates')); 75 | 76 | 77 | driver_to_dbname = this.model.get('driver_to_dbname'); 78 | 79 | activeConnection = "" 80 | 81 | 82 | render() { 83 | this.el.classList.add('connector-widget'); 84 | 85 | this.drawConnectionsList(this.connections); 86 | 87 | // Listen for messages from the Python backend 88 | this.model.on('msg:custom', this.handleMessage.bind(this)); 89 | } 90 | 91 | /** 92 | * Draws the connection list 93 | * 94 | * 95 | * @param connection : The availble connections 96 | */ 97 | drawConnectionsList(connections: Array) { 98 | console.log('driver to db name', this.driver_to_dbname) 99 | 100 | this.el.innerHTML = "" 101 | const template = ` 102 |
103 |
104 |

105 | Connections 106 |

107 | 108 |
109 | 110 | * Connections are loaded from your connections file (set it with %config SqlMagic.dsn_filename = "path/to/file", default is "~/.jupysql/connections.ini"). 111 | 112 | 113 | 116 | 117 | 120 |
121 | 122 |
123 |
124 | 125 |
126 |
127 |
128 |
129 | Create new connection 130 |
131 |
132 |
133 |
134 | 135 | 140 | 141 | 142 | 151 |
152 | ` 153 | this.el.innerHTML = template; 154 | 155 | // Draw connection buttons 156 | connections.forEach((connection: Connection) => { 157 | const { name } = connection; 158 | const name_without_spaces = name.replace(/ /g, "_"); 159 | 160 | const buttonContainer = document.createElement("DIV"); 161 | buttonContainer.className = "connection-button-container"; 162 | 163 | const actionsContainer = document.createElement("DIV"); 164 | actionsContainer.className = "connection-button-actions"; 165 | 166 | const connectionName = document.createElement("DIV"); 167 | connectionName.className = "connection-name"; 168 | connectionName.innerText = name; 169 | actionsContainer.appendChild(connectionName); 170 | 171 | const connectButton = document.createElement("BUTTON"); 172 | connectButton.id = `connBtn_${name_without_spaces}`; 173 | connectButton.className = "secondary connectionStatusButton"; 174 | connectButton.innerHTML = "Connect"; 175 | connectButton.onclick = this.handleConnectionClick.bind(this, connection); 176 | 177 | // button to edit a connection 178 | const editConnection = document.createElement("BUTTON"); 179 | editConnection.className = `edit-connection-button`; 180 | editConnection.id = `editConnBtn_${name_without_spaces}`; 181 | editConnection.onclick = this.handleEditConnectionClick.bind(this, connection); 182 | 183 | // trash can button to delete a connection 184 | const deleteConnection = document.createElement("BUTTON"); 185 | deleteConnection.className = `delete-connection-button`; 186 | deleteConnection.id = `deleteConnBtn_${name_without_spaces}`; 187 | deleteConnection.onclick = this.handleDeleteConnectionClick.bind(this, connection); 188 | 189 | // add buttons to the actions container 190 | let connectionsButtonsContainer = this.el.querySelector('#connectionsButtonsContainer'); 191 | actionsContainer.appendChild(connectButton); 192 | actionsContainer.appendChild(editConnection); 193 | actionsContainer.appendChild(deleteConnection); 194 | 195 | buttonContainer.appendChild(actionsContainer); 196 | connectionsButtonsContainer.appendChild(buttonContainer); 197 | 198 | let divider = document.createElement("HR"); 199 | divider.className = "divider"; 200 | buttonContainer.appendChild(divider); 201 | 202 | }); 203 | 204 | // Draw new connection dropdown 205 | const select = this.el.querySelector("#selectConnection"); 206 | Object.keys(this.connectionsTemplates).forEach(key => { 207 | const option = document.createElement("OPTION"); 208 | option.innerHTML = key; 209 | select.appendChild(option) 210 | }) 211 | 212 | select.addEventListener("change", this.handleCreateNewConnectionChange.bind(this)) 213 | 214 | const newConnectionButton = this.el.querySelector("#createNewConnectionButton"); 215 | newConnectionButton.addEventListener("click", this.handleCreateNewConnectionClick.bind(this)); 216 | 217 | if (this.activeConnection) { 218 | this.markConnectedButton(this.activeConnection); 219 | } 220 | 221 | setTimeout(() => { 222 | const message = { 223 | method: 'check_config_file' 224 | }; 225 | 226 | 227 | this.send(message); 228 | }, 500) 229 | } 230 | 231 | /** 232 | * Connects to a database 233 | * 234 | * @param connection - connection object 235 | */ 236 | handleConnectionClick(connection: Connection) { 237 | const message = { 238 | method: 'connect', 239 | data: connection 240 | }; 241 | 242 | this.send(message); 243 | } 244 | 245 | deleteConnection(connection: Connection) { 246 | const message = { 247 | method: 'delete_connection', 248 | data: connection 249 | }; 250 | 251 | this.send(message); 252 | } 253 | 254 | 255 | handleEditConnectionClick(connection: Connection) { 256 | this.el.querySelector("#connectionFormHeader").innerHTML = "Edit connection"; 257 | 258 | // hide connectionsContainer 259 | (this.el.querySelector("#connectionsContainer")).style.display = "none"; 260 | 261 | // show newConnectionContainer 262 | (this.el.querySelector("#newConnectionContainer")).style.display = "block"; 263 | 264 | 265 | const dropdown = this.el.querySelector("#selectConnection"); 266 | const valueToSelect = this.driver_to_dbname[connection.driver]; 267 | 268 | for (let i = 0; i < dropdown.options.length; i++) { 269 | if (dropdown.options[i].value === valueToSelect) { 270 | dropdown.selectedIndex = i; 271 | break; 272 | } 273 | } 274 | 275 | const select = (this.el.querySelector("#selectConnection")); 276 | const key = select.value; 277 | const connectionTemplate = this.connectionsTemplates[key]; 278 | this.drawConnectionDetailsForm(connectionTemplate, connection.name); 279 | 280 | const name = (this.el.querySelector("#connectionName")); 281 | if (name) { 282 | name.value = connection.name; 283 | } 284 | 285 | const username = (this.el.querySelector("#username")); 286 | if (username) { 287 | username.value = connection.username; 288 | } 289 | 290 | const password = (this.el.querySelector("#password")); 291 | if (password) { 292 | password.value = connection.password; 293 | } 294 | 295 | const host = (this.el.querySelector("#host")); 296 | if (host) { 297 | host.value = connection.host; 298 | } 299 | 300 | const db = (this.el.querySelector("#database")); 301 | if (db) { 302 | db.value = connection.database; 303 | } 304 | 305 | const port = (this.el.querySelector("#port")); 306 | if (port) { 307 | port.value = connection.port; 308 | } 309 | } 310 | 311 | handleDeleteConnectionClick(connection: Connection) { 312 | this.hideDeleteMessageApproval() 313 | 314 | // create new message 315 | const deleteConnectionMessage = document.createElement("DIV"); 316 | deleteConnectionMessage.id = "deleteConnectionMessage"; 317 | 318 | // const warningMessage = `

Delete connection from ini file

319 | //
Are you sure you want to delete ${connection["name"]}?
320 | //
Please note that by doing so, you will permanently remove ${connection["name"]} from the ini file.
` 321 | 322 | const warningMessage = `

Delete ${connection["name"]} from ini file

323 |
Please note that by doing so, you will permanently remove ${connection["name"]} from the ini file.
324 |
Are you sure?
325 | ` 326 | 327 | deleteConnectionMessage.innerHTML = `${warningMessage}
`; 328 | 329 | const cancelButton = document.createElement("BUTTON"); 330 | cancelButton.innerHTML = "Cancel"; 331 | cancelButton.addEventListener("click", this.hideDeleteMessageApproval.bind(this)) 332 | deleteConnectionMessage.querySelector(".actions").appendChild(cancelButton); 333 | 334 | const deleteButton = document.createElement("BUTTON"); 335 | deleteButton.innerHTML = "Delete"; 336 | deleteButton.className = "danger"; 337 | deleteButton.id = "deleteConnectionButton"; 338 | deleteButton.addEventListener("click", this.deleteConnection.bind(this, connection)) 339 | deleteConnectionMessage.querySelector(".actions").appendChild(deleteButton); 340 | 341 | // hide controllers 342 | const deleteConnBtn = this.el.querySelector(`#deleteConnBtn_${connection["name"].replace(/ /g, "_")}`); 343 | const actionsContainer = deleteConnBtn.parentNode; 344 | actionsContainer.style.display = "none" 345 | 346 | // show buttons 347 | const buttonsContainer = actionsContainer.parentNode; 348 | buttonsContainer.prepend(deleteConnectionMessage); 349 | } 350 | 351 | hideDeleteMessageApproval() { 352 | this.el.querySelector("#deleteConnectionMessage")?.remove(); 353 | this.el.querySelectorAll(".connection-button-actions") 354 | .forEach(c => (c).style.display = "inline-flex"); 355 | } 356 | 357 | /** 358 | * Handle create new connection click 359 | */ 360 | handleCreateNewConnectionClick() { 361 | this.el.querySelector("#connectionFormHeader").innerHTML = "Create new connection"; 362 | 363 | // hide connectionsContainer 364 | (this.el.querySelector("#connectionsContainer")).style.display = "none"; 365 | 366 | // show newConnectionContainer 367 | (this.el.querySelector("#newConnectionContainer")).style.display = "block"; 368 | 369 | this.handleCreateNewConnectionChange() 370 | } 371 | 372 | /** 373 | * Handle select new connection 374 | */ 375 | handleCreateNewConnectionChange() { 376 | const select = (this.el.querySelector("#selectConnection")); 377 | const key = select.value; 378 | 379 | const connectionTemplate = this.connectionsTemplates[key]; 380 | 381 | // capture any user inputs before dropdown changed 382 | const userInputData: { [key: string]: any } = {}; 383 | 384 | // get previous selected connection 385 | const previousSelect = sessionStorage.getItem("selectConnection"); 386 | 387 | // when the database selection dropdown changes we need to capture any inputs 388 | // entered by the user in the previous form and save them in the session. 389 | // Only the fields which have been changed by the user are saved. This saved 390 | // data can be used to auto-populate the new form. 391 | if (previousSelect) { 392 | const prevConnectionTemplate = this.connectionsTemplates[previousSelect]; 393 | const { fields } = prevConnectionTemplate; 394 | fields.forEach((field: { id: string; default?: string }) => { 395 | const id = field.id; 396 | const defaultValue = field.hasOwnProperty("default") 397 | ? field["default"] 398 | : ""; 399 | const formField = this.el.querySelector(`#${id}`); 400 | if (formField && formField.value != defaultValue) { 401 | userInputData[id] = formField.value; 402 | } 403 | }); 404 | } 405 | 406 | // save the previous form details 407 | sessionStorage.setItem("fieldInputs", JSON.stringify(userInputData)); 408 | 409 | // save new DB selection 410 | sessionStorage.setItem("selectConnection", key); 411 | 412 | this.drawConnectionDetailsForm(connectionTemplate); 413 | 414 | } 415 | 416 | /** 417 | * Draws a form to create or edit connections 418 | * 419 | * @param connectionTemplate - new connection template 420 | */ 421 | drawConnectionDetailsForm(connectionTemplate: ConnectionTemplate, connectionAlias: string = "") { 422 | const { fields } = connectionTemplate; 423 | 424 | const savedFields = JSON.parse(sessionStorage.getItem("fieldInputs")); 425 | 426 | const connectionFormContainer = this.el.querySelector("#connectionFormContainer"); 427 | connectionFormContainer.innerHTML = ""; 428 | 429 | const connectionForm = document.createElement("FORM"); 430 | connectionForm.id = "connectionForm"; 431 | connectionFormContainer.appendChild(connectionForm) 432 | 433 | // add a hidden value to hold the alias, this is used when editing a connection 434 | const hiddenInput = document.createElement("input"); 435 | hiddenInput.type = "hidden"; 436 | hiddenInput.name = "existingConnectionAlias"; 437 | hiddenInput.value = connectionAlias || ""; 438 | connectionForm.appendChild(hiddenInput); 439 | 440 | fields.forEach(field => { 441 | // text description 442 | const fieldContainer = document.createElement("DIV"); 443 | fieldContainer.className = "field-container"; 444 | const label = document.createElement("LABEL"); 445 | label.setAttribute("for", field.id); 446 | label.innerHTML = field.label; 447 | 448 | // form value 449 | const input = document.createElement("INPUT"); 450 | input.id = field.id; 451 | input.name = field.id; 452 | input.className = "field"; 453 | 454 | // check for saved values 455 | const savedInput = savedFields ? savedFields[field.id] || "" : ""; 456 | 457 | 458 | // when creating the connection alias field, set the default value 459 | // to "default" if there are no connections, this will ensure that 460 | // the notebook automatically reconnects to the database if the 461 | // kernel is restarted 462 | if (field.id == "connectionName" && this.connections.length === 0) { 463 | if (savedInput) { 464 | input.value = savedInput; 465 | } else { 466 | input.value = "default"; 467 | } 468 | } 469 | 470 | // check if any user inputs saved 471 | else if (savedInput) { 472 | input.value = savedInput; 473 | } 474 | 475 | // otherwise, set the default value if there's one 476 | else if (field.default !== undefined) { 477 | input.value = field.default; 478 | } 479 | 480 | input.setAttribute("type", field.type); 481 | 482 | fieldContainer.appendChild(label); 483 | fieldContainer.appendChild(input); 484 | 485 | connectionForm.appendChild(fieldContainer); 486 | }) 487 | 488 | const buttonsContainer = document.createElement("DIV"); 489 | buttonsContainer.className = "buttons-container"; 490 | 491 | // cancel button 492 | const cancelButton = document.createElement("BUTTON"); 493 | cancelButton.innerHTML = "Cancel"; 494 | cancelButton.className = "secondary"; 495 | cancelButton.addEventListener("click", this.drawConnectionsList.bind(this, this.connections)) 496 | buttonsContainer.appendChild(cancelButton); 497 | 498 | // submit form button 499 | const submitButton = document.createElement("BUTTON"); 500 | submitButton.className = "primary"; 501 | buttonsContainer.appendChild(submitButton); 502 | 503 | if (connectionAlias) { 504 | // editing an existing connection 505 | submitButton.innerHTML = "Update"; 506 | submitButton.id = "updateConnectionFormButton"; 507 | connectionForm.addEventListener("submit", this.handleSubmitNewConnection.bind(this)) 508 | } else { 509 | // creating a new connection 510 | submitButton.innerHTML = "Create"; 511 | submitButton.id = "createConnectionFormButton"; 512 | connectionForm.addEventListener("submit", this.handleSubmitNewConnection.bind(this)) 513 | } 514 | 515 | // add buttons to the form 516 | connectionForm.appendChild(buttonsContainer); 517 | 518 | 519 | } 520 | 521 | /** 522 | * Submits new connection form 523 | * 524 | * @param event - Submit event 525 | */ 526 | handleSubmitNewConnection(event: SubmitEvent) { 527 | event.preventDefault(); 528 | sessionStorage.clear() 529 | 530 | let allFieldsFilled = true; 531 | 532 | // Extract form data 533 | const form = event.target as HTMLFormElement; 534 | const formData = new FormData(form); 535 | 536 | 537 | // Convert form data to a plain object 538 | const formValues: { [key: string]: string } = {}; 539 | 540 | for (const [key, value] of formData.entries()) { 541 | const _value = value.toString(); 542 | 543 | formValues[key] = _value; 544 | 545 | // Skip validation for existingConnectionAlias field since it's hidden 546 | // and only used when editing a connection 547 | if (key !== "existingConnectionAlias" && _value.length === 0) { 548 | allFieldsFilled = false; 549 | } 550 | } 551 | 552 | const select = this.el.querySelector("#selectConnection"); 553 | 554 | const driver = this.connectionsTemplates[select.value].driver; 555 | 556 | formValues["driver"] = driver; 557 | 558 | if (allFieldsFilled) { 559 | this.sendFormData(formValues); 560 | } else { 561 | this.showErrorMessage("Error: Please fill in all fields.") 562 | } 563 | } 564 | 565 | 566 | 567 | 568 | /** 569 | * Sends form data to the backend 570 | * 571 | * @param formData - FormData object 572 | */ 573 | sendFormData(formData: { [key: string]: string }) { 574 | const message = { 575 | method: "submit_new_connection", 576 | data: formData 577 | }; 578 | 579 | 580 | // NOTE: responses are handled in the `handleMessage` method 581 | this.send(message); 582 | } 583 | 584 | /** 585 | * Handle messages from the backend 586 | * 587 | * @param content - The method to invoke with data 588 | */ 589 | handleMessage(content: any) { 590 | const errors = ["connection_error", "connection_name_exists_error"] 591 | 592 | if (errors.includes(content.method)) { 593 | this.showErrorMessage(content.message); 594 | } 595 | 596 | if (content.method === "update_connections") { 597 | this.connections = JSON.parse(content.message); 598 | 599 | this.drawConnectionsList(this.connections); 600 | } 601 | 602 | if (content.method === "connected") { 603 | const connectionName = content.message; 604 | this.activeConnection = connectionName; 605 | this.markConnectedButton(connectionName); 606 | } 607 | 608 | 609 | if (content.method === "check_config_file") { 610 | const isExist = content.message; 611 | const i = this.el.querySelector(".connections-guidelines .no-config-file") 612 | if (isExist) { 613 | i.style.display = "none"; 614 | 615 | const iKernelMessage = this.el.querySelector(".connections-guidelines .no-config-file") 616 | iKernelMessage.style.display = (this.connections.length === 0) ? "block" : "none"; 617 | 618 | } else { 619 | i.style.display = "block"; 620 | } 621 | } 622 | } 623 | 624 | /** 625 | * Marks active connection button 626 | * 627 | * @param connectionName - Active connection name 628 | */ 629 | markConnectedButton(connectionName: string) { 630 | this.el.querySelectorAll('.connection-button-actions .connectionStatusButton') 631 | .forEach((button: Element) => { 632 | const buttonEl = (button); 633 | buttonEl.innerHTML = "Connect"; 634 | buttonEl.classList.remove("primary"); 635 | buttonEl.classList.add("secondary"); 636 | }); 637 | 638 | const selectedButtonEl = (this.el.querySelector(`#connBtn_${connectionName.replace(/ /g, "_")}`)); 639 | selectedButtonEl.innerText = "Connected"; 640 | selectedButtonEl.classList.add("primary"); 641 | } 642 | 643 | showErrorMessage(error: string) { 644 | const errorEl = this.el.querySelector(".user-error-message"); 645 | const errorMessageContainer = errorEl.querySelector("pre"); 646 | errorMessageContainer.innerHTML = `${error}`; 647 | errorEl.style.display = "block"; 648 | } 649 | 650 | } 651 | --------------------------------------------------------------------------------