diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile new file mode 100644 index 000000000..19510e0dc --- /dev/null +++ b/.devcontainer/Dockerfile @@ -0,0 +1,5 @@ +FROM mcr.microsoft.com/devcontainers/anaconda:0-3 + +# [Optional] Uncomment this section to install additional OS packages. +# RUN apt-get update && export DEBIAN_FRONTEND=noninteractive \ +# && apt-get -y install --no-install-recommends diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json new file mode 100644 index 000000000..1060e8fa9 --- /dev/null +++ b/.devcontainer/devcontainer.json @@ -0,0 +1,23 @@ +// For format details, see https://aka.ms/devcontainer.json. +{ + "name": "Jupyter", + "build": { + "context": "..", + "dockerfile": "Dockerfile" + }, + "features": { + "ghcr.io/devcontainers/features/git:1": {}, + "ghcr.io/devcontainers/features/github-cli:1": {} + }, + "customizations": { + "vscode": { + "extensions": [ + "ms-python.python", + "ms-python.vscode-pylance", + "ms-toolsai.jupyter", + "GitHub.codespaces" + ] + } + }, + "postCreateCommand": "./.devcontainer/postCreate.sh" +} diff --git a/.devcontainer/postCreate.sh b/.devcontainer/postCreate.sh new file mode 100755 index 000000000..9b3bacd4d --- /dev/null +++ b/.devcontainer/postCreate.sh @@ -0,0 +1,13 @@ +#!/bin/bash -x + +conda init bash + +# Perform install instructions from +# https://ploomber-contributing.readthedocs.io/en/latest/contributing/setup.html +conda create --name ploomber-base python=3.10 --yes +conda activate ploomber-base +pip install pkgmt +pkgmt setup --doc + +# After the devcontainer comes up, you can just enable the jupysql conda env: +# conda activate jupysql \ No newline at end of file diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 000000000..382d04d60 --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1,5 @@ +* @edublancas + +/.github/ @edublancas + +/doc/ @edublancas @neelasha23 diff --git a/.github/ISSUE_TEMPLATE/bug-report.yml b/.github/ISSUE_TEMPLATE/bug-report.yml new file mode 100644 index 000000000..7e4340ea8 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug-report.yml @@ -0,0 +1,63 @@ +name: Bug report +description: Something not working? Create a bug report. +body: + - type: textarea + attributes: + label: What happens? + description: A short, clear and concise description of what the bug is. + validations: + required: true + + - type: textarea + attributes: + label: To Reproduce + description: Steps to reproduce the behavior. Providing a minimal, reproducible example (you can attach a notebook) is the best way to get your issue resolved quickly. + validations: + required: true + + - type: markdown + attributes: + value: "## Environment" + + - type: input + attributes: + label: "OS:" + placeholder: Linux + description: Operating system (e.g. Linux, Windows, macOS) + validations: + required: true + + - type: input + attributes: + label: "JupySQL Version:" + placeholder: e.g. 0.9.0 + validations: + required: true + + - type: markdown + attributes: + value: "To get the version. run: `import sql; print(sql.__version__)`" + + - type: markdown + attributes: + value: "## Identity Disclosure" + + - type: input + attributes: + label: "Full Name:" + placeholder: e.g. John Doe + validations: + required: true + + - type: input + attributes: + label: "Affiliation:" + placeholder: e.g. Big Corp + validations: + required: true + + - type: markdown + attributes: + value: | + If the above is not given and is not obvious from your GitHub profile page, we might close your issue without further review. Please refer to the [reasoning behind this rule](https://berthub.eu/articles/posts/anonymous-help/) if you have questions. + diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 000000000..38dbf99d1 --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,13 @@ +## Describe your changes + +## Issue number + +Closes #X + +## Checklist before requesting a review + +- [ ] Performed a self-review of my code +- [ ] Formatted my code with [`pkgmt format`](https://ploomber-contributing.readthedocs.io/en/latest/contributing/submitting-pr.html#linting-formatting) +- [ ] Added [tests](https://ploomber-contributing.readthedocs.io/en/latest/contributing/submitting-pr.html#testing) (when necessary). +- [ ] Added [docstring](https://ploomber-contributing.readthedocs.io/en/latest/contributing/submitting-pr.html#documenting-changes-and-new-features) documentation and update the [changelog](https://ploomber-contributing.readthedocs.io/en/latest/contributing/submitting-pr.html#changelog) (when needed) + diff --git a/.github/workflows/chatops.yml b/.github/workflows/chatops.yml new file mode 100644 index 000000000..d7d62cc06 --- /dev/null +++ b/.github/workflows/chatops.yml @@ -0,0 +1,78 @@ +name: bot-format + +on: + issue_comment: + types: [created] + +jobs: + pkgmt-format: + if: contains(github.event.comment.html_url, '/pull/') && contains(github.event.comment.body, '/format') + runs-on: ubuntu-latest + + steps: + - uses: xt0rted/pull-request-comment-branch@v2 + id: comment-branch + + - name: Set latest commit status as pending + uses: myrotvorets/set-commit-status-action@master + with: + sha: ${{ steps.comment-branch.outputs.head_sha }} + token: ${{ secrets.GITHUB_TOKEN }} + status: pending + + # there's an alternative way to check out: + # https://github.com/actions/checkout/issues/331#issuecomment-925405415 + - name: Checkout PR branch + uses: actions/checkout@v3 + with: + ref: ${{ steps.comment-branch.outputs.head_ref }} + + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v2 + with: + python-version: '3.10' + + - name: format + run: | + python -m pip install --upgrade pip pkgmt + + # https://github.com/actions/checkout/discussions/479#discussioncomment-625461 + git config user.name 'github-actions[bot]' + git config user.email 'github-actions[bot]@users.noreply.github.com' + + pkgmt format + + if [[ -z $(git status -s) ]] + then + echo "No changes to commit..." + else + echo "Committing changes..." + git add --all + git commit -m 'formattting' + git push + fi + + - name: Set latest commit status as ${{ job.status }} + uses: myrotvorets/set-commit-status-action@master + if: always() + with: + sha: ${{ steps.comment-branch.outputs.head_sha }} + token: ${{ secrets.GITHUB_TOKEN }} + status: ${{ job.status }} + + - name: Add comment to PR + uses: actions/github-script@v6 + if: always() + with: + script: | + const name = '${{ github.workflow }}'; + const url = '${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}'; + const success = '${{ job.status }}' === 'success'; + const body = `${name}: ${success ? 'succeeded ✅' : 'failed ❌'}\n${url}`; + + await github.rest.issues.createComment({ + issue_number: context.issue.number, + owner: context.repo.owner, + repo: context.repo.repo, + body: body + }) diff --git a/.github/workflows/ci-integration-db.yaml b/.github/workflows/ci-integration-db.yaml new file mode 100644 index 000000000..7f2bf3a97 --- /dev/null +++ b/.github/workflows/ci-integration-db.yaml @@ -0,0 +1,54 @@ +# CI - DB Integration - Local is designed to run integration testing against to databases hosted by docker containers +# Target database: PostgreSQL, MySQL, MariaDB, SQLite, DuckDB, MSSQL, Oracle Database +# Sqlalchemy version: 2+ +name: CI - DB Integration - Local +on: + workflow_call: + +jobs: + database-integration-test: + strategy: + matrix: + python-version: ['3.11'] + os: [ubuntu-latest] + runs-on: ${{ matrix.os }} + + env: + PLOOMBER_VERSION_CHECK_DISABLED: true + PYTHON_VERSION: ${{ matrix.python-version }} + + steps: + + - name: Checkout + uses: actions/checkout@v2 + + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v2 + with: + python-version: ${{ matrix.python-version }} + + - name: Install dependencies + run: | + # Install MSSQL ODBC 18 + curl https://packages.microsoft.com/keys/microsoft.asc | sudo apt-key add - + sudo curl -o /etc/apt/sources.list.d/mssql-release.list https://packages.microsoft.com/config/ubuntu/$(lsb_release -rs)/prod.list + sudo apt-get update + sudo ACCEPT_EULA=Y apt-get install -y msodbcsql18 + sudo ACCEPT_EULA=Y apt-get install -y mssql-tools18 + echo 'export PATH="$PATH:/opt/mssql-tools18/bin"' >> ~/.bashrc + source ~/.bashrc + + python -m pip install --upgrade pip + python -m pip install --upgrade nox + nox --session test_integration --install-only + + - name: Integration Test + run: | + nox --session test_integration --no-install --reuse-existing-virtualenvs + + - name: Upload failed images artifacts + uses: actions/upload-artifact@v3 + if: failure() + with: + name: failed-image-artifacts-integration ${{ matrix.os }} ${{ matrix.python-version }} + path: result_images/ \ No newline at end of file diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml new file mode 100644 index 000000000..4a44b0372 --- /dev/null +++ b/.github/workflows/ci.yaml @@ -0,0 +1,211 @@ +name: CI + +on: + push: + branches: + - master + - 'dev/**' + tags: + - '[0-9]+.[0-9]+.[0-9]+' + pull_request: + +jobs: + preliminary: + runs-on: ubuntu-latest + outputs: + check_doc_modified: ${{steps.check_doc_modified.outcome}} + check_changelog_modified: ${{steps.check_changelog_modified.outcome}} + steps: + - name: Checkout Master + uses: actions/checkout@v2 + with: + ref: master + fetch-depth: 1000 + + - name: Checkout + uses: actions/checkout@v2 + with: + fetch-depth: 1000 + + - name: Set up Python + uses: actions/setup-python@v2 + with: + python-version: '3.10' + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install pkgmt + + - name: Check Doc Modified + id: check_doc_modified + run: | + if [ "$GITHUB_EVENT_NAME" == "pull_request" ] + then + echo "Pull request, running check_doc" + python -m pkgmt.fail_if_modified -b origin/master -e doc CHANGELOG.md + else + echo "This is not a pull request event" + echo "Running all tests" + exit 1 + fi + continue-on-error: true + + - name: Check Changelog Modified + env: + labels_JSON: ${{ toJSON(github.event.pull_request.labels.*.name) }} + id: check_changelog_modified + run: | + if [ "$GITHUB_EVENT_NAME" == "pull_request" ] + then + # Check if the array contains "no-changelog" + if echo "$labels_JSON" | jq '. | contains(["no-changelog"])' | grep -q true; then + echo "PR contains no-changelog label" + else + echo "PR does not contain no-changelog label"; + echo "Checking if changelog is modified"; + echo "If this test fails, please add the no-changelog label to the PR or modify the changelog" + python -m pkgmt.fail_if_not_modified -b origin/master -i CHANGELOG.md + fi + else + exit 0 + fi + + test: + needs: [preliminary] + if: needs.preliminary.outputs.check_doc_modified == 'failure' + strategy: + matrix: + python-version: ['3.9', '3.10', '3.11'] + os: [ubuntu-latest, macos-latest, windows-latest] + + runs-on: ${{ matrix.os }} + + env: + PLOOMBER_VERSION_CHECK_DISABLED: true + PYTHON_VERSION: ${{ matrix.python-version }} + + steps: + + - name: Checkout + uses: actions/checkout@v2 + + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v2 + with: + python-version: ${{ matrix.python-version }} + + - name: Lint + run: | + python -m pip install --upgrade pip + python -m pip install --upgrade pkgmt codespell nox + pkgmt lint + codespell + + - name: Install dependencies + run: | + + nox --session test_unit --install-only + + - name: Test with pytest + run: | + + nox --session test_unit --no-install --reuse-existing-virtualenvs + + - name: Upload failed images artifacts + uses: actions/upload-artifact@v3 + if: failure() + with: + name: failed-image-artifacts ${{ matrix.os }} ${{ matrix.python-version }} + path: result_images/ + + test-sqlalchemy-v1: + needs: [preliminary] + if: needs.preliminary.outputs.check_doc_modified == 'failure' + strategy: + matrix: + python-version: ['3.11'] + os: [ubuntu-latest, macos-latest, windows-latest] + + runs-on: ${{ matrix.os }} + + env: + PLOOMBER_VERSION_CHECK_DISABLED: true + PYTHON_VERSION: ${{ matrix.python-version }} + + steps: + + - name: Checkout + uses: actions/checkout@v2 + + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v2 + with: + python-version: ${{ matrix.python-version }} + + - name: Lint + run: | + python -m pip install --upgrade pip + python -m pip install --upgrade pkgmt nox + pkgmt lint + + - name: Install dependencies + run: | + nox --session test_unit_sqlalchemy_one --install-only + + - name: Test with pytest + run: | + nox --session test_unit_sqlalchemy_one --no-install --reuse-existing-virtualenvs + + - name: Upload failed images artifacts sqlalchemyv1 + uses: actions/upload-artifact@v3 + if: failure() + with: + name: failed-image-artifacts-sqlalchemy ${{ matrix.os }} ${{ matrix.python-version }} + path: result_images/ + + # run: pkgmt check + check: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v2 + with: + python-version: '3.10' + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install 'pkgmt[check]' + + - name: Check project + run: | + pkgmt check + + + release: + needs: [test, test-sqlalchemy-v1, check] + if: startsWith(github.ref, 'refs/tags') && github.event_name != 'pull_request' + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v3 + + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v2 + with: + python-version: '3.11' + + - name: Install dependencies + run: | + python -m pip install pkgmt twine wheel setuptools --upgrade + + - name: Release + env: + TWINE_USERNAME: __token__ + TWINE_PASSWORD: ${{ secrets.PYPI_API_TOKEN }} + TAG: ${{ github.ref_name }} + run: | + echo "tag is $TAG" + pkgmt release $TAG --production --yes diff --git a/.github/workflows/rtd.yml b/.github/workflows/rtd.yml new file mode 100644 index 000000000..502bd44cc --- /dev/null +++ b/.github/workflows/rtd.yml @@ -0,0 +1,18 @@ +# .github/workflows/documentation-links.yaml + +name: Read the Docs Pull Request Preview +on: + pull_request_target: + types: + - opened + +permissions: + pull-requests: write + +jobs: + documentation-links: + runs-on: ubuntu-latest + steps: + - uses: readthedocs/actions/preview@v1 + with: + project-slug: "jupysql" \ No newline at end of file diff --git a/.github/workflows/scheduled.yaml b/.github/workflows/scheduled.yaml new file mode 100644 index 000000000..95f817373 --- /dev/null +++ b/.github/workflows/scheduled.yaml @@ -0,0 +1,29 @@ +name: check-for-broken-links + +on: + schedule: + - cron: '0 8 * * *' + + pull_request: + +jobs: + broken-links: + runs-on: ubuntu-latest + if: ${{ !contains(github.event.pull_request.labels.*.name, 'allow-broken-links') }} + + steps: + - uses: actions/checkout@v2 + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v2 + with: + python-version: '3.10' + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install pkgmt + + - name: Check for broken links + run: | + pkgmt check-links --only-404 + diff --git a/.github/workflows/workflow-edits.yml b/.github/workflows/workflow-edits.yml new file mode 100644 index 000000000..13e369566 --- /dev/null +++ b/.github/workflows/workflow-edits.yml @@ -0,0 +1,34 @@ +# NOTE: we need this as a security measure so PRs from forks cannot submit a PR and +# modify this file. The only way for this test to pass is to add the +# 'allow-workflow-edits' label and push again. +name: workflow-edited + + +# note that this triggers on 'pull_request_target' instead of 'pull_request'. +# https://docs.github.com/en/actions/using-workflows/events-that-trigger-workflows#pull_request_target +# https://docs.boostsecurity.io/rules/cicd-gha-risky-pull-request-target-usage.html +on: [pull_request_target] + +jobs: + workflow-edited: + runs-on: ubuntu-latest + if: ${{ !contains(github.event.pull_request.labels.*.name, 'allow-workflow-edits') }} + + steps: + - name: Checkout + uses: actions/checkout@v3 + with: + ref: ${{ github.event.pull_request.head.sha }} + + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v2 + with: + python-version: '3.10' + + - name: Check + id: check + run: | + BRANCH_NAME=master + git fetch --depth=1 origin $BRANCH_NAME:$BRANCH_NAME + git diff --exit-code "$BRANCH_NAME" .github/workflows/ + diff --git a/.gitignore b/.gitignore index e025587de..ab57ca2b9 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,32 @@ +# db connection files +*.ini + +# profiling data +*.lprof + +.virtual_documents +.DS_Store +*.jsonl +*.json +*.db +*.csv +*.parquet +env.sh +**/.ipynb_checkpoints +.vscode +doc/_build +doc/**/*.csv +doc/**/*.db +doc/**/*.sql +doc/**/*.py +examples/*.csv +examples/*.db +examples/*.sql +# temp testing assets +src/tests/tmp + +scripts/large-table.sql + *.py[cod] # C extensions @@ -38,3 +67,10 @@ nosetests.xml /.idea .venv + + +# Do not include test output of matplotlib +result_images + +# Ignore Github codespace build artifact +oryx-build-commands.txt diff --git a/.readthedocs.yml b/.readthedocs.yml new file mode 100644 index 000000000..a6e6ac719 --- /dev/null +++ b/.readthedocs.yml @@ -0,0 +1,23 @@ +version: 2 + +build: + os: ubuntu-22.04 + tools: + python: mambaforge-4.10 + + jobs: + # download latest version from S3 to leverage notebook cache + pre_build: + - 'mkdir -p $HOME/.ploomber/stats/' + - 'echo "version_check_enabled: false" >> $HOME/.ploomber/stats/config.yaml' + # upload to S3 + post_build: + - conda env export --no-build > environment.lock.yml + - cat environment.lock.yml + +conda: + environment: doc/environment.lock.yml + +sphinx: + builder: html + fail_on_warning: true diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 000000000..8e532cb15 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,534 @@ +# CHANGELOG + +## 0.10.18dev + +## 0.10.17 (2025-01-08) + +* [Feature] Disable full stack trace when using spark connect ([#1011](https://github.com/ploomber/jupysql/issues/1011)) (by [@b1ackout](https://github.com/b1ackout)) + +## 0.10.16 (2024-11-07) + +* [Fix] Updates docs for querying data frames when using DuckDB SQLAlchemy connections +* [Fix] Support for scanning data frames when using native DuckDB connections due to changes in DuckDB's API + +## 0.10.15 (2024-11-05) + +*Drops compatibility with Python 3.8* + +* [Fix] Compatibility with `prettytable>=3.12.0` + +## 0.10.14 (2024-09-18) + +* [Feature] Removes telemetry + +## 0.10.13 (2024-09-12) + +* [Feature] `ploomber-extension` is no longer a dependency + +## 0.10.12 (2024-07-12) + +* [Feature] Remove sqlalchemy upper bound ([#1020](https://github.com/ploomber/jupysql/pull/1020)) + +## 0.10.11 (2024-07-03) + +* [Fix] Fix error when connections.ini contains a `query` value as dictionary ([#1015](https://github.com/ploomber/jupysql/issues/1015)) + +## 0.10.10 (2024-02-07) + +* [Feature] Adds `ploomber-extension` as a dependency + +## 0.10.9 (2024-01-31) + +* [Feature] Add option to disable named parameters; options now changed to: `warn`, `enabled`, and `disabled` - fixes ([#971](https://github.com/ploomber/jupysql/issues/971)) and ([#972](https://github.com/ploomber/jupysql/issues/972)) +* [Fix] Fix error when fuzzy matching configuration file (now we only match config keys) ([#975](https://github.com/ploomber/jupysql/issues/975) by [@maciejb](https://github.com/maciejb)) +* [Fix] Fix error that caused JupySQL to read a config file even when there was no JupySQL config ([#975](https://github.com/ploomber/jupysql/issues/975) by [@maciejb](https://github.com/maciejb)) + +## 0.10.8 (2024-01-25) + +* [Feature] Add support for parametrizing string type arguments of `%%sql`, `%sqlplot`, `%sqlcmd`' ([#699](https://github.com/ploomber/jupysql/issues/699)) +* [Fix] Fix edge case where `select` and other SQL keywords were not properly used to find where the user's query started, causing argument parsing issues ([#973](https://github.com/ploomber/jupysql/issues/973)) + +## 0.10.7 (2023-12-23) + +* [Feature] Add Spark Connection as a dialect for Jupysql ([#965](https://github.com/ploomber/jupysql/issues/965)) (by [@gilandose](https://github.com/gilandose)) + +## 0.10.6 (2023-12-21) + +* [Fix] Fix error when `%sql` includes a query with negative numbers ([#958](https://github.com/ploomber/jupysql/issues/958)) + +## 0.10.5 (2023-12-11) + +* [Fix] Look into `~/.jupysql/config` for config if pyproject.toml does not have a SqlMagic section ([#911](https://github.com/ploomber/jupysql/issues/911)) +* [Fix] Update to be compatible with DuckDB v0.9.0 ([#897](https://github.com/ploomber/jupysql/issues/897)) and Pandas 2.1.0 ([#890](https://github.com/ploomber/jupysql/issues/890)) +* [Fix] Pins `sqlplot<20.0.0` + +## 0.10.4 (2023-11-28) + +* [Feature] Allow user to specify the schema when saving dataframes using `--persist` ([#945](https://github.com/ploomber/jupysql/issues/945)) +* [Fix] Fix bug causing empty result on SQL with trailing semicolon and comment ([#907](https://github.com/ploomber/jupysql/issues/907)) +* [Fix] Fix bug %sql not parsing JSON arrow operators correctly ([#918](https://github.com/ploomber/jupysql/issues/918)) +* [Fix] Fixed bug that returns empty results when exception is raised from DB driver +* [Fix] Added guards to check and raise errors when arguments are entered twice in %sql, %sqlcmd and %sqlplot ([#806](https://github.com/ploomber/jupysql/issues/806)) +* [Fix] Fixed bug that returns snippet typo error message when another table is misspelled ([#940](https://github.com/ploomber/jupysql/issues/940)) +* [Doc] Use Oracle Database Free for Oracle Database Quick Start tutorial ([#943](https://github.com/ploomber/jupysql/issues/943)) + +## 0.10.3 (2023-11-06) + +* [Feature] Allow user-level config using ~/.jupysql/config ([#880](https://github.com/ploomber/jupysql/issues/880)) +* [Fix] Remove force deleted snippets from dependent snippet's `with` ([#717](https://github.com/ploomber/jupysql/issues/717)) +* [Fix] Comments added in SQL query to be stripped before saved as snippet ([#886](https://github.com/ploomber/jupysql/issues/886)) +* [Fix] Fixed bug passing :NUMBER while string slicing in query ([#901](https://github.com/ploomber/jupysql/issues/901)) +* [Fix] Fixed bug that showed wrong error when querying snippet with invalid function ([#902](https://github.com/ploomber/jupysql/issues/902)) +* [Fix] Disabled CTE generation when snippets are detected in a non-SELECT type query. ([#651](https://github.com/ploomber/jupysql/issues/651), [#652](https://github.com/ploomber/jupysql/issues/652)) +* [Fix] Fix empty result in certain duckdb `SELECT` and `SUMMARIZE` queries with leading comments ([#892](https://github.com/ploomber/jupysql/issues/892)) +* [Fix] Fix incorrect conversion to Pandas/Polars dataframe for PIVOT statement results and InvalidInputException in PIVOT subqueries ([#917](https://github.com/ploomber/jupysql/issues/917)) +* [Doc] Added `run_statements` to the Python API docs ([#922](https://github.com/ploomber/jupysql/issues/922)) + +## 0.10.2 (2023-09-22) + +* [Feature] Improved messages when loading configurations from `pyproject.toml` file. +* [Feature] Add `--schema/-s` for `%sqlcmd` commands that support `--table/-t` and ensure `--table schema.table` works ([#519](https://github.com/ploomber/jupysql/issues/519)) +* [Feature] Add `schema/-s` for `%sqlplot` and ensure `--table schema.table` works ([#854](https://github.com/ploomber/jupysql/issues/854)) +* [Feature] Expose link in feedback when it is shown in a terminal ([#846](https://github.com/ploomber/jupysql/issues/846)) +* [Feature] Show feedback when starting a new connection ([#807](https://github.com/ploomber/jupysql/issues/807)) +* [Feature] `jupysql-plugin` is now bundled with `jupysql` by default +* [Fix] Fix result not displayed when `SUMMARIZE` argument is used in duckdb with a sqlalchemy connection ([#836](https://github.com/ploomber/jupysql/issues/836)) +* [Fix] Show deprecation warnings for legacy plot API ([#513](https://github.com/ploomber/jupysql/issues/513)) +* [Fix] Fix error when trying to access previously non-existing file ([#840](https://github.com/ploomber/jupysql/issues/840)) +* [Fix] Testing with latest DuckDB version ([#498](https://github.com/ploomber/jupysql/issues/498)) +* [Fix] Remove duplicate integration tests ([#827](https://github.com/ploomber/jupysql/issues/827)) +* [Doc] Fixed typo in the `./doc/integrations/postgres-connect.ipynb` file (Line 180) ([#845](https://github.com/ploomber/jupysql/issues/845)) +* [Doc] Add chDB integration tutorial +* [Doc] Clarify the use of `pyproject.toml` and `connections.ini` in documentations ([#850](https://github.com/ploomber/jupysql/issues/850)) +* [Doc] Update documentation to use `{{variable}}` instead of `string.Template` and remove `--with` since it's optional ([#838](https://github.com/ploomber/jupysql/issues/838)) + +## 0.10.1 (2023-08-30) + +* [Feature] Automatically connect if the `dsn_filename` (defaults to `~/.jupysql/connections.ini`) contains a `default` section +* [Feature] Add `%sqlcmd connect` to see existing connections and create new ones ([#632](https://github.com/ploomber/jupysql/issues/632)) +* [Fix] Clearer error messages when failing to initialize a connection +* [Fix] Improve error when passing a non-identifier to start a connection ([#764](https://github.com/ploomber/jupysql/issues/764)) +* [Fix] Display a warning (instead of raising an error) if the `default` connection in the `.ini` file cannot start +* [Fix] Display a message instead of an error when `toml` isn't installed and `pyproject.toml` is found ([#825](https://github.com/ploomber/jupysql/issues/825)) +* [Fix] Fix argument parsing error on Windows when it contains quotations ([#425](https://github.com/ploomber/jupysql/issues/425)) +* [Fix] Fix error when a linebreak is included during nonidentifier validation process +* [Fix] Fix error when an argument ending with semicolon is passed to `%sql/%%sql` ([#842](https://github.com/ploomber/jupysql/issues/842)) +* [Doc] Added section on installing database drivers + +## 0.10.0 (2023-08-19) + +* [API Change] `%config SqlMagic.feedback` now takes values `0` (disabled), `1` (normal), `2` (verbose) +* [API Change] When loading connections from a `.ini` file via `%sql --section section_name`, the section name is set as the connection alias +* [API Change] Starting connections from a `.ini` file via `%sql [section_name]` has been deprecated +* [API Change] `%config SqlMagic.dsn_filename` default value changed from `odbc.ini` to `~/.jupysql/connections.ini` +* [Feature] Add `--binwidth/-W` to ggplot histogram for specifying binwidth ([#784](https://github.com/ploomber/jupysql/issues/784)) +* [Feature] Add `%sqlcmd profile` support for DBAPI connections ([#743](https://github.com/ploomber/jupysql/issues/743)) +* [Fix] Perform `ROLLBACK` when SQLAlchemy raises `PendingRollbackError` +* [Fix] Perform `ROLLBACK` when `psycopg2` raises `current transaction is aborted, commands ignored until end of transaction block` +* [Fix] Perform `ROLLBACK` when `psycopg2` raises `server closed the connection unexpectedly` ([#677](https://github.com/ploomber/jupysql/issues/677)) +* [Fix] Fix a bug that caused a cell with a CTE to fail if it referenced a table/view with the same name as an existing snippet ([#753](https://github.com/ploomber/jupysql/issues/753)) +* [Fix] Shorter `displaylimit` footer +* [Fix] `ResultSet` footer only displayed when `feedback=2` +* [Fix] Current connection and switching connections message only displayed when `feedback>=1` +* [Fix] `--persist/--persist-replace` perform `ROLLBACK` automatically when needed +* [Fix] `ResultSet` footer (when `displaylimit` truncates results and when showing how to convert to a data frame) now appears in the `ResultSet` plain text representation ([#682](https://github.com/ploomber/jupysql/issues/682)) +* [Fix] Improve error when calling `%sqlcmd` ([#761](https://github.com/ploomber/jupysql/issues/761)) +* [Fix] Fix count statement's result not displayed when `displaylimit=None` ([#801](https://github.com/ploomber/jupysql/issues/801)) +* [Fix] Fix an error that caused a connection error message to be turned into a `print` statement +* [Fix] Fix Twice message printing when switching to the current connection ([#772](https://github.com/ploomber/jupysql/issues/772)) +* [Fix] Error when using %sqlplot in snowflake ([#697](https://github.com/ploomber/jupysql/issues/697)) +* [Doc] Fixes documentation inaccuracy that said `:variable` was deprecated (we brought it back in `0.9.0`) +* [Fix] Descriptive error messages when specific syntax error occurs when running query in DuckDB or Oracle. + +## 0.9.1 (2023-08-10) + +* [Feature] Added `--breaks/-B` to ggplot histogram for specifying breaks ([#719](https://github.com/ploomber/jupysql/issues/719)) +* [Feature] Adds Redshift support for `%sqlplot boxplot` +* [Fix] Fix boxplot for duckdb native ([#728](https://github.com/ploomber/jupysql/issues/728)) +* [Fix] Fix error when using SQL Server with pyodbc that caused queries to fail due to multiple open result sets +* [Fix] Improves performance when converting DuckDB results to `pandas.DataFrame` +* [Fix] Fixes a bug when converting a CTE stored with `--save` into a `pandas.DataFrame` via `.DataFrame()` +* [Doc] Add Redshift tutorial + +## 0.9.0 (2023-08-01) + +* [Feature] Allow loading configuration value from a `pyproject.toml` file upon magic initialization ([#689](https://github.com/ploomber/jupysql/issues/689)) +* [Feature] Adds `with_` to `{SQLAlchemyConnection, DBAPIConnection}.raw_execute` to resolve CTEs +* [Feature] allows parametrizing queries with `:variable` with `%config SqlMagic.named_parameters = True` +* [Fix] Fix error that was incorrectly converted into a print message +* [Fix] Modified histogram query to ensure histogram binning is done correctly ([#751](https://github.com/ploomber/jupysql/issues/751)) +* [Fix] Fix bug that caused the `COMMIT` not to work when the SQLAlchemy driver did not support `set_isolation_level` +* [Fix] Fixed vertical color breaks in histograms ([#702](https://github.com/ploomber/jupysql/issues/702)) +* [Fix] Showing feedback when switching connections ([#727](https://github.com/ploomber/jupysql/issues/727)) +* [Fix] Fix error that caused some connections not to be closed when calling `--close/-x` +* [Fix] Fix bug that caused the query transpilation process to fail when passing multiple statements +* [Fix] Fixes error when creating tables and querying them in the same cell when using DuckDB + SQLAlchemy ([#674](https://github.com/ploomber/jupysql/issues/674)) +* [Fix] Using native methods to convert to data frames from DuckDB when using native connections and SQLAlchemy +* [Fix] Fix error that caused literals like `':something'` to be interpreted as query parameters + +## 0.8.0 (2023-07-18) + +* [Feature] Modified `TableDescription` to add styling, generate messages and format the calculated outputs ([#459](https://github.com/ploomber/jupysql/issues/459)) +* [Feature] Support flexible spacing `myvar=<<` operator ([#525](https://github.com/ploomber/jupysql/issues/525)) +* [Feature] Added a line under `ResultSet` to distinguish it from data frame and error message when invalid operations are performed ([#468](https://github.com/ploomber/jupysql/issues/468)) +* [Feature] Moved `%sqlrender` feature to `%sqlcmd snippets` ([#647](https://github.com/ploomber/jupysql/issues/647)) +* [Feature] Added tables listing stored snippets when `%sqlcmd snippets` is called ([#648](https://github.com/ploomber/jupysql/issues/648)) +* [Feature] Better performance when using DuckDB native connection and converting to `pandas.DataFrame` or `polars.DataFrame` +* [Fix] Fixed CI issue by updating `invalid_connection_string_duckdb` in `test_magic.py` ([#631](https://github.com/ploomber/jupysql/issues/631)) +* [Fix] Refactored `ResultSet` to lazy loading ([#470](https://github.com/ploomber/jupysql/issues/470)) +* [Fix] Removed `WITH` when a snippet does not have a dependency ([#657](https://github.com/ploomber/jupysql/issues/657)) +* [Fix] Used display module when generating CTE ([#649](https://github.com/ploomber/jupysql/issues/649)) +* [Fix] Adding `--with` back because of issues with sqlglot query parser ([#684](https://github.com/ploomber/jupysql/issues/684)) +* [Fix] Improving `<<` parsing logic ([#610](https://github.com/ploomber/jupysql/issues/610)) +* [Fix] Migrate user feedback to use display module ([#548](https://github.com/ploomber/jupysql/issues/548)) +* [Doc] Modified integrations content to ensure they're all consistent ([#523](https://github.com/ploomber/jupysql/issues/523)) +* [Doc] Document `--persist-replace` in API section ([#539](https://github.com/ploomber/jupysql/issues/539)) +* [Doc] Re-organized sections. Adds section showing how to share notebooks via Ploomber Cloud + +## 0.7.9 (2023-06-19) + +* [Feature] Modified `histogram` command to support data with NULL values ([#176](https://github.com/ploomber/jupysql/issues/176)) +* [Feature] Automated dependency inference when creating CTEs. `--with` is now deprecated and will display a warning. ([#166](https://github.com/ploomber/jupysql/issues/166)) +* [Feature] Close all connections when Python shuts down ([#563](https://github.com/ploomber/jupysql/issues/563)) +* [Fix] Fixed `ResultSet` class to display result table with proper style and added relevant example ([#54](https://github.com/ploomber/jupysql/issues/54)) +* [Fix] Fixed `Set` method in `Connection` class to recognize same descriptor with different aliases ([#532](https://github.com/ploomber/jupysql/issues/532)) +* [Fix] Added bottom-padding to the buttons in table explorer. Now they are not hidden by the scrollbar ([#540](https://github.com/ploomber/jupysql/issues/540)) +* [Fix] `psutil` is no longer a dependency for JupySQL ([#541](https://github.com/ploomber/jupysql/issues/541)) +* [Fix] Validating arguments passed to `%%sql` ([#561](https://github.com/ploomber/jupysql/issues/561)) +* [Doc] Added bar and pie examples in the plotting section ([#564](https://github.com/ploomber/jupysql/issues/564)) +* [Doc] Added more details to the SQL parametrization user guide. ([#288](https://github.com/ploomber/jupysql/issues/288)) +* [Doc] Snowflake integration guide ([#384](https://github.com/ploomber/jupysql/issues/384)) +* [Doc] User guide on using JupySQL in `.py` scripts ([#449](https://github.com/ploomber/jupysql/issues/449)) +* [Doc] Added `%magic?` to APIs and quickstart ([#97](https://github.com/ploomber/jupysql/issues/97)) + +## 0.7.8 (2023-06-01) + +* [Feature] Add `%sqlplot bar` and `%sqlplot pie` ([#508](https://github.com/ploomber/jupysql/issues/508)) + +## 0.7.7 (2023-05-31) + +* [Feature] Clearer message display when executing queries, listing connections and persisting data frames ([#432](https://github.com/ploomber/jupysql/issues/432)) +* [Feature] `%sql --connections` now displays an HTML table in Jupyter and a text-based table in the terminal +* [Fix] Fix CTE generation when the snippets have trailing semicolons +* [Doc] Hiding connection string when passing `--alias` when opening a connection ([#432](https://github.com/ploomber/jupysql/issues/432)) +* [Doc] Fix `api/magic-sql.md` since it incorrectly stated that listing functions was `--list`, but it's `--connections` ([#432](https://github.com/ploomber/jupysql/issues/432)) +* [Doc] Added Howto documentation for enabling JupyterLab cell runtime display ([#448](https://github.com/ploomber/jupysql/issues/448)) + +## 0.7.6 (2023-05-29) + +* [Feature] Add `%sqlcmd explore` to explore tables interactively ([#330](https://github.com/ploomber/jupysql/issues/330)) + +* [Feature] Support for printing capture variables using `=<<` syntax (by [@jorisroovers](https://github.com/jorisroovers)) + +* [Feature] Adds `--persist-replace` argument to replace existing tables when persisting data frames ([#440](https://github.com/ploomber/jupysql/issues/440)) + +* [Fix] Fix error when checking if custom connection was PEP 249 Compliant ([#517](https://github.com/ploomber/jupysql/issues/517)) + +* [Doc] documenting how to manage connections with `Connection` object ([#282](https://github.com/ploomber/jupysql/issues/282)) + +* [Feature] Github Codespace (Devcontainer) support for development (by [@jorisroovers](https://github.com/jorisroovers)) ([#484](https://github.com/ploomber/jupysql/issues/484)) + +* [Feature] Added bar plot and pie charts to %sqlplot ([#417](https://github.com/ploomber/jupysql/issues/417)) + +## 0.7.5 (2023-05-24) + +* [Feature] Using native DuckDB `.df()` method when using `autopandas` +* [Feature] Better error messages when function used in plotting API unsupported by DB driver ([#159](https://github.com/ploomber/jupysql/issues/159)) +* [Feature] Detailed error messages when syntax error in SQL query, postgres connection password missing or inaccessible, invalid DuckDB connection string ([#229](https://github.com/ploomber/jupysql/issues/229)) +* [Fix] Fix the default value of %config SqlMagic.displaylimit to 10 ([#462](https://github.com/ploomber/jupysql/issues/462)) +* [Doc] documenting `%sqlcmd tables`/`%sqlcmd columns` + +## 0.7.4 (2023-04-28) + +No changes + +## 0.7.3 (2023-04-28) + +Never deployed due to a CI error + +* [Fix] Fixing ipython version to 8.12.0 on python 3.8 +* [Fix] Fix `--alias` when passing an existing engine +* [Doc] Tutorial on querying excel files with pandas and jupysql ([#423](https://github.com/ploomber/jupysql/pull/423)) + +## 0.7.2 (2023-04-25) + +* [Feature] Support for DB API 2.0 drivers ([#350](https://github.com/ploomber/jupysql/issues/350)) +* [Feature] Improve boxplot performance ([#152](https://github.com/ploomber/jupysql/issues/152)) +* [Feature] Add sticky first column styling to sqlcmd profile command +* [Fix] Updates errors so only the error message is displayed (and traceback is hidden) ([#407](https://github.com/ploomber/jupysql/issues/407)) +* [Fix] Fixes `%sqlcmd plot` when `--table` or `--column` have spaces ([#409](https://github.com/ploomber/jupysql/issues/409)) +* [Doc] Add QuestDB tutorial ([#350](https://github.com/ploomber/jupysql/issues/350)) + +## 0.7.1 (2023-04-19) + +* [Feature] Upgrades SQLAlchemy version to 2 +* [Fix] Fix `%sqlcmd columns` in MySQL and MariaDB +* [Fix] `%sqlcmd --test` improved, changes in logic and addition of user guide ([#275](https://github.com/ploomber/jupysql/issues/275)) +* [Doc] Algolia search added ([#64](https://github.com/ploomber/jupysql/issues/64)) +* [Doc] Updating connecting guide (by [@DaveOkpare](https://github.com/DaveOkpare)) ([#56](https://github.com/ploomber/jupysql/issues/56)) + +## 0.7.0 (2023-04-05) + +JupySQL is now available via `conda install jupysql -c conda-forge`. Thanks, [@sterlinm](https://github.com/sterlinm)! + +* [API Change] Deprecates old SQL parametrization: `$var`, `:var`, and `{var}` in favor of `{{var}}` +* [Feature] Adds `%sqlcmd profile` ([#66](https://github.com/ploomber/jupysql/issues/66)) +* [Feature] Adds `%sqlcmd test` to run tests on tables +* [Feature] Adds `--interact` argument to `%%sql` to enable interactivity in parametrized SQL queries ([#293](https://github.com/ploomber/jupysql/issues/293)) +* [Feature] Results parse HTTP URLs to make them clickable ([#230](https://github.com/ploomber/jupysql/issues/230)) +* [Feature] Adds `ggplot` plotting API (histogram and boxplot) +* [Feature] Adds `%%config SqlMagic.polars_dataframe_kwargs = {...}` (by [@jorisroovers](https://github.com/jorisroovers)) +* [Feature] Adding `sqlglot` to better support SQL dialects in some internal SQL queries +* [Fix] Clearer error when using bad table/schema name with `%sqlcmd` and `%sqlplot` ([#155](https://github.com/ploomber/jupysql/issues/155)) +* [Fix] Fix `%sqlcmd` exception handling ([#262](https://github.com/ploomber/jupysql/issues/262)) +* [Fix] `--save` + `--with` double quotes syntax error in MySQL ([#145](https://github.com/ploomber/jupysql/issues/145)) +* [Fix] Clearer error when using `--with` with snippets that do not exist ([#257](https://github.com/ploomber/jupysql/issues/257)) +* [Fix] Pytds now automatically compatible +* [Fix] Jupysql with autopolars crashes when schema cannot be inferred from the first 100 rows (by [@jorisroovers](https://github.com/jorisroovers)) ([#312](https://github.com/ploomber/jupysql/issues/312)) +* [Fix] Fix problem where a `%name` in a query (even if commented) would be interpreted as a query parameter ([#362](https://github.com/ploomber/jupysql/issues/362)) +* [Fix] Better support for MySQL and MariaDB (generating internal SQL queries with backticks instead of double quotes) +* [Doc] Tutorial on ETLs via Jupysql and Github actions +* [Doc] SQL keywords autocompletion +* [Doc] Included schema and dataspec into `%sqlrender` API reference + +## 0.6.6 (2023-03-16) + +* [Fix] Pinning SQLAlchemy 1.x + +## 0.6.5 (2023-03-15) + +* [Feature] Displaying warning when passing a identifier with hyphens to `--save` or `--with` +* [Fix] Addresses enable AUTOCOMMIT config issue in PostgreSQL ([#90](https://github.com/ploomber/jupysql/issues/90)) +* [Doc] User guide on querying Github API with DuckDB and JupySQL + +## 0.6.4 (2023-03-12) + +**Note:** This release has been yanked due to an error when using it with SQLAlchemy 2 + +* [Fix] Adds support for SQL Alchemy 2.0 +* [Doc] Summary section on jupysql vs ipython-sql + +## 0.6.3 (2023-03-06) + +* [Fix] Displaying variable substitution warning only when the variable to expand exists in the user's namespace + +## 0.6.2 (2023-03-05) + +* [Fix] Deprecation warning incorrectly displayed [#213](https://github.com/ploomber/jupysql/issues/213) + +## 0.6.1 (2023-03-02) + +* [Feature] Support new variable substitution using `{{variable}}` format ([#137](https://github.com/ploomber/jupysql/pull/137)) +* [Fix] Adds support for newer versions of prettytable + +## 0.6.0 (2023-02-27) + +* [API Change] Drops support for old versions of IPython (removed imports from `IPython.utils.traitlets`) +* [Feature] Adds `%%config SqlMagic.autopolars = True` ([#138](https://github.com/ploomber/jupysql/issues/138)) + +## 0.5.6 (2023-02-16) + +* [Feature] Shows missing driver package suggestion message ([#124](https://github.com/ploomber/jupysql/issues/124)) + +## 0.5.5 (2023-02-08) + +* [Fix] Clearer error message on connection failure ([#120](https://github.com/ploomber/jupysql/issues/120)) +* [Doc] Adds tutorial on querying JSON data + +## 0.5.4 (2023-02-06) + +* [Feature] Adds `%jupysql`/`%%jupysql` as alias for `%sql`/`%%sql` +* [Fix] Adds community link to `ValueError` and `TypeError` + +## 0.5.3 (2023-01-31) + +* [Feature] Adds `%sqlcmd tables` ([#76](https://github.com/ploomber/jupysql/issues/76)) +* [Feature] Adds `%sqlcmd columns` ([#76](https://github.com/ploomber/jupysql/issues/76)) +* [Fix] `setup.py` fix due to change in setuptools 67.0.0 + +## 0.5.2 (2023-01-03) + +* Adds example for connecting to a SQLite database with spaces ([#35](https://github.com/ploomber/jupysql/issues/35)) +* Documents how to securely pass credentials ([#40](https://github.com/ploomber/jupysql/issues/40)) +* Adds `-a/--alias` option to name connections for easier management ([#59](https://github.com/ploomber/jupysql/issues/59)) +* Adds `%sqlplot` for plotting histograms and boxplots +* Adds missing documentation for the Python API +* Several improvements to the `sql.plot` module +* Removes `six` as dependency (drops Python 2 support) + +## 0.5.1 (2022-12-26) + +* Allow to connect to databases with an existing `sqlalchemy.engine.Engine` object + +## 0.5 (2022-12-24) + +* `ResultSet.plot()`, `ResultSet.bar()`, and `ResultSet.pie()` return `matplotlib.Axes` objects + +## 0.4.7 (2022-12-23) + +* Assigns a variable without displaying an output message ([#13](https://github.com/ploomber/jupysql/issues/13)) + +## 0.4.6 (2022-08-30) + +* Updates telemetry key + +## 0.4.5 (2022-08-13) + +* Adds anonymous telemetry + +## 0.4.4 (2022-08-06) + +* Adds `plot` module (boxplot and histogram) + +## 0.4.3 (2022-08-04) + +* Adds `--save`, `--with`, and `%sqlrender` for SQL composition ([#1](https://github.com/ploomber/jupysql/issues/1)) + +## 0.4.2 (2022-07-26) + +*First version release by Ploomber* + +* Adds `--no-index` option to `--persist` data frames without the index + +## 0.4.1 + +* Fixed .rst file location in MANIFEST.in +* Parse SQL comments in first line +* Bugfixes for DSN, `--close`, others + +## 0.4.0 + +* Changed most non-SQL commands to argparse arguments (thanks pik) +* User can specify a creator for connections (thanks pik) +* Bogus pseudo-SQL command `PERSIST` removed, replaced with `--persist` arg +* Turn off echo of connection information with `displaycon` in config +* Consistent support for {} variables (thanks Lucas) + +## 0.3.9 + +* Restored Python 2 compatibility (thanks tokenmathguy) +* Fix truth value of DataFrame error (thanks michael-erasmus) +* `<<` operator (thanks xiaochuanyu) +* added README example (thanks tanhuil) +* bugfix in executing column_local_vars (thanks tebeka) +* pgspecial installation optional (thanks jstoebel and arjoe) +* conceal passwords in connection strings (thanks jstoebel) + +## 0.3.8 + +* Stop warnings for deprecated use of IPython 3 traitlets in IPython 4 (thanks graphaelli; also stonebig, aebrahim, mccahill) +* README update for keeping connection info private, from eshilts + +## 0.3.7.1 + +* Avoid "connection busy" error for SQL Server (thanks Andrés Celis) + +## 0.3.7 + +* New `column_local_vars` config option submitted by darikg +* Avoid contaminating user namespace from locals (thanks alope107) + +## 0.3.6 + +* Fixed issue number 30, commit failures for sqlite (thanks stonebig, jandot) + +## 0.3.5 + +* Indentations visible in HTML cells +* COMMIT each SQL statement immediately - prevent locks + +## 0.3.4 + +* PERSIST pseudo-SQL command added + +## 0.3.3 + +* Python 3 compatibility restored +* DSN access supported (thanks Berton Earnshaw) + +## 0.3.2 + +* `.csv(filename=None)` method added to result sets + +## 0.3.1 + +* Reporting of number of rows affected configurable with `feedback` + +* Local variables usable as SQL bind variables + +## 0.3.0 + +*Release date: 13-Oct-2013* + +* displaylimit config parameter +* reports number of rows affected by each query +* test suite working again +* dict-style access for result sets by primary key + +## 0.2.3 + +*Release date: 20-Sep-2013* + +* Contributions from Olivier Le Thanh Duong: + + - SQL errors reported without internal IPython error stack + + - Proper handling of configuration + + +* Added .DataFrame(), .pie(), .plot(), and .bar() methods to + result sets + +## 0.2.2.1 + +*Release date: 01-Aug-2013* + +Deleted Plugin import left behind in 0.2.2 + +## 0.2.2 + +*Release date: 30-July-2013* + +Converted from an IPython Plugin to an Extension for 1.0 compatibility + +## 0.2.1 + +*Release date: 15-June-2013* + +* Recognize socket connection strings + +* Bugfix - issue 4 (remember existing connections by case) + +## 0.2.0 + +*Release date: 30-May-2013* + +* Accept bind variables (Thanks Mike Wilson!) + +## 0.1.2 + +*Release date: 29-Mar-2013* + +* Python 3 compatibility + +* use prettyprint package + +* allow multiple SQL per cell + +## 0.1.1 + +*Release date: 29-Mar-2013* + +* Release to PyPI + +* Results returned as lists + +* print(_) to get table form in text console + +* set autolimit and text wrap in configuration + +## 0.1 + +*Release date: 21-Mar-2013* + +* Initial release diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 000000000..39dc2437c --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,7 @@ +# Contributing + +For general information, see [Ploombers' contributing guidelines.](https://ploomber-contributing.readthedocs.io) + + +For specific JupySQL contributing guidelines, see the [Developer guide](https://jupysql.ploomber.io/en/latest/community/developer-guide.html). + diff --git a/HACKING.txt b/HACKING.txt deleted file mode 100644 index e6bb1403c..000000000 --- a/HACKING.txt +++ /dev/null @@ -1,27 +0,0 @@ -Development setup -================= - -Running nose tests with IPython is tricky, so there's a -run_tests.sh script for it. - - pip install -e . - ./run_tests.sh - -To temporarily insert breakpoints for debugging: `from nose.tools import set_trace; set_trace()`. -Or, if running tests, use `pytest.set_trace()`. - -Tests have requirements not installed by setup.py: - -- nose -- pandas - -Release HOWTO -============= - -To make a release, - - 1) Update release date/version in NEWS.txt and setup.py - 2) Run 'python setup.py sdist' - 3) Test the generated source distribution in dist/ - 4) Upload to PyPI: 'python setup.py sdist register upload' - 5) Increase version in setup.py (for next release) diff --git a/LICENSE b/LICENSE index fa5629966..6bc81fd8e 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,210 @@ -MIT License + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + +1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + +2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + +3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + +4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + +5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + +6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + +7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + +8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + +9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + +END OF TERMS AND CONDITIONS + +APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + +Copyright 2022-Present Ploomber Inc. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. + + +MIT License (ORIGINAL) Copyright (c) 2014 Catherine Devlin +Copyright 2022-Present Ploomber Inc. Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/MANIFEST.in b/MANIFEST.in index c27afb39e..f3f9ebf92 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1,3 +1,5 @@ include README.rst include NEWS.rst include LICENSE +include src/sql/widgets/table_widget/css/* +include src/sql/widgets/table_widget/js/* \ No newline at end of file diff --git a/NEWS.rst b/NEWS.rst deleted file mode 100644 index dce9388c2..000000000 --- a/NEWS.rst +++ /dev/null @@ -1,173 +0,0 @@ -News ----- - -0.1 -~~~ - -*Release date: 21-Mar-2013* - -* Initial release - -0.1.1 -~~~~~ - -*Release date: 29-Mar-2013* - -* Release to PyPI - -* Results returned as lists - -* print(_) to get table form in text console - -* set autolimit and text wrap in configuration - - -0.1.2 -~~~~~ - -*Release date: 29-Mar-2013* - -* Python 3 compatibility - -* use prettyprint package - -* allow multiple SQL per cell - -0.2.0 -~~~~~ - -*Release date: 30-May-2013* - -* Accept bind variables (Thanks Mike Wilson!) - -0.2.1 -~~~~~ - -*Release date: 15-June-2013* - -* Recognize socket connection strings - -* Bugfix - issue 4 (remember existing connections by case) - -0.2.2 -~~~~~ - -*Release date: 30-July-2013* - -Converted from an IPython Plugin to an Extension for 1.0 compatibility - -0.2.2.1 -~~~~~~~ - -*Release date: 01-Aug-2013* - -Deleted Plugin import left behind in 0.2.2 - -0.2.3 -~~~~~ - -*Release date: 20-Sep-2013* - -* Contributions from Olivier Le Thanh Duong: - - - SQL errors reported without internal IPython error stack - - - Proper handling of configuration - -* Added .DataFrame(), .pie(), .plot(), and .bar() methods to - result sets - -0.3.0 -~~~~~ - -*Release date: 13-Oct-2013* - -* displaylimit config parameter - -* reports number of rows affected by each query - -* test suite working again - -* dict-style access for result sets by primary key - -0.3.1 -~~~~~ - -* Reporting of number of rows affected configurable with ``feedback`` - -* Local variables usable as SQL bind variables - -0.3.2 -~~~~~ - -* ``.csv(filename=None)`` method added to result sets - -0.3.3 -~~~~~ - -* Python 3 compatibility restored -* DSN access supported (thanks Berton Earnshaw) - -0.3.4 -~~~~~ - -* PERSIST pseudo-SQL command added - -0.3.5 -~~~~~ - -* Indentations visible in HTML cells -* COMMIT each SQL statement immediately - prevent locks - -0.3.6 -~~~~~ - -* Fixed issue #30, commit failures for sqlite (thanks stonebig, jandot) - -0.3.7 -~~~~~ - -* New `column_local_vars` config option submitted by darikg -* Avoid contaminating user namespace from locals (thanks alope107) - -0.3.7.1 -~~~~~~~ - -* Avoid "connection busy" error for SQL Server (thanks Andrés Celis) - -0.3.8 -~~~~~ - -* Stop warnings for deprecated use of IPython 3 traitlets in IPython 4 (thanks graphaelli; also stonebig, aebrahim, mccahill) -* README update for keeping connection info private, from eshilts - -0.3.9 -~~~~~ - -* Fix truth value of DataFrame error (thanks michael-erasmus) -* `<<` operator (thanks xiaochuanyu) -* added README example (thanks tanhuil) -* bugfix in executing column_local_vars (thanks tebeka) -* pgspecial installation optional (thanks jstoebel and arjoe) -* conceal passwords in connection strings (thanks jstoebel) - -0.3.9 -~~~~~ - -* Restored Python 2 compatibility (thanks tokenmathguy) - -0.4.0 -~~~~~ - -* Changed most non-SQL commands to argparse arguments (thanks pik) -* User can specify a creator for connections (thanks pik) -* Bogus pseudo-SQL command `PERSIST` removed, replaced with `--persist` arg -* Turn off echo of connection information with `displaycon` in config -* Consistent support for {} variables (thanks Lucas) - -0.4.1 -~~~~~ - -* Fixed .rst file location in MANIFEST.in -* Parse SQL comments in first line -* Bugfixes for DSN, `--close`, others \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 000000000..d3a478adf --- /dev/null +++ b/README.md @@ -0,0 +1,57 @@ +# JupySQL +![CI](https://github.com/ploomber/jupysql/workflows/CI/badge.svg) +![CI Integration Tests](https://github.com/ploomber/jupysql/actions/workflows/ci-integration-db.yaml/badge.svg) +![Broken Links](https://github.com/ploomber/jupysql/workflows/check-for-broken-links/badge.svg) +[![PyPI version](https://badge.fury.io/py/jupysql.svg)](https://badge.fury.io/py/jupysql) +[![Twitter](https://img.shields.io/twitter/follow/edublancas?label=Follow&style=social)](https://twitter.com/intent/user?screen_name=ploomber) +[![Code style: black](https://img.shields.io/badge/code%20style-black-000000.svg)](https://github.com/psf/black) +[![Downloads](https://static.pepy.tech/badge/jupysql/month)](https://pepy.tech/project/jupysql) + +

+ Join our community + | + Newsletter + | + Contact us + | + Docs + | + Blog + | + Website + | + YouTube +

+ +> [!TIP] +> Deploy Streamlit and Dash apps for free on [Ploomber Cloud!](https://www.platform.ploomber.io/register/?utm_medium=github&utm_source=jupysql) + +Run SQL in Jupyter/IPython via a `%sql` and `%%sql` magics. + +## Features + +- [Pandas integration](https://jupysql.ploomber.io/en/latest/integrations/pandas.html) +- [SQL composition (no more hard-to-debug CTEs!)](https://jupysql.ploomber.io/en/latest/compose.html) +- [Plot massive datasets without blowing up memory](https://jupysql.ploomber.io/en/latest/plot.html) +- [DuckDB integration](https://jupysql.ploomber.io/en/latest/integrations/duckdb.html) + +## Installation + +``` +pip install jupysql +``` + +or: + +``` +conda install jupysql -c conda-forge +``` + +## Documentation + +[Click here to see the documentation.](https://jupysql.ploomber.io) + + +## Credits + +This project is a fork of [ipython-sql](https://github.com/catherinedevlin/ipython-sql); the objective is to turn this project into a full-featured SQL client for Jupyter. We're looking for feedback and taking feature requests, so please [join our community](https://ploomber.io/community) and enter the #jupysql channel. diff --git a/README.rst b/README.rst deleted file mode 100644 index 68f8fafcc..000000000 --- a/README.rst +++ /dev/null @@ -1,432 +0,0 @@ -=========== -ipython-sql -=========== - -:Author: Catherine Devlin, http://catherinedevlin.blogspot.com - -Introduces a %sql (or %%sql) magic. - -Connect to a database, using `SQLAlchemy URL`_ connect strings, then issue SQL -commands within IPython or IPython Notebook. - -.. image:: https://raw.github.com/catherinedevlin/ipython-sql/master/examples/writers.png - :width: 600px - :alt: screenshot of ipython-sql in the Notebook - -Examples --------- - -.. code-block:: python - - In [1]: %load_ext sql - - In [2]: %%sql postgresql://will:longliveliz@localhost/shakes - ...: select * from character - ...: where abbrev = 'ALICE' - ...: - Out[2]: [(u'Alice', u'Alice', u'ALICE', u'a lady attending on Princess Katherine', 22)] - - In [3]: result = _ - - In [4]: print(result) - charid charname abbrev description speechcount - ================================================================================= - Alice Alice ALICE a lady attending on Princess Katherine 22 - - In [4]: result.keys - Out[5]: [u'charid', u'charname', u'abbrev', u'description', u'speechcount'] - - In [6]: result[0][0] - Out[6]: u'Alice' - - In [7]: result[0].description - Out[7]: u'a lady attending on Princess Katherine' - -After the first connection, connect info can be omitted:: - - In [8]: %sql select count(*) from work - Out[8]: [(43L,)] - -Connections to multiple databases can be maintained. You can refer to -an existing connection by username@database - -.. code-block:: python - - In [9]: %%sql will@shakes - ...: select charname, speechcount from character - ...: where speechcount = (select max(speechcount) - ...: from character); - ...: - Out[9]: [(u'Poet', 733)] - - In [10]: print(_) - charname speechcount - ====================== - Poet 733 - -If no connect string is supplied, ``%sql`` will provide a list of existing connections; -however, if no connections have yet been made and the environment variable ``DATABASE_URL`` -is available, that will be used. - -For secure access, you may dynamically access your credentials (e.g. from your system environment or `getpass.getpass`) to avoid storing your password in the notebook itself. Use the `$` before any variable to access it in your `%sql` command. - -.. code-block:: python - - In [11]: user = os.getenv('SOME_USER') - ....: password = os.getenv('SOME_PASSWORD') - ....: connection_string = "postgresql://{user}:{password}@localhost/some_database".format(user=user, password=password) - ....: %sql $connection_string - Out[11]: u'Connected: some_user@some_database' - -You may use multiple SQL statements inside a single cell, but you will -only see any query results from the last of them, so this really only -makes sense for statements with no output - -.. code-block:: python - - In [11]: %%sql sqlite:// - ....: CREATE TABLE writer (first_name, last_name, year_of_death); - ....: INSERT INTO writer VALUES ('William', 'Shakespeare', 1616); - ....: INSERT INTO writer VALUES ('Bertold', 'Brecht', 1956); - ....: - Out[11]: [] - - -As a convenience, dict-style access for result sets is supported, with the -leftmost column serving as key, for unique values. - -.. code-block:: python - - In [12]: result = %sql select * from work - 43 rows affected. - - In [13]: result['richard2'] - Out[14]: (u'richard2', u'Richard II', u'History of Richard II', 1595, u'h', None, u'Moby', 22411, 628) - -Results can also be retrieved as an iterator of dictionaries (``result.dicts()``) -or a single dictionary with a tuple of scalar values per key (``result.dict()``) - -Variable substitution ---------------------- - -Bind variables (bind parameters) can be used in the "named" (:x) style. -The variable names used should be defined in the local namespace. - -.. code-block:: python - - In [15]: name = 'Countess' - - In [16]: %sql select description from character where charname = :name - Out[16]: [(u'mother to Bertram',)] - - In [17]: %sql select description from character where charname = '{name}' - Out[17]: [(u'mother to Bertram',)] - -Alternately, ``$variable_name`` or ``{variable_name}`` can be -used to inject variables from the local namespace into the SQL -statement before it is formed and passed to the SQL engine. -(Using ``$`` and ``{}`` together, as in ``${variable_name}``, -is not supported.) - -Bind variables are passed through to the SQL engine and can only -be used to replace strings passed to SQL. ``$`` and ``{}`` are -substituted before passing to SQL and can be used to form SQL -statements dynamically. - -Assignment ----------- - -Ordinary IPython assignment works for single-line `%sql` queries: - -.. code-block:: python - - In [18]: works = %sql SELECT title, year FROM work - 43 rows affected. - -The `<<` operator captures query results in a local variable, and -can be used in multi-line ``%%sql``: - -.. code-block:: python - - In [19]: %%sql works << SELECT title, year - ...: FROM work - ...: - 43 rows affected. - Returning data to local variable works - -Connecting ----------- - -Connection strings are `SQLAlchemy URL`_ standard. - -Some example connection strings:: - - mysql+pymysql://scott:tiger@localhost/foo - oracle://scott:tiger@127.0.0.1:1521/sidname - sqlite:// - sqlite:///foo.db - mssql+pyodbc://username:password@host/database?driver=SQL+Server+Native+Client+11.0 - -.. _`SQLAlchemy URL`: http://docs.sqlalchemy.org/en/latest/core/engines.html#database-urls - -Note that ``mysql`` and ``mysql+pymysql`` connections (and perhaps others) -don't read your client character set information from .my.cnf. You need -to specify it in the connection string:: - - mysql+pymysql://scott:tiger@localhost/foo?charset=utf8 - -Note that an ``impala`` connection with `impyla`_ for HiveServer2 requires disabling autocommit:: - - %config SqlMagic.autocommit=False - %sql impala://hserverhost:port/default?kerberos_service_name=hive&auth_mechanism=GSSAPI - -.. _impyla: https://github.com/cloudera/impyla - -Connection arguments not whitelisted by SQLALchemy can be provided as -a flag with (-a|--connection_arguments)the connection string as a JSON string. -See `SQLAlchemy Args`_. - - | %sql --connection_arguments {"timeout":10,"mode":"ro"} sqlite:// SELECT * FROM work; - | %sql -a '{"timeout":10, "mode":"ro"}' sqlite:// SELECT * from work; - -.. _`SQLAlchemy Args`: https://docs.sqlalchemy.org/en/13/core/engines.html#custom-dbapi-args - -DSN connections -~~~~~~~~~~~~~~~ - -Alternately, you can store connection info in a -configuration file, under a section name chosen to -refer to your database. - -For example, if dsn.ini contains - - | [DB_CONFIG_1] - | drivername=postgres - | host=my.remote.host - | port=5433 - | database=mydatabase - | username=myuser - | password=1234 - -then you can - - | %config SqlMagic.dsn_filename='./dsn.ini' - | %sql --section DB_CONFIG_1 - -Configuration -------------- - -Query results are loaded as lists, so very large result sets may use up -your system's memory and/or hang your browser. There is no autolimit -by default. However, `autolimit` (if set) limits the size of the result -set (usually with a `LIMIT` clause in the SQL). `displaylimit` is similar, -but the entire result set is still pulled into memory (for later analysis); -only the screen display is truncated. - -.. code-block:: python - - In [2]: %config SqlMagic - SqlMagic options - -------------- - SqlMagic.autocommit= - Current: True - Set autocommit mode - SqlMagic.autolimit= - Current: 0 - Automatically limit the size of the returned result sets - SqlMagic.autopandas= - Current: False - Return Pandas DataFrames instead of regular result sets - SqlMagic.column_local_vars= - Current: False - Return data into local variables from column names - SqlMagic.displaycon= - Current: False - Show connection string after execute - SqlMagic.displaylimit= - Current: None - Automatically limit the number of rows displayed (full result set is still - stored) - SqlMagic.dsn_filename= - Current: 'odbc.ini' - Path to DSN file. When the first argument is of the form [section], a - sqlalchemy connection string is formed from the matching section in the DSN - file. - SqlMagic.feedback= - Current: False - Print number of rows affected by DML - SqlMagic.short_errors= - Current: True - Don't display the full traceback on SQL Programming Error - SqlMagic.style= - Current: 'DEFAULT' - Set the table printing style to any of prettytable's defined styles - (currently DEFAULT, MSWORD_FRIENDLY, PLAIN_COLUMNS, RANDOM) - - In[3]: %config SqlMagic.feedback = False - -Please note: if you have autopandas set to true, the displaylimit option will not apply. You can set the pandas display limit by using the pandas ``max_rows`` option as described in the `pandas documentation `_. - -Pandas ------- - -If you have installed ``pandas``, you can use a result set's -``.DataFrame()`` method - -.. code-block:: python - - In [3]: result = %sql SELECT * FROM character WHERE speechcount > 25 - - In [4]: dataframe = result.DataFrame() - - -The ``--persist`` argument, with the name of a -DataFrame object in memory, -will create a table name -in the database from the named DataFrame. -Or use ``--append`` to add rows to an existing -table by that name. - -.. code-block:: python - - In [5]: %sql --persist dataframe - - In [6]: %sql SELECT * FROM dataframe; - -.. _Pandas: http://pandas.pydata.org/ - -Graphing --------- - -If you have installed ``matplotlib``, you can use a result set's -``.plot()``, ``.pie()``, and ``.bar()`` methods for quick plotting - -.. code-block:: python - - In[5]: result = %sql SELECT title, totalwords FROM work WHERE genretype = 'c' - - In[6]: %matplotlib inline - - In[7]: result.pie() - -.. image:: https://raw.github.com/catherinedevlin/ipython-sql/master/examples/wordcount.png - :alt: pie chart of word count of Shakespeare's comedies - -Dumping -------- - -Result sets come with a ``.csv(filename=None)`` method. This generates -comma-separated text either as a return value (if ``filename`` is not -specified) or in a file of the given name. - -.. code-block:: python - - In[8]: result = %sql SELECT title, totalwords FROM work WHERE genretype = 'c' - - In[9]: result.csv(filename='work.csv') - -PostgreSQL features -------------------- - -``psql``-style "backslash" `meta-commands`_ commands (``\d``, ``\dt``, etc.) -are provided by `PGSpecial`_. Example: - -.. code-block:: python - - In[9]: %sql \d - -.. _PGSpecial: https://pypi.python.org/pypi/pgspecial - -.. _meta-commands: https://www.postgresql.org/docs/9.6/static/app-psql.html#APP-PSQL-META-COMMANDS - - -Options -------- - -``-l`` / ``--connections`` - List all active connections - -``-x`` / ``--close `` - Close named connection - -``-c`` / ``--creator `` - Specify creator function for new connection - -``-s`` / ``--section `` - Section of dsn_file to be used for generating a connection string - -``-p`` / ``--persist`` - Create a table name in the database from the named DataFrame - -``--append`` - Like ``--persist``, but appends to the table if it already exists - -``-a`` / ``--connection_arguments <"{connection arguments}">`` - Specify dictionary of connection arguments to pass to SQL driver - -``-f`` / ``--file `` - Run SQL from file at this path - -Caution -------- - -Comments -~~~~~~~~ - -Because ipyton-sql accepts ``--``-delimited options like ``--persist``, but ``--`` -is also the syntax to denote a SQL comment, the parser needs to make some assumptions. - -- If you try to pass an unsupported argument, like ``--lutefisk``, it will - be interpreted as a SQL comment and will not throw an unsupported argument - exception. -- If the SQL statement begins with a first-line comment that looks like one - of the accepted arguments - like ``%sql --persist is great!`` - it will be - parsed like an argument, not a comment. Moving the comment to the second - line or later will avoid this. - -Installing ----------- - -Install the latest release with:: - - pip install ipython-sql - -or download from https://github.com/catherinedevlin/ipython-sql and:: - - cd ipython-sql - sudo python setup.py install - -Development ------------ - -https://github.com/catherinedevlin/ipython-sql - -Credits -------- - -- Matthias Bussonnier for help with configuration -- Olivier Le Thanh Duong for ``%config`` fixes and improvements -- Distribute_ -- Buildout_ -- modern-package-template_ -- Mike Wilson for bind variable code -- Thomas Kluyver and Steve Holden for debugging help -- Berton Earnshaw for DSN connection syntax -- Bruno Harbulot for DSN example -- Andrés Celis for SQL Server bugfix -- Michael Erasmus for DataFrame truth bugfix -- Noam Finkelstein for README clarification -- Xiaochuan Yu for `<<` operator, syntax colorization -- Amjith Ramanujam for PGSpecial and incorporating it here -- Alexander Maznev for better arg parsing, connections accepting specified creator -- Jonathan Larkin for configurable displaycon -- Jared Moore for ``connection-arguments`` support -- Gilbert Brault for ``--append`` -- Lucas Zeer for multi-line bugfixes for var substitution, ``<<`` -- vkk800 for ``--file`` -- Jens Albrecht for MySQL DatabaseError bugfix -- meihkv for connection-closing bugfix - -.. _Distribute: http://pypi.python.org/pypi/distribute -.. _Buildout: http://www.buildout.org/ -.. _modern-package-template: http://pypi.python.org/pypi/modern-package-template diff --git a/_static/get-started.svg b/_static/get-started.svg new file mode 100644 index 000000000..881d5d6cc --- /dev/null +++ b/_static/get-started.svg @@ -0,0 +1,3 @@ + + +
Get Started
Get Started
Viewer does not support full SVG 1.1
\ No newline at end of file diff --git a/benchmarks/profiling.py b/benchmarks/profiling.py new file mode 100644 index 000000000..71658178d --- /dev/null +++ b/benchmarks/profiling.py @@ -0,0 +1,36 @@ +""" +Sample script to profile the sql magic. + +>>> pip install line_profiler +>>> kernprof -lv profiling.py +""" + +from sql.magic import SqlMagic +from IPython import InteractiveShell +import duckdb +from pandas import DataFrame +import numpy as np + +num_rows = 1_000_000 +num_cols = 50 + +df = DataFrame(np.random.randn(num_rows, num_cols)) + +magic = SqlMagic(InteractiveShell()) + +conn = duckdb.connect() +magic.execute(line="conn --alias duckdb", local_ns={"conn": conn}) +magic.autopandas = True +magic.displaycon = False + + +# NOTE: you can put the @profile decorator on any internal function to profile it +# the @profile decorator is injected by the line_profiler package at runtime, to learn +# more, see: https://github.com/pyutils/line_profiler +# e.g., to check the magic performance, you can add @profile to the _execute function +def run_magic(): + magic.execute("SELECT * FROM df") + + +if __name__ == "__main__": + run_magic() diff --git a/bootstrap.py b/bootstrap.py deleted file mode 100644 index 63aebb99d..000000000 --- a/bootstrap.py +++ /dev/null @@ -1,113 +0,0 @@ -############################################################################## -# -# Copyright (c) 2006 Zope Corporation and Contributors. -# All Rights Reserved. -# -# This software is subject to the provisions of the Zope Public License, -# Version 2.1 (ZPL). A copy of the ZPL should accompany this distribution. -# THIS SOFTWARE IS PROVIDED "AS IS" AND ANY AND ALL EXPRESS OR IMPLIED -# WARRANTIES ARE DISCLAIMED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED -# WARRANTIES OF TITLE, MERCHANTABILITY, AGAINST INFRINGEMENT, AND FITNESS -# FOR A PARTICULAR PURPOSE. -# -############################################################################## -"""Bootstrap a buildout-based project - -Simply run this script in a directory containing a buildout.cfg. -The script accepts buildout command-line options, so you can -use the -c option to specify an alternate configuration file. - -$Id: bootstrap.py 102545 2009-08-06 14:49:47Z chrisw $ -""" - -import os, shutil, sys, tempfile, urllib2 -from optparse import OptionParser - -tmpeggs = tempfile.mkdtemp() - -is_jython = sys.platform.startswith('java') - -# parsing arguments -parser = OptionParser() -parser.add_option("-v", "--version", dest="version", - help="use a specific zc.buildout version") -parser.add_option("-d", "--distribute", - action="store_true", dest="distribute", default=True, - help="Use Disribute rather than Setuptools.") - -options, args = parser.parse_args() - -if options.version is not None: - VERSION = '==%s' % options.version -else: - VERSION = '' - -USE_DISTRIBUTE = options.distribute -args = args + ['bootstrap'] - -to_reload = False -try: - import pkg_resources - if not hasattr(pkg_resources, '_distribute'): - to_reload = True - raise ImportError -except ImportError: - ez = {} - if USE_DISTRIBUTE: - exec urllib2.urlopen('http://python-distribute.org/distribute_setup.py' - ).read() in ez - ez['use_setuptools'](to_dir=tmpeggs, download_delay=0, no_fake=True) - else: - exec urllib2.urlopen('http://peak.telecommunity.com/dist/ez_setup.py' - ).read() in ez - ez['use_setuptools'](to_dir=tmpeggs, download_delay=0) - - if to_reload: - reload(pkg_resources) - else: - import pkg_resources - -if sys.platform == 'win32': - def quote(c): - if ' ' in c: - return '"%s"' % c # work around spawn lamosity on windows - else: - return c -else: - def quote (c): - return c - -cmd = 'from setuptools.command.easy_install import main; main()' -ws = pkg_resources.working_set - -if USE_DISTRIBUTE: - requirement = 'distribute' -else: - requirement = 'setuptools' - -if is_jython: - import subprocess - - assert subprocess.Popen([sys.executable] + ['-c', quote(cmd), '-mqNxd', - quote(tmpeggs), 'zc.buildout' + VERSION], - env=dict(os.environ, - PYTHONPATH= - ws.find(pkg_resources.Requirement.parse(requirement)).location - ), - ).wait() == 0 - -else: - assert os.spawnle( - os.P_WAIT, sys.executable, quote (sys.executable), - '-c', quote (cmd), '-mqNxd', quote (tmpeggs), 'zc.buildout' + VERSION, - dict(os.environ, - PYTHONPATH= - ws.find(pkg_resources.Requirement.parse(requirement)).location - ), - ) == 0 - -ws.add_entry(tmpeggs) -ws.require('zc.buildout' + VERSION) -import zc.buildout.buildout -zc.buildout.buildout.main(args) -shutil.rmtree(tmpeggs) diff --git a/buildout.cfg b/buildout.cfg deleted file mode 100644 index 4f7b8bf27..000000000 --- a/buildout.cfg +++ /dev/null @@ -1,13 +0,0 @@ -[buildout] -parts = python scripts -develop = . -eggs = ipython-sql - -[python] -recipe = zc.recipe.egg -interpreter = python -eggs = ${buildout:eggs} - -[scripts] -recipe = zc.recipe.egg:scripts -eggs = ${buildout:eggs} diff --git a/doc/_config.yml b/doc/_config.yml new file mode 100644 index 000000000..71915c0dc --- /dev/null +++ b/doc/_config.yml @@ -0,0 +1 @@ +# do not use this, edit conf.py instead diff --git a/doc/_static/algolia.css b/doc/_static/algolia.css new file mode 100644 index 000000000..622145035 --- /dev/null +++ b/doc/_static/algolia.css @@ -0,0 +1,16 @@ +/* Hide search button from article-header-buttons +Removing `search_bar_text` from +`html_theme_options` in conf.py doesn't work */ +.article-header-buttons .search-button { + display: none; +} + +/* Hide the search wrapper window when hitting Ctrl+K */ +.search-button__wrapper.show { + display: none !important; +} + +/* Make sure Algolia's search container is always on top */ +.bd-article-container { + z-index: 10; +} \ No newline at end of file diff --git a/doc/_static/algolia.js b/doc/_static/algolia.js new file mode 100644 index 000000000..87d882fba --- /dev/null +++ b/doc/_static/algolia.js @@ -0,0 +1,24 @@ +// https://docsearch.algolia.com/docs/DocSearch-v3#implementation +// +// Since we can't add a custom element to article-header, we wait until +// DOM is ready and creating a new element - #docsearch +// After the element was added to the DOM, we initialize docsearch. + +addEventListener("DOMContentLoaded", (event) => { + const container = document.querySelector(".article-header-buttons"); + let docsearchDiv = document.createElement("DIV") + docsearchDiv.id = 'docsearch'; + container.appendChild(docsearchDiv); + + setTimeout(() => { + docsearch({ + container: '#docsearch', + appId: 'Y6L7HQ2HZO', + indexName: 'ploomber_jupysql', + apiKey: '9a1fd3379e6d318ef4f46aa36a3c5fe6' + }); + }, 100); + +}); + + diff --git a/doc/_static/marketing.css b/doc/_static/marketing.css new file mode 100644 index 000000000..1a0c8f441 --- /dev/null +++ b/doc/_static/marketing.css @@ -0,0 +1,28 @@ +.bd-header-announcement { + color: white; + padding: 8px; + position: sticky; + top: 0; + z-index: 1000; +} + +.bd-header-announcement a { + color: white; + font-weight: bold; + text-decoration: underline; + animation: pulse 4s infinite; +} + +@keyframes pulse { + 0% { + opacity: 1; + } + + 50% { + opacity: 0.5; + } + + 100% { + opacity: 1; + } +} \ No newline at end of file diff --git a/doc/_static/marketing.js b/doc/_static/marketing.js new file mode 100644 index 000000000..b362a9b18 --- /dev/null +++ b/doc/_static/marketing.js @@ -0,0 +1,58 @@ +document.addEventListener("DOMContentLoaded", (event) => { + options = [ + { + text: "Deploy Streamlit apps for free on ", + link: "Ploomber Cloud!", + url: "https://platform.ploomber.io/register/?utm_medium=readthedocs&utm_source=jupysql&onboarding=streamlit", + }, + { + text: "Deploy Shiny apps for free on ", + link: "Ploomber Cloud!", + url: "https://platform.ploomber.io/register/?utm_medium=readthedocs&utm_source=jupysql&onboarding=shiny-r", + }, + { + text: "Deploy Dash apps for free on ", + link: "Ploomber Cloud!", + url: "https://platform.ploomber.io/register/?utm_medium=readthedocs&utm_source=jupysql&onboarding=dash", + }, + { + text: "Try our new Streamlit ", + link: "AI Editor!", + url: "https://editor.ploomber.io/?utm_medium=readthedocs&utm_source=jupysql", + } + ] + const announcementElement = document.querySelector("#ploomber-top-announcement"); + if (announcementElement) { + const updateAnnouncement = (firstTime = false) => { + let randomIndex; + let currentContent = announcementElement.textContent; + + // Keep selecting a new random index until we get a different announcement + do { + randomIndex = Math.floor(Math.random() * options.length); + } while (options[randomIndex].text + options[randomIndex].link === currentContent); + + const option = options[randomIndex]; + + if (firstTime) { + // First time - just set content without transition + announcementElement.innerHTML = `${option.text}${option.link}`; + } else { + // Subsequent updates - use transition + announcementElement.style.opacity = 0; + announcementElement.style.transition = 'opacity 0.5s ease'; + + setTimeout(() => { + announcementElement.innerHTML = `${option.text}${option.link}`; + announcementElement.style.opacity = 1; + }, 500); + } + }; + + // Set initial content without transition + updateAnnouncement(true); + + // Update every 5 seconds with transition + setInterval(() => updateAnnouncement(false), 5000); + } +}); diff --git a/doc/_toc.yml b/doc/_toc.yml new file mode 100644 index 000000000..180d5d694 --- /dev/null +++ b/doc/_toc.yml @@ -0,0 +1,89 @@ +# Table of contents +# Learn more at https://jupyterbook.org/customize/toc.html + +format: jb-book +root: quick-start +parts: + - caption: User Guide + chapters: + - file: intro + - file: connecting + - file: plot + - file: compose + - file: user-guide/tables-columns + - file: user-guide/ggplot + - file: user-guide/template + - file: user-guide/argument-expansion + - file: user-guide/connection-file + - file: user-guide/table_explorer + - file: user-guide/data-profiling + + - caption: JupyterLab integration + chapters: + - file: jupyterlab/syntax-highlighting + - file: jupyterlab/format-sql + - file: jupyterlab/autocompletion + + - caption: Integrations + chapters: + - file: integrations/duckdb + - file: integrations/pandas + - file: integrations/polars + - file: integrations/snowflake + - file: integrations/redshift + - file: integrations/postgres-connect + - file: integrations/mysql + - file: integrations/mssql + - file: integrations/mariadb + - file: integrations/clickhouse + - file: integrations/mindsdb + - file: integrations/questdb + - file: integrations/oracle + - file: integrations/trinodb + - file: integrations/duckdb-native + - file: integrations/compatibility + - file: integrations/chdb + - file: integrations/spark + + - caption: API Reference + chapters: + - file: api/magic-sql + - file: api/magic-plot + - file: api/magic-snippets + - file: api/configuration + - file: api/python + - file: api/magic-tables-columns + - file: api/magic-profile + - file: api/magic-connect + - file: api/plot-legacy + + - caption: How-To + chapters: + - file: howto + - file: howto/postgres-install + - file: howto/json + - file: howto/csv + - file: howto/ggplot-interact + - file: howto/benchmarking-time + - file: howto/py-scripts + - file: howto/interactive + - file: howto/testing-columns + - file: howto/db-drivers + + - caption: Tutorials + chapters: + - file: tutorials/duckdb-github + - file: tutorials/etl + - file: tutorials/excel + - file: tutorials/product-analytics + - file: tutorials/duckdb-native-sqlalchemy + + - caption: Community + chapters: + - file: community/vs + - file: community/FAQ + - file: community/coc + - file: community/support + - file: community/projects + - file: community/credits + - file: community/developer-guide diff --git a/doc/api/configuration.md b/doc/api/configuration.md new file mode 100644 index 000000000..0ae1e34d1 --- /dev/null +++ b/doc/api/configuration.md @@ -0,0 +1,370 @@ +--- +jupytext: + cell_metadata_filter: -all + formats: md:myst + notebook_metadata_filter: myst + text_representation: + extension: .md + format_name: myst + format_version: 0.13 + jupytext_version: 1.15.1 +kernelspec: + display_name: Python 3 (ipykernel) + language: python + name: python3 +myst: + html_meta: + description lang=en: Configure the %sql/%%sql magics in Jupyter + keywords: jupyter, sql, jupysql + property=og:locale: en_US +--- + +# `%sql` Configuration + +Query results are loaded as lists, so very large result sets may use up +your system's memory and/or hang your browser. There is no autolimit +by default. However, `autolimit` (if set) limits the size of the result +set (usually with a `LIMIT` clause in the SQL). `displaylimit` is similar, +but the entire result set is still pulled into memory (for later analysis); +only the screen display is truncated. + +If you are concerned about query performance, please use the `autolimit` config. + ++++ + +## Setup + +```{code-cell} ipython3 +%load_ext sql +``` + +```{code-cell} ipython3 +%sql sqlite:// +``` + +```{code-cell} ipython3 +%%sql +CREATE TABLE languages (name, rating, change); +INSERT INTO languages VALUES ('Python', 14.44, 2.48); +INSERT INTO languages VALUES ('C', 13.13, 1.50); +INSERT INTO languages VALUES ('Java', 11.59, 0.40); +INSERT INTO languages VALUES ('C++', 10.00, 1.98); +``` + +## Options + +```{code-cell} ipython3 +%config SqlMagic +``` + +```{note} +If you have autopandas set to true, the displaylimit option will not apply. You can set the pandas display limit by using the pandas `max_rows` option as described in the [pandas documentation](http://pandas.pydata.org/pandas-docs/version/0.18.1/options.html#frequently-used-options). +``` + ++++ + +## Changing configuration + +```{code-cell} ipython3 +%config SqlMagic.feedback = 0 +``` + +## `autocommit` +Default: `True` + +Commits each executed query to the database automatically. + +Set to `False` to disable this behavior. +This may be needed when commits are not supported by the database +(for example in sqlalchemy 1.x does not support commits) + +```{code-cell} ipython3 +%config SqlMagic.autocommit = False +``` + +## `autolimit` + +Default: `0` (no limit) + +Automatically limit the size of the returned result sets (e.g., add a `LIMIT` at the end of the query). + +```{code-cell} ipython3 +%config SqlMagic.autolimit = 0 +%sql SELECT * FROM languages +``` + +```{code-cell} ipython3 +%config SqlMagic.autolimit = 1 +%sql SELECT * FROM languages +``` + +```{code-cell} ipython3 +%config SqlMagic.autolimit = 0 +``` + +## `autopandas` + +Default: `False` + +Return Pandas DataFrames instead of regular result sets. + +```{code-cell} ipython3 +%config SqlMagic.autopandas = True +df = %sql SELECT * FROM languages +type(df) +``` + +```{code-cell} ipython3 +%config SqlMagic.autopandas = False +res = %sql SELECT * FROM languages +type(res) +``` + +## `autopolars` + +Default: `False` + +Return Polars DataFrames instead of regular result sets. + +```{code-cell} ipython3 +%config SqlMagic.autopolars = True +df = %sql SELECT * FROM languages +type(df) +``` + +```{code-cell} ipython3 +%config SqlMagic.autopolars = False +res = %sql SELECT * FROM languages +type(res) +``` + +## `column_local_vars` +Default: `False` +Returns data into local variable corresponding to column name. +To enable this behavior, set to `True`. + +```{code-cell} ipython3 +%config SqlMagic.column_local_vars = True +%sql SELECT * FROM languages +``` +You can now access columns returned through variables with the same name. + +```{code-cell} ipython3 +print(f"Name: {name}") +print(f"Rating: {rating}") +print(f"Change: {change}") +``` + +Note that ```column_local_vars``` cannot be used when either of +```autopandas``` or ```autopolars``` is enabled, and vice-versa. + +```{code-cell} ipython3 +%config SqlMagic.column_local_vars = False +``` + +## `displaycon` + +Default: `True` + +Show connection string after execution. + +```{code-cell} ipython3 +%config SqlMagic.displaycon = False +%sql SELECT * FROM languages LIMIT 2 +``` + +```{code-cell} ipython3 +%config SqlMagic.displaycon = True +%sql SELECT * FROM languages LIMIT 2 +``` + +## `displaylimit` + +Default: `10` + +Automatically limit the number of rows displayed (full result set is still stored). + +(To display all rows: set to `0` or `None`) + +```{code-cell} ipython3 +%config SqlMagic.displaylimit = None +%sql SELECT * FROM languages +``` + +```{code-cell} ipython3 +%config SqlMagic.displaylimit = 1 +res = %sql SELECT * FROM languages +res +``` + +One displayed, but all results fetched: + +```{code-cell} ipython3 +len(res) +``` + +## `dsn_filename` + +```{versionchanged} 0.10.0 +`dsn_filename` default changed from `odbc.ini` to `~/.jupysql/connections.ini`. +``` + +Default: `~/.jupysql/connections.ini` + +File to load connections configuration from. For an example, see: [](../user-guide/connection-file.md) + ++++ + +## `feedback` + +```{versionchanged} 0.10 +`feedback` takes values `0`, `1`, and `2` instead of `True`/`False` +``` + +Default: `1` + +Control the quantity of messages displayed when performing certain operations. Each +value enables the ones from previous values plus new ones: + +- `0`: Minimal feedback +- `1`: Normal feedback (default) + - Connection name when switching + - Connection name when running a query + - Number of rows afffected by DML (e.g., `INSERT`, `UPDATE`, `DELETE`) +- `2`: All feedback + - Footer to distinguish pandas/polars data frames from JupySQL's result sets + +## `lazy_execution` + +```{versionadded} 0.10.7 +This option only works when connecting to Spark +``` + +Default: `False` + +Return lazy relation to dataset rather than executing through JupySql. + +```{code-cell} ipython3 +%config SqlMagic.lazy_execution = True +df = %sql SELECT * FROM languages +``` + +```{code-cell} ipython3 +%config SqlMagic.lazy_execution = False +res = %sql SELECT * FROM languages +``` + +## `named_parameters` + +```{versionchanged} 0.10.9 +``` + +Default: `warn` + +If `warn`, a warning will be raised when named parameters are included in the statement. +If `enabled`, the statement will be executed with named parameters enabled. +If `disabled`, the statement will be executed with named parameters disabled. + +```{important} +The `disabled` feature makes use of SQLAlchemy's `exec_driver_sql()` instead of `execute()` +to execute SQL statements without the use of bound parameters. This operation doesn't include +other SQL compilation steps which could affect the behavior of your program. +If you encounter problems, please open an issue on [Slack](https://ploomber.io/community) or [Github](https://github.com/ploomber/jupysql). +``` + +Learn more in the [tutorial.](../user-guide/template.md) + +Named parameters can be declared with `:variable`. + +```{code-cell} ipython3 +%config SqlMagic.named_parameters="enabled" +``` + +```{code-cell} ipython3 +rating = 12 +``` + +```{code-cell} ipython3 +%%sql +SELECT * +FROM languages +WHERE rating > :rating +``` + +## `polars_dataframe_kwargs` + +Default: `{}` + +Polars [DataFrame constructor](https://pola-rs.github.io/polars/py-polars/html/reference/dataframe/index.html) keyword arguments (e.g. infer_schema_length, nan_to_null, schema_overrides, etc) + +```{code-cell} ipython3 +# By default Polars will only look at the first 100 rows to infer schema +# Disable this limit by setting infer_schema_length to None +%config SqlMagic.polars_dataframe_kwargs = { "infer_schema_length": None} + +# Create a table with 101 rows, last row has a string which will cause the +# column type to be inferred as a string (rather than crashing polars) +%sql CREATE TABLE points (x, y); +insert_stmt = "" +for _ in range(100): + insert_stmt += "INSERT INTO points VALUES (1, 2);" +%sql {{insert_stmt}} +%sql INSERT INTO points VALUES (1, "foo"); + + +%sql SELECT * FROM points +``` + +To unset: + +```{code-cell} ipython3 +%config SqlMagic.polars_dataframe_kwargs = {} +``` + +## `short_errors` + +DEFAULT: `True` + +Set the error description size. +If `False`, displays entire traceback. + +```{code-cell} ipython3 +%config SqlMagic.short_errors = False +``` + +## `style` + +DEFAULT: `DEFAULT` + +Set the table printing style to any of prettytable's defined styles + +```{code-cell} ipython3 +%config SqlMagic.style = "MSWORD_FRIENDLY" +res = %sql SELECT * FROM languages LIMIT 2 +print(res) +``` + +```{code-cell} ipython3 +%config SqlMagic.style = "SINGLE_BORDER" +res = %sql SELECT * FROM languages LIMIT 2 +print(res) +``` + +## Loading from a file + +```{versionadded} 0.9 +``` + +```{versionchanged} 0.10.3 +Look for `~/.jupysql/config` if `pyproject.toml` doesn't exist. +``` + +You can define configurations in a `pyproject.toml` file and automatically load the configurations when you run `%load_ext sql`. If the file is not found in the current or parent directories, jupysql then looks for configurations in `~/.jupysql/config`. If no configuration file is found, default values will be used. A sample configuration file could look like this: + +``` +[tool.jupysql.SqlMagic] +feedback = true +autopandas = true +``` + +Note that these files are only for setting configurations. To store connection details, please use [`connections.ini`](../user-guide/connection-file.md) file. diff --git a/doc/api/magic-connect.md b/doc/api/magic-connect.md new file mode 100644 index 000000000..212a00d62 --- /dev/null +++ b/doc/api/magic-connect.md @@ -0,0 +1,65 @@ +--- +jupytext: + notebook_metadata_filter: myst + text_representation: + extension: .md + format_name: myst + format_version: 0.13 + jupytext_version: 1.15.0 +kernelspec: + display_name: Python 3 (ipykernel) + language: python + name: python3 +myst: + html_meta: + description lang=en: Documentation for the %sqlcmd tables and %sqlcmd columns + from JupySQL + keywords: jupyter, sql, jupysql, tables, columns + property=og:locale: en_US +--- + +# `%sqlcmd connect` + +```{versionadded} 0.10.1 +``` + +`%sqlcmd connect` displays a widget that allows you to create new connections and manage existing ones. + +## Installation + +Since `%sqlcmd connect` uses the optional `ipywidgets` package: + +```sh +pip install ipywidgets --upgrade +``` + +## Create a new connection + +Click on the `+ Create new connection` button and fill out the form: + +![create](../static/create-connection.gif) + +## Delete a connection + +Click on trash bin icon and confirm: + +![delete](../static/delete-connection.gif) + + +## Edit an existing connection + +Click on the pencil button, edit details, and click on `Update`: + +![edit](../static/edit-connection.gif) + +## Connect to an existing connection + +Click on the `Connect` button: + +![existing](../static/existing-connection.gif) + +## The connections file + +All your connections are stored in the `%config SqlMagic.dsn_filename` file +(`~/.jupysql/connections.ini` by default). You can change the file location +and edit it manually, to learn more, see: [](../user-guide/connection-file.md) \ No newline at end of file diff --git a/doc/api/magic-plot.md b/doc/api/magic-plot.md new file mode 100644 index 000000000..bc37fff64 --- /dev/null +++ b/doc/api/magic-plot.md @@ -0,0 +1,342 @@ +--- +jupytext: + notebook_metadata_filter: myst + text_representation: + extension: .md + format_name: myst + format_version: 0.13 + jupytext_version: 1.16.0 +kernelspec: + display_name: Python 3 (ipykernel) + language: python + name: python3 +myst: + html_meta: + description lang=en: Documentation for the %sqlplot magic from JupySQL + keywords: jupyter, sql, jupysql, plotting + property=og:locale: en_US +--- + +# `%sqlplot` + +```{versionadded} 0.5.2 +``` + + +```{note} +`%sqlplot` requires `matplotlib`: `pip install matplotlib` and this example requires +duckdb-engine: `pip install duckdb-engine` +``` + +```{code-cell} ipython3 +%load_ext sql +``` + +```{code-cell} ipython3 +%sql duckdb:// +``` + +```{code-cell} ipython3 +from pathlib import Path +from urllib.request import urlretrieve + +if not Path("penguins.csv").is_file(): + urlretrieve( + "https://raw.githubusercontent.com/mwaskom/seaborn-data/master/penguins.csv", + "penguins.csv", + ) +``` + +```{code-cell} ipython3 +%%sql +SELECT * FROM "penguins.csv" LIMIT 3 +``` + +```{note} +You can view the documentation and command line arguments by running `%sqlplot?` +``` + +## `%sqlplot boxplot` + + +```{note} +To use `%sqlplot boxplot`, your SQL engine must support: + +`percentile_disc(...) WITHIN GROUP (ORDER BY ...)` + +[Snowflake](https://docs.snowflake.com/en/sql-reference/functions/percentile_disc.html), +[Postgres](https://www.postgresql.org/docs/9.4/functions-aggregate.html), +[DuckDB](https://duckdb.org/docs/sql/aggregates), and others support this. +``` + +Shortcut: `%sqlplot box` + +`-t`/`--table` Table to use (if using DuckDB: path to the file to query) + +`-s`/`--schema` Schema to use (No need to pass if using a default schema) + +`-c`/`--column` Column(s) to plot. You might pass one than one value (e.g., `-c a b c`) + +`-o`/`--orient` Boxplot orientation (`h` for horizontal, `v` for vertical) + +`-w`/`--with` Use a previously saved query as input data + +```{code-cell} ipython3 +%sqlplot boxplot --table penguins.csv --column body_mass_g +``` + +### Transform data before plotting + +```{code-cell} ipython3 +%%sql +SELECT island, COUNT(*) +FROM penguins.csv +GROUP BY island +ORDER BY COUNT(*) DESC +``` + +```{code-cell} ipython3 +%%sql --save biscoe --no-execute +SELECT * +FROM penguins.csv +WHERE island = 'Biscoe' +``` + +Since we saved `biscoe` from the cell above, we can pass it as an argument to `--table` since jupysql autogenerates the CTE. + +```{code-cell} ipython3 +%sqlplot boxplot --table biscoe --column body_mass_g +``` + +### Horizontal boxplot + +```{code-cell} ipython3 +%sqlplot boxplot --table penguins.csv --column bill_length_mm --orient h +``` + +### Multiple columns + +```{code-cell} ipython3 +%sqlplot boxplot --table penguins.csv --column bill_length_mm bill_depth_mm flipper_length_mm +``` + +## `%sqlplot histogram` + +Shortcut: `%sqlplot hist` + +`-t`/`--table` Table to use (if using DuckDB: path to the file to query) + +`-s`/`--schema` Schema to use (No need to pass if using a default schema) + +`-c`/`--column` Column to plot + +`-b`/`--bins` (default: `50`) Number of bins + +`-B`/`--breaks` Custom bin intervals + +`-W`/`--binwidth` Width of each bin + +`-w`/`--with` Use a previously saved query as input data + +```{note} +When using -b/--bins, -B/--breaks, or -W/--binwidth, you can only specify one of them. If none of them is specified, the default value for -b/--bins will be used. +``` + ++++ + +Histogram supports NULL values by skipping them. Now we can +generate histograms without explicitly removing NULL entries. +```{versionadded} 0.7.9 +``` + +```{code-cell} ipython3 +%sqlplot histogram --table penguins.csv --column body_mass_g +``` + +When plotting a histogram, it divides a range with the number of bins - 1 to calculate a bin size. Then, it applies round half down relative to the bin size and categorizes continuous values into bins to replicate right closed intervals from the ggplot histogram in R. + +![body_mass_g](../static/body_mass_g_R.png) + ++++ + +### Specifying bins + +Bins allow you to set the number of bins in a histogram, and it's useful when you are interested in the overall distribution. + +```{code-cell} ipython3 +%sqlplot histogram --table penguins.csv --column body_mass_g --bins 100 +``` + +### Specifying breaks + +Breaks allow you to set custom intervals for a histogram. It is useful when you want to view distribution within a specific range. You can specify breaks by passing desired each end and break points separated by whitespace after `-B/--breaks`. Since those break points define a range of data points to plot, bar width, and number of bars in a histogram, make sure to pass more than 1 point that is strictly increasing and includes at least one data point. + +```{code-cell} ipython3 +%sqlplot histogram --table penguins.csv --column body_mass_g --breaks 3200 3400 3600 3800 4000 4200 4400 4600 4800 +``` + +### Specifying binwidth + +Binwidth allows you to set the width of bins in a histogram. It is useful when you directly aim to adjust the granularity of the histogram. To specify the binwidth, pass a desired width after `-W/--binwidth`. Since the binwidth determines details of distribution, make sure to pass a suitable positive numeric value based on your data. + +```{code-cell} ipython3 +%sqlplot histogram --table penguins.csv --column body_mass_g --binwidth 150 +``` + +### Multiple columns + +```{code-cell} ipython3 +%sqlplot histogram --table penguins.csv --column bill_length_mm bill_depth_mm +``` + +## Customize plot + +`%sqlplot` returns a `matplotlib.Axes` object. + +```{code-cell} ipython3 +ax = %sqlplot histogram --table penguins.csv --column body_mass_g +ax.set_title("Body mass (grams)") +_ = ax.grid() +``` + +## `%sqlplot bar` + +```{versionadded} 0.7.6 +``` + +Shortcut: `%sqlplot bar` + +`-t`/`--table` Table to use (if using DuckDB: path to the file to query) + +`-s`/`--schema` Schema to use (No need to pass if using a default schema) + +`-c`/`--column` Column to plot. + +`-o`/`--orient` Barplot orientation (`h` for horizontal, `v` for vertical) + +`-w`/`--with` Use a previously saved query as input data + +`-S`/`--show-numbers` Show numbers on top of the bar + +Bar plot does not support NULL values, so we automatically remove them, when plotting. + +```{code-cell} ipython3 +%sqlplot bar --table penguins.csv --column species +``` + +You can additionally pass two columns to bar plot i.e. `x` and `height` columns. + +```{code-cell} ipython3 +%%sql --save add_col --no-execute +SELECT species, count(species) as cnt +FROM penguins.csv +group by species +``` + +```{code-cell} ipython3 +%sqlplot bar --table add_col --column species cnt +``` + +You can also pass the orientation using the `orient` argument. + +```{code-cell} ipython3 +%sqlplot bar --table add_col --column species cnt --orient h +``` + +You can also show the number on top of the bar using the `S`/`show-numbers` argument. + +```{code-cell} ipython3 +%sqlplot bar --table penguins.csv --column species -S +``` + +## `%sqlplot pie` + +```{versionadded} 0.7.6 +``` + +Shortcut: `%sqlplot pie` + +`-t`/`--table` Table to use (if using DuckDB: path to the file to query) + +`-s`/`--schema` Schema to use (No need to pass if using a default schema) + +`-c`/`--column` Column to plot + +`-w`/`--with` Use a previously saved query as input data + +`-S`/`--show-numbers` Show the percentage on top of the pie + +Pie chart does not support NULL values, so we automatically remove them, when plotting the pie chart. + +```{code-cell} ipython3 +%sqlplot pie --table penguins.csv --column species +``` + +You can additionally pass two columns to bar plot i.e. `labels` and `x` columns. + +```{code-cell} ipython3 +%%sql --save add_col --no-execute +SELECT species, count(species) as cnt +FROM penguins.csv +group by species +``` + +```{code-cell} ipython3 +%sqlplot pie --table add_col --column species cnt +``` + +Here, `species` is the `labels` column and `cnt` is the `x` column. + + +You can also show the percentage on top of the pie using the `S`/`show-numbers` argument. + +```{code-cell} ipython3 +%sqlplot pie --table penguins.csv --column species -S +``` + +## Parametrizing arguments + +JupySQL supports variable expansion of arguments in the form of `{{variable}}`. This allows the user to specify arguments with placeholders that can be replaced by variables dynamically. + +```{code-cell} ipython3 +%%sql +DROP TABLE IF EXISTS penguins; +CREATE SCHEMA IF NOT EXISTS s1; +CREATE TABLE s1.penguins ( + species VARCHAR(255), + island VARCHAR(255), + bill_length_mm DECIMAL(5, 2), + bill_depth_mm DECIMAL(5, 2), + flipper_length_mm DECIMAL(5, 2), + body_mass_g INTEGER, + sex VARCHAR(255) +); +COPY s1.penguins FROM 'penguins.csv' WITH (FORMAT CSV, HEADER TRUE); +``` + +```{code-cell} ipython3 +table = "penguins" +schema = "s1" +orient = "h" +column = "bill_length_mm" +``` + +```{code-cell} ipython3 +%sqlplot boxplot --table {{table}} --schema {{schema}} --column {{column}} --orient {{orient}} +``` + +Now let's see another example using `--with`: + +```{code-cell} ipython3 +snippet = "gentoo" +``` + +```{code-cell} ipython3 +%%sql --save {{snippet}} +SELECT * FROM {{schema}}.{{table}} +WHERE species == 'Gentoo' +``` + +```{code-cell} ipython3 +%sqlplot boxplot --table {{snippet}} --with {{snippet}} --column {{column}} +``` diff --git a/doc/api/magic-profile.md b/doc/api/magic-profile.md new file mode 100644 index 000000000..054617cc5 --- /dev/null +++ b/doc/api/magic-profile.md @@ -0,0 +1,228 @@ +--- +jupytext: + notebook_metadata_filter: myst + text_representation: + extension: .md + format_name: myst + format_version: 0.13 + jupytext_version: 1.14.5 +kernelspec: + display_name: Python 3 (ipykernel) + language: python + name: python3 +myst: + html_meta: + description lang=en: Documentation for the %sqlcmd profile from JupySQL + keywords: jupyter, sql, jupysql, profile + property=og:locale: en_US +--- + +# `%sqlcmd profile` + +`%sqlcmd profile` allows you to obtain summary statistics of a table quickly. The code used here is compatible with all major databases. + +```{note} +You can view the documentation and command line arguments by running `%sqlcmd?` +``` + +Arguments: + +`-t`/`--table` (Required) Get the profile of a table + +`-s`/`--schema` (Optional) Get the profile of a table under a specified schema + +`-o`/`--output` (Optional) Output the profile at a specified location (path name expected) + +```{note} +This example requires duckdb-engine: `pip install duckdb-engine` +``` + +## Load CSV Data with DuckDB + +Load the extension and connect to an in-memory DuckDB database: + +```{code-cell} ipython3 +%load_ext sql +%sql duckdb:// +``` + +Load and download `penguins.csv` dataset , using DuckDB. + +```{code-cell} ipython3 +from pathlib import Path +from urllib.request import urlretrieve + +if not Path("penguins.csv").is_file(): + urlretrieve( + "https://raw.githubusercontent.com/mwaskom/seaborn-data/master/penguins.csv", + "penguins.csv", + ) +``` + +```{code-cell} ipython3 +%%sql +SELECT * FROM "penguins.csv" LIMIT 3 +``` + +## Load Parquet Data with DuckDB + +Load and download a sample dataset that contains historical taxi data from NYC, using DuckDB. + +```{code-cell} ipython3 +import os +from pathlib import Path +from urllib.request import urlretrieve + +url = "https://d37ci6vzurychx.cloudfront.net/trip-data/yellow_tripdata_2021-01.parquet" +new_filename = "yellow_tripdata_2021.parquet" + +if not Path(new_filename).is_file(): + urlretrieve(url, new_filename) + # Rename the downloaded file to remove the month ("-" interferes with the SQL query) + os.rename(new_filename, new_filename.replace("-01", "")) +``` + +```{code-cell} ipython3 +%%sql +SELECT * FROM yellow_tripdata_2021.parquet LIMIT 3 +``` + +# Profile + +Let us profile the `penguins.csv` data + +```{code-cell} ipython3 +%sqlcmd profile --table "penguins.csv" +``` + +Let us profile the `yellow_tripdata_2021.parquet` data + +```{code-cell} ipython3 +%sqlcmd profile --table "yellow_tripdata_2021.parquet" +``` + +# Saving report as HTML + +To save the generated report as an HTML file, use the `--output/-o` attribute followed by the desired file name. + +To save the profile of the `penguins.csv` data as an HTML file: + +```{code-cell} ipython3 +:tags: [hide-output] + +%sqlcmd profile --table "penguins.csv" --output penguins-report.html +``` + +```{code-cell} ipython3 +from IPython.display import HTML + +HTML("penguins-report.html") +``` + +To save the profile of the `yellow_tripdata_2021.parquet` data as an HTML file: + +```{code-cell} ipython3 +:tags: [hide-output] + +%sqlcmd profile --table "yellow_tripdata_2021.parquet" --output taxi-report.html +``` + +```{code-cell} ipython3 +from IPython.display import HTML + +HTML("taxi-report.html") +``` + +# Use schemas with DuckDB + +To profile a specific table from various tables in different schemas, we can use the `--schema/-s` attribute. + +Let's save the file penguins.csv as a table `penguins` under the schema `s1`. + +```{code-cell} ipython3 +%%sql +DROP TABLE IF EXISTS penguins; +CREATE SCHEMA IF NOT EXISTS s1; +CREATE TABLE s1.penguins ( + species VARCHAR(255), + island VARCHAR(255), + bill_length_mm DECIMAL(5, 2), + bill_depth_mm DECIMAL(5, 2), + flipper_length_mm DECIMAL(5, 2), + body_mass_g INTEGER, + sex VARCHAR(255) +); +COPY s1.penguins FROM 'penguins.csv' WITH (FORMAT CSV, HEADER TRUE); +``` + +```{code-cell} ipython3 +%sqlcmd profile --table penguins --schema s1 +``` + +# Use schemas with SQLite + +```{code-cell} ipython3 +%%sql duckdb:/// +INSTALL 'sqlite_scanner'; +LOAD 'sqlite_scanner'; +``` + +```{code-cell} ipython3 +import sqlite3 + +with sqlite3.connect("a.db") as conn: + conn.execute("CREATE TABLE my_numbers (number FLOAT)") + conn.execute("INSERT INTO my_numbers VALUES (1)") + conn.execute("INSERT INTO my_numbers VALUES (2)") + conn.execute("INSERT INTO my_numbers VALUES (3)") +``` + +```{code-cell} ipython3 +%%sql +ATTACH DATABASE 'a.db' AS a_schema +``` + +```{code-cell} ipython3 +import sqlite3 + +with sqlite3.connect("b.db") as conn: + conn.execute("CREATE TABLE my_numbers (number FLOAT)") + conn.execute("INSERT INTO my_numbers VALUES (11)") + conn.execute("INSERT INTO my_numbers VALUES (22)") + conn.execute("INSERT INTO my_numbers VALUES (33)") +``` + +```{code-cell} ipython3 +%%sql +ATTACH DATABASE 'b.db' AS b_schema +``` + +Let’s profile `my_numbers` of `b_schema` + +```{code-cell} ipython3 +%sqlcmd profile --table my_numbers --schema b_schema +``` + +# Parametrizing arguments + +JupySQL supports variable expansion of arguments in the form of `{{variable}}`. This allows the user to specify arguments with placeholders that can be replaced by variables dynamically. + +Let's look at an example that uses variable expansion for `table`, `schema` and `output` arguments: + +```{code-cell} ipython3 +table = "my_numbers" +schema = "b_schema" +output = "numbers-report.html" +``` + +```{code-cell} ipython3 +:tags: [hide-output] + +%sqlcmd profile --table {{table}} --schema {{schema}} --output {{output}} +``` + +```{code-cell} ipython3 +from IPython.display import HTML + +HTML(output) +``` diff --git a/doc/api/magic-snippets.md b/doc/api/magic-snippets.md new file mode 100644 index 000000000..1b5be7f4e --- /dev/null +++ b/doc/api/magic-snippets.md @@ -0,0 +1,177 @@ +--- +jupytext: + notebook_metadata_filter: myst + text_representation: + extension: .md + format_name: myst + format_version: 0.13 + jupytext_version: 1.14.6 +kernelspec: + display_name: Python 3 (ipykernel) + language: python + name: python3 +myst: + html_meta: + description lang=en: Documentation for %sqlcmd snippets from JupySQL + keywords: jupyter, sql, jupysql, snippets + property=og:locale: en_US +--- + +# `%sqlcmd snippets` + +`%sqlcmd snippets` returns the query snippets saved using `--save` + +## Load Data + +```{code-cell} ipython3 +%load_ext sql +%sql duckdb:// +``` + +```{code-cell} ipython3 +from pathlib import Path +from urllib.request import urlretrieve + +if not Path("penguins.csv").is_file(): + urlretrieve( + "https://raw.githubusercontent.com/mwaskom/seaborn-data/master/penguins.csv", + "penguins.csv", + ) +``` + +```{code-cell} ipython3 +%%sql +SELECT * FROM penguins.csv LIMIT 3 +``` + +Let's save a couple of snippets. + +```{code-cell} ipython3 +:tags: [hide-output] + +%%sql --save gentoo +SELECT * FROM penguins.csv where species == 'Gentoo' +``` + +```{code-cell} ipython3 +:tags: [hide-output] + +%%sql --save chinstrap +SELECT * FROM penguins.csv where species == 'Chinstrap' +``` + +## `%sqlcmd snippets` + ++++ + +Returns all the snippets saved in the environment + +```{code-cell} ipython3 +%sqlcmd snippets +``` + +Arguments: + +`{snippet_name}` Return a snippet. + +`-d`/`--delete` Delete a snippet. + +`-D`/`--delete-force` Force delete a snippet. This may be useful if there are other dependent snippets, and you still need to delete this snippet. + +`-A`/`--delete-force-all` Force delete a snippet and all dependent snippets. + +```{code-cell} ipython3 +chinstrap_snippet = %sqlcmd snippets chinstrap +print(chinstrap_snippet) +``` + +This returns the stored snippet `chinstrap`. + +Calling `%sqlcmd snippets {snippet_name}` also works on a snippet that is dependent on others. To demonstrate it, let's create a snippet dependent on the `chinstrap` snippet. + +```{code-cell} ipython3 +%%sql --save chinstrap_sub +SELECT * FROM chinstrap where island == 'Dream' +``` + +```{code-cell} ipython3 +chinstrap_sub_snippet = %sqlcmd snippets chinstrap_sub +print(chinstrap_sub_snippet) +``` + +This returns the stored snippet `chinstrap_sub`. + +Now, let's see how to delete a stored snippet. + +```{code-cell} ipython3 +%sqlcmd snippets -d gentoo +``` + +This deletes the stored snippet `gentoo`. + +Now, let's see how to delete a stored snippet that other snippets are dependent on. Recall we have created `chinstrap_sub` which is dependent on `chinstrap`. + +```{code-cell} ipython3 +print(chinstrap_sub_snippet) +``` + +Trying to delete the `chinstrap` snippet will display an error message: + +```{code-cell} ipython3 +:tags: [raises-exception] + +%sqlcmd snippets -d chinstrap +``` + +If you still wish to delete this snippet, you should use `force-delete` by running the below command: + +```{code-cell} ipython3 +%sqlcmd snippets -D chinstrap +``` + +Now, let's see how to delete a snippet and all other dependent snippets. We'll create a few snippets again. + +```{code-cell} ipython3 +:tags: [hide-output] + +%%sql --save chinstrap +SELECT * FROM penguins.csv where species == 'Chinstrap' +``` + +```{code-cell} ipython3 +:tags: [hide-output] + +%%sql --save chinstrap_sub +SELECT * FROM chinstrap where island == 'Dream' +``` + +Now, force delete `chinstrap` and its dependent `chinstrap_sub`: + +```{code-cell} ipython3 +%sqlcmd snippets -A chinstrap +``` + + +## Parameterizing arguments + +JupySQL supports variable expansion of arguments in the form of `{{variable}}`. This allows the user to specify arguments with placeholders that can be replaced by variables dynamically. + +Let's see some examples: + +```{code-cell} ipython3 +snippet_name = "gentoo" +``` + +```{code-cell} ipython3 +%%sql --save {{snippet_name}} +SELECT * FROM penguins.csv where species == 'Gentoo' +``` + +```{code-cell} ipython3 +gentoo_snippet = %sqlcmd snippets {{snippet_name}} +print(gentoo_snippet) +``` + +```{code-cell} ipython3 +%sqlcmd snippets -d {{snippet_name}} +``` \ No newline at end of file diff --git a/doc/api/magic-sql.md b/doc/api/magic-sql.md new file mode 100644 index 000000000..c5f4d3b75 --- /dev/null +++ b/doc/api/magic-sql.md @@ -0,0 +1,379 @@ +--- +jupytext: + notebook_metadata_filter: myst + text_representation: + extension: .md + format_name: myst + format_version: 0.13 + jupytext_version: 1.16.0 +kernelspec: + display_name: Python 3 (ipykernel) + language: python + name: python3 +myst: + html_meta: + description lang=en: Documentation for the %sql and %%sql magics from JupySQL + keywords: jupyter, sql, jupysql + property=og:locale: en_US +--- + +# `%sql`/`%%sql` + +```{note} +You can view the documentation and command line arguments by running `%sql?` +``` + +``-l`` / ``--connections`` + List all active connections ([example](#list-connections)) + +``-x`` / ``--close `` + Close named connection ([example](#close-connection)) + +``-c`` / ``--creator `` + Specify creator function for new connection ([example](#specify-creator-function)) + +``-s`` / ``--section `` + Section of dsn_file to be used for generating a connection string ([example](#start-a-connection-from-ini-file)) + +``-p`` / ``--persist`` + Create a table name in the database from the named DataFrame ([example](#create-table)) + +``--append`` + Like ``--persist``, but appends to the table if it already exists ([example](#append-to-table)) + +``--persist-replace`` + Like ``--persist``, but it will drop the existing table before inserting the new table ([example](#persist-replace-to-table)) + +``-a`` / ``--connection_arguments <"{connection arguments}">`` + Specify dictionary of connection arguments to pass to SQL driver + +``-f`` / ``--file `` + Run SQL from file at this path ([example](#run-query-from-file)) + +```{versionadded} 0.4.2 +``` + +``-n`` / ``--no-index`` + Do not persist data frame's index (used with `-p/--persist`) ([example](#create-table-without-dataframe-index)) + +```{versionadded} 0.4.3 +``` + +``-S`` / ``--save `` + Save this query for later use ([example](#compose-large-queries)) + +``-w`` / ``--with `` + Use a previously saved query (used after `-S/--save`) ([example](#compose-large-queries)) + +```{versionadded} 0.5.2 +``` + +``-A`` / ``--alias `` + Assign an alias when establishing a connection ([example](#connect-to-database)) + +```{code-cell} ipython3 +:tags: [remove-input] + +from pathlib import Path + +files = [Path("db_one.db"), Path("db_two.db"), Path("db_three.db"), Path("my_data.csv")] + +for f in files: + if f.exists(): + f.unlink() +``` + +## Initialization + +```{code-cell} ipython3 +%load_ext sql +``` + +## Connect to database + +```{code-cell} ipython3 +%sql sqlite:///db_one.db +``` + +Assign an alias to the connection (**added 0.5.2**): + +```{code-cell} ipython3 +%sql sqlite:///db_two.db --alias db-two +``` + +```{code-cell} ipython3 +%sql sqlite:///db_three.db --alias db-three +``` + +To make all subsequent queries to use certain connection, pass the connection name: + +```{code-cell} ipython3 +%sql db-two +``` + +```{code-cell} ipython3 +%sql db-three +``` + +You can inspect which is the current active connection: + +```{code-cell} ipython3 +%sql --connections +``` + +For more details on managing connections, see [Switch connections](../howto.md#switch-connections). + ++++ + +## List connections + +```{code-cell} ipython3 +%sql --connections +``` + +## Close connection + +```{code-cell} ipython3 +%sql --close sqlite:///db_one.db +``` + +Or pass an alias (**added in 0.5.2**): + +```{code-cell} ipython3 +%sql --close db-two +``` + +## Specify creator function + +```{code-cell} ipython3 +import os +import sqlite3 + +# Set environment variable $DATABASE_URL +os.environ["DATABASE_URL"] = "sqlite:///" + +# Define a function that returns a DBAPI connection + + +def creator(): + return sqlite3.connect("") +``` + +```{code-cell} ipython3 +%sql --creator creator +``` + +## Start a connection from `.ini` file + +```{versionchanged} 0.10.0 +`dsn_filename` default changed from `odbc.ini` to `~/.jupysql/connections.ini`. +``` + +Use `--section` to start a connection from the `dsn_filename`. To learn more, see: [](../user-guide/connection-file.md) + +By default, JupySQL reads connections from `~/.jupysql/connections.ini`, but you can set it to a different value: + +```{code-cell} ipython3 +%config SqlMagic.dsn_filename +``` + +```{code-cell} ipython3 +%config SqlMagic.dsn_filename = "connections.ini" +``` + +```{code-cell} ipython3 +%config SqlMagic.dsn_filename +``` + +```{code-cell} ipython3 +from pathlib import Path + +_ = Path("connections.ini").write_text( + """ +[mydb] +drivername = duckdb +""" +) +``` + +```{code-cell} ipython3 +%sql --section mydb +``` + +```{code-cell} ipython3 +%sql --connections +``` + +## Create table + +```{code-cell} ipython3 +%sql sqlite:// +``` + +```{code-cell} ipython3 +import pandas as pd + +my_data = pd.DataFrame({"x": range(3), "y": range(3)}) +``` + +```{code-cell} ipython3 +%sql --persist my_data +``` + +```{code-cell} ipython3 +%sql SELECT * FROM my_data +``` + +## Create table without `DataFrame` index + +```{code-cell} ipython3 +my_chars = pd.DataFrame({"char": ["a", "b", "c"]}) +my_chars +``` + +```{code-cell} ipython3 +%sql --persist my_chars --no-index +``` + +```{code-cell} ipython3 +%sql SELECT * FROM my_chars +``` + +## Append to table + +```{code-cell} ipython3 +my_data = pd.DataFrame({"x": range(3, 6), "y": range(3, 6)}) +``` + +```{code-cell} ipython3 +%sql --append my_data +``` + +```{code-cell} ipython3 +%sql SELECT * FROM my_data +``` + +## Persist replace to table + +```{code-cell} ipython3 +my_data = pd.DataFrame({"x": range(3), "y": range(3)}) +``` + +```{code-cell} ipython3 +%sql --persist-replace my_data --no-index +``` + +```{code-cell} ipython3 +%sql SELECT * FROM my_data +``` + +## Query + +```{code-cell} ipython3 +%sql SELECT * FROM my_data LIMIT 2 +``` + +```{code-cell} ipython3 +%%sql +SELECT * FROM my_data LIMIT 2 +``` + +## Programmatic SQL queries + +```{code-cell} ipython3 +QUERY = """ +SELECT * +FROM my_data +LIMIT 3 +""" + +%sql {{QUERY}} +``` + +## Templated SQL queries + +```{code-cell} ipython3 +target = 1 +``` + +```{code-cell} ipython3 +%%sql +SELECT * +FROM my_data +WHERE x = {{target}} +``` + +**Important:** Ensure you sanitize the input parameters; as malicious parameters will be able to run arbitrary SQL queries. + +For more information, visit [Parameterizing SQL queries](../user-guide/template.md) section. + ++++ + +## Compose large queries + +```{code-cell} ipython3 +%%sql --save larger_than_one --no-execute +SELECT x, y +FROM my_data +WHERE x > 1 +``` + +```{code-cell} ipython3 +%%sql +SELECT x, y +FROM larger_than_one +WHERE y < 5 +``` + +## Convert result to `pandas.DataFrame` + +```{code-cell} ipython3 +result = %sql SELECT * FROM my_data +df = result.DataFrame() +print(type(df)) +df.head() +``` + +## Store as CSV + +```{code-cell} ipython3 +result = %sql SELECT * FROM my_data +result.csv(filename="my_data.csv") +``` + +## Run query from file + +```{code-cell} ipython3 +from pathlib import Path + +# generate sql file +Path("my-query.sql").write_text( + """ +SELECT * +FROM my_data +LIMIT 3 +""" +) +``` + +```{code-cell} ipython3 +%sql --file my-query.sql +``` + +## Parameterizing arguments + +JupySQL supports variable expansion of arguments in the form of `{{variable}}`. This allows the user to specify arguments with placeholders that can be replaced by variables dynamically. + +Let's see an example of creating a connection using an alias and closing the same through variable substitution. + +```{code-cell} ipython3 +alias = "db-four" +``` + +```{code-cell} ipython3 +%sql sqlite:///db_four.db --alias {{alias}} +``` + +```{code-cell} ipython3 +%sql --close {{alias}} +``` \ No newline at end of file diff --git a/doc/api/magic-tables-columns.md b/doc/api/magic-tables-columns.md new file mode 100644 index 000000000..cef3d1798 --- /dev/null +++ b/doc/api/magic-tables-columns.md @@ -0,0 +1,152 @@ +--- +jupytext: + notebook_metadata_filter: myst + text_representation: + extension: .md + format_name: myst + format_version: 0.13 + jupytext_version: 1.14.5 +kernelspec: + display_name: Python 3 (ipykernel) + language: python + name: python3 +myst: + html_meta: + description lang=en: Documentation for the %sqlcmd tables and %sqlcmd columns + from JupySQL + keywords: jupyter, sql, jupysql, tables, columns + property=og:locale: en_US +--- + +# `%sqlcmd tables`/`%sqlcmd columns` + +`%sqlcmd tables` returns the current table names saved in environments. + +`%sqlcmd columns` returns the column information in a specified table. + +## Load Data + +```{code-cell} ipython3 +%load_ext sql +%sql duckdb:// +``` + +```{code-cell} ipython3 +from pathlib import Path +from urllib.request import urlretrieve + +if not Path("penguins.csv").is_file(): + urlretrieve( + "https://raw.githubusercontent.com/mwaskom/seaborn-data/master/penguins.csv", + "penguins.csv", + ) +``` + +```{code-cell} ipython3 +%%sql +SELECT * FROM penguins.csv LIMIT 3 +``` + +Let's save the file penguins.csv as a table penguins. + +```{code-cell} ipython3 +:tags: [hide-output] + +%%sql +DROP TABLE IF EXISTS penguins; + +CREATE TABLE penguins ( + species VARCHAR(255), + island VARCHAR(255), + bill_length_mm DECIMAL(5, 2), + bill_depth_mm DECIMAL(5, 2), + flipper_length_mm DECIMAL(5, 2), + body_mass_g INTEGER, + sex VARCHAR(255) +); + +COPY penguins FROM 'penguins.csv' WITH (FORMAT CSV, HEADER TRUE); +``` + +## `%sqlcmd tables` + ++++ + +Returns the current table names saved in environments. + +```{code-cell} ipython3 +%sqlcmd tables +``` + +Arguments: + +`-s`/`--schema` Get all table names under this schema + +To show the usage of schema, let's put two tables under two schema. +In this code example, we create schema s1 and s2. We put **t1** under schema s1, **t2** under schema s2 + +```{code-cell} ipython3 +:tags: [hide-output] + +%%sql +CREATE SCHEMA IF NOT EXISTS s1; +CREATE SCHEMA IF NOT EXISTS s2; +CREATE TABLE s1.t1(id INTEGER PRIMARY KEY, other_id INTEGER); +CREATE TABLE s2.t2(id INTEGER PRIMARY KEY, j VARCHAR); +``` + +```{code-cell} ipython3 +:tags: [hide-output] + +%sqlcmd tables -s s1 +``` + +JupySQL supports variable expansion of arguments in the form of `{{variable}}`. This allows the user to specify arguments with placeholders that can be replaced by variables dynamically. + +Let's see an example: + +```{code-cell} ipython3 +schema = "s1" +``` + +```{code-cell} ipython3 +:tags: [hide-output] + +%sqlcmd tables -s {{schema}} +``` + +As expected, the argument returns the table names under schema s1, which is t1. + ++++ + +## `%sqlcmd columns` + ++++ + +Arguments: + +`-t/--table` (Required) Get the column features of a specified table. + +`-s/--schema` (Optional) Get the column features of a table under a schema + +```{code-cell} ipython3 +%sqlcmd columns -t penguins +``` + +```{code-cell} ipython3 + +%sqlcmd columns -s s1 -t t1 +``` + +JupySQL also supports variable expansion of arguments of `columns`. Let's see an example: + +```{code-cell} ipython3 + +table = "t1" +schema = "s1" +``` + +```{code-cell} ipython3 + +%sqlcmd columns -s {{schema}} -t {{table}} +``` diff --git a/doc/api/plot-legacy.md b/doc/api/plot-legacy.md new file mode 100644 index 000000000..7dfb3f009 --- /dev/null +++ b/doc/api/plot-legacy.md @@ -0,0 +1,103 @@ +--- +jupytext: + notebook_metadata_filter: myst + cell_metadata_filter: -all + formats: md:myst + text_representation: + extension: .md + format_name: myst + format_version: 0.13 + jupytext_version: 1.14.4 +kernelspec: + display_name: Python 3 (ipykernel) + language: python + name: python3 +myst: + html_meta: + description lang=en: Create line, bar and pie charts from SQL queries in a Jupyter notebook using JupySQL + keywords: jupyter, sql, jupysql, plotting, matplotlib + property=og:locale: en_US +--- + +# Plotting (legacy API) + +```{note} +This is a legacy API that's kept for backwards compatibility. +``` + ++++ + +Ensure you have `matplotlib` installed: + +```{code-cell} ipython3 +%pip install matplotlib --quiet +``` + +```{code-cell} ipython3 +%load_ext sql +``` + +Connect to an in-memory SQLite database. + +```{code-cell} ipython3 +%sql sqlite:// +``` + +## Line + +```{code-cell} ipython3 +%%sql sqlite:// +CREATE TABLE points (x, y); +INSERT INTO points VALUES (0, 0); +INSERT INTO points VALUES (1, 1.5); +INSERT INTO points VALUES (2, 3); +INSERT INTO points VALUES (3, 3); +``` + +```{code-cell} ipython3 +points = %sql SELECT x, y FROM points +points.plot() +``` + +## Bar + ++++ + +*Note: sample data from the TIOBE index.* + +```{code-cell} ipython3 +%%sql sqlite:// +CREATE TABLE languages (name, rating, change); +INSERT INTO languages VALUES ('Python', 14.44, 2.48); +INSERT INTO languages VALUES ('C', 13.13, 1.50); +INSERT INTO languages VALUES ('Java', 11.59, 0.40); +INSERT INTO languages VALUES ('C++', 10.00, 1.98); +``` + +```{code-cell} ipython3 +change = %sql SELECT name, change FROM languages +change.bar() +``` + +## Pie + +Data from [Our World in Data.](https://ourworldindata.org/grapher/energy-consumption-by-source-and-country?time=latest) + +```{code-cell} ipython3 +%%sql sqlite:// +CREATE TABLE energy_2021 (source, percentage); +INSERT INTO energy_2021 VALUES ('Oil', 31.26); +INSERT INTO energy_2021 VALUES ('Coal', 27.17); +INSERT INTO energy_2021 VALUES ('Gas', 24.66); +INSERT INTO energy_2021 VALUES ('Hydropower', 6.83); +INSERT INTO energy_2021 VALUES ('Nuclear', 4.3); +INSERT INTO energy_2021 VALUES ('Wind', 2.98); +INSERT INTO energy_2021 VALUES ('Solar', 1.65); +INSERT INTO energy_2021 VALUES ('Biofuels', 0.70); +INSERT INTO energy_2021 VALUES ('Other renewables', 0.47); +``` + +```{code-cell} ipython3 +energy = %sql SELECT source, percentage FROM energy_2021 +energy.pie() +``` diff --git a/doc/api/python.rst b/doc/api/python.rst new file mode 100644 index 000000000..3954e391f --- /dev/null +++ b/doc/api/python.rst @@ -0,0 +1,53 @@ +Python API +========== + +JupySQL is primarily used via the ``%sql``, ``%%sql``, and ``%sqlplot`` magics; however +there is a public Python API you can also use. + +``sql.plot`` +------------ + +.. note:: + + ``sql.plot`` requires ``matplotlib``: ``pip install matplotlib`` + + +The ``sql.plot`` module implements functions that compute the summary statistics +in the database, a much more scalable approach that loading all your data into +memory with pandas. + +``histogram`` +************* + +.. autofunction:: sql.plot.histogram + + +``boxplot`` +*********** + +.. autofunction:: sql.plot.boxplot + + +``sql.store`` +------------- + +The ``sql.store`` module implements utilities to compose and manage large SQL queries + + +``SQLStore`` +************ + +.. autoclass:: sql.store.SQLStore + :members: + + +``sql.run.run`` +--------------- + +The ``sql.run.run`` module implements utility function for running SQL statements with the given connection. + +``run_statements`` +****************** + +.. autofunction:: sql.run.run.run_statements + diff --git a/doc/community/FAQ.md b/doc/community/FAQ.md new file mode 100644 index 000000000..d4190de72 --- /dev/null +++ b/doc/community/FAQ.md @@ -0,0 +1,62 @@ +--- +jupytext: + notebook_metadata_filter: myst + text_representation: + extension: .md + format_name: myst + format_version: 0.13 + jupytext_version: 1.14.5 +kernelspec: + display_name: Python 3 (ipykernel) + language: python + name: python3 +--- + ++++ {"user_expressions": []} + +# FAQ + +## What is a magic? + +One may be unfamiliar with the commands prefixed with `%` used in this instruction. Here is a detailed description of this command and its usage. + +### Definition of Jupyter Magic + +Magics are specific to and provided by the IPython kernel. Some common usage of magic functions are: running external code files, timing code execution, and loading IPython Extensions. + +Suppose execute.py is a python code file + +``` +%run execute.py +%timeit L = [n ** 2 for n in range(1000)] (Timing executions -- will return 1000 loops, best of 3: 325 µs per loop) +``` + +In our code above, we use **%load_ext** to load an IPython extension by its module name, `sql`, and then directly use the extension by using `%sql`. + +``` +load an IPython extension by its module name. +%load_ext sql +``` + +### Line Magic VS Cell Magic + +**Line magics**, which are denoted by a single % prefix and operate on a single line of input, and **cell magics**, which are denoted by a double %% prefix and operate on multiple lines of input. + +For example, for the code above, **%sql** is a line magic, and **%%sql** is a code magic. + +### Reference + +[IPython doc](https://ipython.readthedocs.io/en/stable/interactive/magics.html#cell-magics) + +[Python Data Science Handbook](https://jakevdp.github.io/PythonDataScienceHandbook/01.03-magic-commands.html) + ++++ {"user_expressions": []} + +## Connecting to `impala` + +For an `impala` connection with [`impyla`](https://github.com/cloudera/impyla) for HiveServer2, you need to disable autocommit: + +``` +%config SqlMagic.autocommit=False +%sql impala://hserverhost:port/default?kerberos_service_name=hive&auth_mechanism=GSSAPI +``` diff --git a/doc/community/coc.md b/doc/community/coc.md new file mode 100644 index 000000000..f25a76e19 --- /dev/null +++ b/doc/community/coc.md @@ -0,0 +1,5 @@ +# Code of Conduct + +Ploomber has been committed to build the product by making it easy for community users to facilitate the day-to-day work. + +For more details, see [here](https://docs.ploomber.io/en/latest/community/coc.html) \ No newline at end of file diff --git a/doc/community/credits.md b/doc/community/credits.md new file mode 100644 index 000000000..33bc90155 --- /dev/null +++ b/doc/community/credits.md @@ -0,0 +1,26 @@ +# Credits + +JupySQL would not be possible without the extraordinary work of Catherine Devlin, the original author of `ipython-sql`, which JupySQL is a fork of. Here is the list of other individuals that contributed to `ipython-sql` (taken from the original repository): + +- Matthias Bussonnier for help with configuration +- Olivier Le Thanh Duong for ``%config`` fixes and improvements +- Distribute_ +- Buildout_ +- modern-package-template_ +- Mike Wilson for bind variable code +- Thomas Kluyver and Steve Holden for debugging help +- Berton Earnshaw for DSN connection syntax +- Bruno Harbulot for DSN example +- Andrés Celis for SQL Server bugfix +- Michael Erasmus for DataFrame truth bugfix +- Noam Finkelstein for README clarification +- Xiaochuan Yu for `<<` operator, syntax colorization +- Amjith Ramanujam for PGSpecial and incorporating it here +- Alexander Maznev for better arg parsing, connections accepting specified creator +- Jonathan Larkin for configurable displaycon +- Jared Moore for ``connection-arguments`` support +- Gilbert Brault for ``--append`` +- Lucas Zeer for multi-line bugfixes for var substitution, ``<<`` +- vkk800 for ``--file`` +- Jens Albrecht for MySQL DatabaseError bugfix +- meihkv for connection-closing bugfix diff --git a/doc/community/developer-guide.md b/doc/community/developer-guide.md new file mode 100644 index 000000000..c4b05710d --- /dev/null +++ b/doc/community/developer-guide.md @@ -0,0 +1,639 @@ +--- +jupytext: + notebook_metadata_filter: myst + text_representation: + extension: .md + format_name: myst + format_version: 0.13 + jupytext_version: 1.15.1 +kernelspec: + display_name: Python 3 (ipykernel) + language: python + name: python3 +myst: + html_meta: + description lang=en: JupySQL's developer guide + keywords: jupyter, sql, jupysql + property=og:locale: en_US +--- + +# Developer guide + +Before continuing, ensure you have a [working development environment locally](https://ploomber-contributing.readthedocs.io/en/latest/contributing/setup.html) or on [github codespaces](https://github.com/features/codespaces). + +## Github Codespace + +Github Codespaces allow you to spin up a fully configured dev environment in the cloud in a few minutes. Github provides 60 hours a month of free usage (for a 2-core codespace). While codespaces will automatically pauze after 30 min of idle time, it's a good idea to shut your codespace down entirely via [the management dashboard](https://github.com/codespaces) and to [setup spending limits](https://github.com/settings/billing/spending_limit) to avoid unexpected charges. + +![JupySQL github codespace](../static/github-codespace.png) +You can launch a new github codespace from the green "Code" button on [the JupySQL github repository](https://github.com/ploomber/jupysql). + +Note that setup will take a few minutes to finish after the codespace becomes available (wait for the **postCreateCommand** step to finish). +![JupySQL github codespace](../static/github-codespace-setup.png) + +After the codespace has finished setting up, you can run `conda activate jupysql` to activate the JupySQL Conda environment. + ++++ + +## The basics + +JupySQL is a Python library that allows users to run SQL queries (among other things) in IPython and Jupyter via a `%sql`/`%%sql` [magic](https://ipython.readthedocs.io/en/stable/interactive/magics.html): + +```{code-cell} ipython3 +%load_ext sql +``` + +```{code-cell} ipython3 +%sql duckdb:// +``` + +```{code-cell} ipython3 +%sql SELECT 42 +``` + +However, there is also a Python API. For example, users can create plots using the `ggplot` module: + +```{code-cell} ipython3 +from sql.ggplot import ggplot # noqa +``` + +So depending on which API is called, the behavior differs. Most notably, when using `%sql`/`%%sql` and other magics, Python tracebacks are hidden, since they're not relevant to the user. For example, if a user tries to query a non-existent table, we won't show the Python traceback: + +```{code-cell} ipython3 +:tags: [raises-exception] + +%sql SELECT * FROM not_a_table +``` + +On the other hand, if they're using the Python API, we'll show a full traceback. + ++++ + +## Displaying messages + +```{important} +Use the `sql.display` module instead of `print` for showing feedback to the user. +``` + +You can use `message` (contextual information) and `message_success` (successful operations) to show feedback to the user. Here's an example: + +```{code-cell} ipython3 +from sql.display import message, message_success +``` + +```{code-cell} ipython3 +message("Some information") +``` + +```{code-cell} ipython3 +message_success("Some operation finished successfully!") +``` + +You can use `message_html` to embed a link in a message and point users to certain sections in our docs. Here's an example: + +```{code-cell} ipython3 +from sql.display import message_html, Link +``` + +```{code-cell} ipython3 +message_html(["Go to our", Link("home", "https://ploomber.io"), "page"]) +``` + +`message_html` will detect the running environment and display `Go to our home (https://ploomber.io) page` message instead if feedback is shown through a terminal. + ++++ + +## Throwing errors + +When writing Python libraries, we often throw errors (and display error tracebacks) to let users know that something went wrong. However, JupySQL is an abstraction for executing SQL queries; hence, Python tracebacks a useless to end-users since they expose JupySQL's internals. + +So in most circumstances, we only display an error without a traceback. For example, when calling `%sqlplot` without arguments, we get an error: + +```{code-cell} ipython3 +:tags: [raises-exception] + +%sqlplot +``` + +To implement such behavior, you can use any of the functions defined in `sql.exceptions`, or implement your own. For example, we have a `UsageError` that can be raised when users pass incorrect arguments: + +```{code-cell} ipython3 +:tags: [raises-exception] + +from sql import exceptions + +raise exceptions.UsageError("something bad happened") +``` + +There are other exceptions available, if nothing fits in your scenario, you can add new ones. + +```{code-cell} ipython3 +:tags: [raises-exception] + +raise exceptions.ValueError("something bad happened") +``` + +```{important} +These errors that hide the traceback should only be used in the `%sql`/`%%sql` magic context. For example, in our ggplot API (Python-based), we do not hide tracebacks as users might need them to debug their code +``` + ++++ + +## Getting connections + +When adding features to JupySQL magics (`%sql/%%sql`), you can use the `ConnectionManager` to get the current open connections. + +```{code-cell} ipython3 +:tags: [remove-output] + +%load_ext sql +``` + +```{code-cell} ipython3 +import sqlite3 + +conn = sqlite3.connect("") + +%sql sqlite:// --alias sqlite-sqlalchemy +%sql conn --alias sqlite-dbapi +``` + +We can access the current connection using `ConnectionManager.current`: + +```{code-cell} ipython3 +from sql.connection import ConnectionManager + +conn = ConnectionManager.current +conn +``` + +To get all open connections: + +```{code-cell} ipython3 +ConnectionManager.connections +``` + +## Using connections + +Connections are either `SQLAlchemyConnection` or `DBAPIConnection` object. Both have the same interface, the difference is that the first one is a connection established via SQLAlchemy and `DBAPIConnection` one is a connection established by an object that follows the [Python DB API](https://peps.python.org/pep-0249/). + +```{code-cell} ipython3 +conn_sqlalchemy = ConnectionManager.connections["sqlite-sqlalchemy"] +conn_dbapi = ConnectionManager.connections["sqlite-dbapi"] +``` + +### `raw_execute` + +```{important} +Always use `raw_execute` for user-submitted queries! +``` + +`raw_execute` allows you to execute a given SQL query in the connection. Unlike `execute`, `raw_execute` does not perform any [transpilation](#sql-transpilation). + +```{code-cell} ipython3 +conn_sqlalchemy.raw_execute("CREATE TABLE foo (bar INT);") +conn_sqlalchemy.raw_execute("INSERT INTO foo VALUES (42), (43), (44), (45);") +results = conn_sqlalchemy.raw_execute("SELECT * FROM foo") +print("one: ", results.fetchone()) +print("many: ", results.fetchmany(size=1)) +print("all: ", results.fetchall()) +``` + +```{code-cell} ipython3 +conn_dbapi.raw_execute("CREATE TABLE foo (bar INT);") +conn_dbapi.raw_execute("INSERT INTO foo VALUES (42), (43), (44), (45);") +results = conn_dbapi.raw_execute("SELECT * FROM foo") +print("one: ", results.fetchone()) +print("many: ", results.fetchmany(size=1)) +print("all: ", results.fetchall()) +``` + +### `execute` + +```{important} +Only use `execute` for internal queries! (queries defined in our own codebase, not +queries we receive as strings from the user.) +``` + +`execute` allows you to run a query but it transpiles it so it's compatible with the target database. + +Since each database SQL dialect is slightly different, we cannot write a single SQL query and expect it to work across all databases. + +For example, in our `plot.py` module we have internal SQL queries for generating plots. However, the queries are designed to work with DuckDB and PostgreSQL, for any other databases, we rely on a transpilation process that converts our query into another one compatible with the target database. Note that this process isn't perfect and it fails often. So whenever you add a new feature ensure that your queries work at least on DuckDB and PostgreSQL, then write integration tests with all the remaining databases and for those that fail, add an `xfail` mark. Then, we can decide which databases we support for which features. + +Note that since `execute` has a transpilation process, it should only be used for internal queries, and not for user-submitted ones. + +```{code-cell} ipython3 +results = conn_sqlalchemy.execute("SELECT * FROM foo") +print("one: ", results.fetchone()) +print("many: ", results.fetchmany(size=1)) +print("all: ", results.fetchall()) +``` + ++++ {"jp-MarkdownHeadingCollapsed": true} + +### Writing functions that use connections + +Functions that expect a `conn` (sometimes named `con`) input variable should assume the input argument is a connection objects (either `SQLAlchemyConnection` or `DBAPIConnection`): + +```python +def histogram(table, column, bins, with_=None, conn=None): + pass +``` + ++++ + +### Reading snippets + +JupySQL allows users to store snippets: + +```{code-cell} ipython3 +%sql sqlite-sqlalchemy +``` + +```{code-cell} ipython3 +%%sql --save fav_number +SELECT * FROM foo WHERE bar = 42 +``` + +These snippets help them break complex logic in multiple cells and automatically generate CTEs. Now that we saved `fav_number` we can run `SELECT * FROM fav_number`, and JupySQL will automatically build the CTE: + +```{code-cell} ipython3 +%%sql +SELECT * FROM fav_number WHERE bar = 42 +``` + +In some scenarios, we want to allow users to use existing snippets for certain features. For example, we allow them to define a snippet and then plot the results using `%sqlplot`. If you're writing a feature that should support snippets, then you can use the `with_` argument in `raw_execute` and `execute`: + +#### `SQlAlchemyConnection` + +```{code-cell} ipython3 +results = conn_sqlalchemy.raw_execute("SELECT * FROM fav_number", with_=["fav_number"]) +results.fetchall() +``` + +#### `DBAPIConnection` + +```{code-cell} ipython3 +results = conn_dbapi.raw_execute("SELECT * FROM fav_number", with_=["fav_number"]) +results.fetchall() +``` + +### `dialect` + +If you need to know the database dialect, you can access the `dialect` property in `SQLAlchemyConnection`s: + +```{code-cell} ipython3 +conn_sqlalchemy.dialect +``` + +Dialect in `DBAPIConnection` is only implemented for DuckDB, for all others, it currently returns `None`: + +```{code-cell} ipython3 +conn_dbapi.dialect is None +``` + +## Testing + +### Running unit tests + +Unit tests are executed on each PR; however, you might need to run them locally. + +To run all unit tests: + +```sh +pytest --ignore=src/tests/integration +``` + +Some unit tests compare reference images with images produced by the test; such tests might fail depending on your OS, to skip them: + +```sh +pytest src/tests/ --ignore src/tests/integration --ignore src/tests/test_ggplot.py --ignore src/tests/test_magic_plot.py +``` + +To run a specific file: + +```sh +pytest src/tests/TEST_FILE_NAME.py +``` + ++++ + +### Running tests with nox + +We use [`nox`](https://github.com/wntrblm/nox) to run the unit and integration tests in the CI. `nox` automates creating an environment with all the dependencies and then running the tests, while using `pytest` assumes you already have all dependencies installed in the current environment. + +If you want to use `nox` locally, check out the [`noxfile.py`](https://github.com/ploomber/jupysql/blob/master/noxfile.py), and for examples, see the [GitHub Actions configuration](https://github.com/ploomber/jupysql/tree/master/.github/workflows). + ++++ + +### Writing tests for magics (e.g., `%sql`, `%%sql`, etc) + +This guide will show you the basics of writing unit tests for JupySQL magics. Magics are commands that begin with `%` (line magics) and `%%` (cell magics). + +In the unit testing suite, there are a few pytest fixtures that prepare the environment so you can get started: + +- `ip_empty` - Empty IPython session (no database connections, no data) +- `ip` - IPython session with some sample data and a SQLite connection +- To check the other available fixtures, see the `conftest.py` files + +So a typical test will look like this: + +```{code-cell} ipython3 +def test_something(ip): + result = ip.run_cell( + """%%sql + SELECT * FROM test + """ + ) + + assert result.success +``` + +To see some sample tests, [click here.](https://github.com/ploomber/jupysql/blob/master/src/tests/test_magic.py) + + +The `ip` object is an IPython session that is created like this: + +```{code-cell} ipython3 +from sql._testing import TestingShell +from sql.magic import SqlMagic + +ip_session = TestingShell() +ip_session.register_magics(SqlMagic) +``` + +To run some code: + +```{code-cell} ipython3 +out = ip_session.run_cell("1 + 1") +``` + +To test the output: + +```{code-cell} ipython3 +assert out.result == 2 +``` + +You can then use pytest to check for errors: + +```{code-cell} ipython3 +import pytest +``` + +```{code-cell} ipython3 +with pytest.raises(ZeroDivisionError): + ip_session.run_cell("1 / 0") +``` + +To check the error message: + +```{code-cell} ipython3 +with pytest.raises(ZeroDivisionError) as excinfo: + ip_session.run_cell("1 / 0") +``` + +```{code-cell} ipython3 +assert str(excinfo.value) == "division by zero" +``` + +### Unit testing custom errors + +The internal implementation of `sql.exceptions` is a workaround due to some IPython limitations; in consequence, you need to test for `IPython.error.UsageError` when checking if a given code raises any of the errors in `sql.exceptions`, see `test_util.py` for examples, and `exceptions.py` for more details. + +```{code-cell} ipython3 +from IPython.core.error import UsageError + +ip_session.run_cell("from sql.exceptions import MissingPackageError") + +# always test for UsageError, even if checking for another error from sql.exceptions! +with pytest.raises(UsageError) as excinfo: + ip_session.run_cell("raise MissingPackageError('something happened')") +``` + +### Integration tests + +Integration tests check compatibility with different databases. They are executed on +each PR; however, you might need to run them locally. + +```{note} +Setting up the development environment for running integration tests locally +is challenging given the number of dependencies. If you have problems, +[message us on Slack.](https://ploomber.io/community) +``` + +Ensure you have [Docker Desktop](https://docs.docker.com/desktop/) before continuing. + +To install all dependencies: + +```sh +# create development environment (you can skip this if you already executed it) +pkgmt setup + +# activate environment +conda activate jupysql + +# install dependencies +pip install -e '.[integration]' +``` + +```{tip} +Ensure Docker is running before continuing! +``` + +To run all integration tests (the tests are pre-configured to start and shut down +the required Docker images): + +```sh +pytest src/tests/integration +``` + +```{important} +If you're using **Apple M chips**, the docker container on Oracle Database might fail since it's only supporting to x86_64 CPU. + +You will need to install [colima](https://github.com/abiosoft/colima) then run `colima start --cpu 4 --memory 4 --disk 30 --arch x86_64` before running the integration testing. [See more](https://hub.docker.com/r/gvenzl/oracle-xe) + +Send us a [message on Slack](https://ploomber.io/community) if any issue happens. +``` + +To run some of the tests: + +```sh +pytest src/tests/integration/test_generic_db_operations.py::test_profile_query +``` + +To run tests for a specific database: + +```sh +pytest src/tests/integration -k duckdb +``` + +To see the databases available, check out [`src/tests/integration/conftest.py`](https://github.com/ploomber/jupysql/blob/master/src/tests/integration/conftest.py) + + +### Integration tests with cloud databases + +Currently, we do not run integration tests against cloud databases like Snowflake and Amazon Redshift. + +To run Snowflake integration tests locally first set your Snowflake account's username and password: + +```bash +export SF_USERNAME="username" +export SF_PASSWORD="password" +``` + +Then run the pytest command: + +```bash +pytest src/tests/integration -k snowflake +``` + +Similarly, for Redshift, set the following environment variables: + +```bash +export REDSHIFT_USERNAME="username" +export REDSHIFT_PASSWORD="password" +export REDSHIFT_HOST="host" +``` + +Then run the below command: + +```bash +pytest src/tests/integration -k redshift +``` + +#### Using Snowflake + +While testing manually with Snowflake, you may run into the below error: + +``` +No active warehouse selected in the current session. Select an active warehouse with the 'use warehouse' command. +``` + +This occurs when you have connected with a registered account but have no current warehouses. If you have permission to create one, open a worksheet and run: + +```sql +CREATE WAREHOUSE WITH WAREHOUSE_SIZE = +``` + +If you need permissions, have the admin run: + +```sql +CREATE ROLE create_wh_role; +GRANT ROLE create_wh_role TO USER ; +GRANT CREATE WAREHOUSE ON ACCOUNT TO ROLE create_wh_role; +``` + +Now, open your own worksheet and run: + +```sql +USE ROLE create_wh_role; +CREATE WAREHOUSE WITH WAREHOUSE_SIZE = +``` + +Now, initiate a connection using your new warehouse and run your tests/queries. ++++ + +## SQL transpilation + +As our codebase is expanding, we have noticed that we need to write SQL queries for different database dialects such as MySQL, PostgreSQL, SQLite, and more. Writing and maintaining separate queries for each database can be time-consuming and error-prone. + +To address this issue, we can use `sqlglot` to create a construct that can be compiled across multiple SQL dialects. This clause will allow us to write a single SQL query that can be translated to different database dialects, then use it for calculating the metadata (e.g. metadata used by boxplot) + +In this section, we'll explain how to build generic SQL constructs and provide examples of how it can be used in our codebase. We will also include instructions on how to add support for additional database dialects. + +### Approach 1 - Provide the general SQL Clause + +We can use [SQLGlot](https://sqlglot.com/sqlglot.html) to build the general sql expressions. + +Then transpile to the sql which is supported by current connected dialect. + +Our `sql.SQLAlchemyConnection._transpile_query` will automatically detect the dialect and transpile the SQL clause. + +#### Example + +```{code-cell} ipython3 +# Prepare connection +from sqlglot import select, condition +from sql.connection import SQLAlchemyConnection +from sqlalchemy import create_engine + +conn = SQLAlchemyConnection(engine=create_engine(url="sqlite://")) +``` + +```{code-cell} ipython3 +# Prepare SQL Clause +where = condition("x=1").and_("y=1") +general_sql = select("*").from_("y").where(where).sql() + +print("General SQL Clause: ") +print(f"{general_sql}\n") +``` + +```{code-cell} ipython3 +# Result +print("Transpiled result: ") +conn._transpile_query(general_sql) +``` + +### Approach 2 - Provide SQL Clause based on specific database + +Sometimes the SQL Clause might be complex, we can also write the SQL Clause based on one specific database and transpile it. + +For example, the `TO_TIMESTAMP` keyword is only defined in duckdb, but we want to also apply this SQL clause to other database. + +We may provide `sqlglot.parse_one({source_sql_clause}, read={source_database_dialect}).sql()` as input sql to `_transpile_query()` + +#### When current connection is via duckdb + +##### Prepare connection + +```{code-cell} ipython3 +from sql.connection import SQLAlchemyConnection +from sqlalchemy import create_engine +import sqlglot + +conn = SQLAlchemyConnection(engine=create_engine(url="duckdb://")) +``` + +##### Prepare SQL clause based on duckdb syntax + +```{code-cell} ipython3 +input_sql = sqlglot.parse_one("SELECT TO_TIMESTAMP(1618088028295)", read="duckdb").sql() +``` + +##### Transpiled Result + +```{code-cell} ipython3 +conn._transpile_query(input_sql) +``` + +#### When current connection is via sqlite + + +##### Prepare connection + +```{code-cell} ipython3 +from sql.connection import SQLAlchemyConnection +from sqlalchemy import create_engine + +conn = SQLAlchemyConnection(engine=create_engine(url="sqlite://")) +``` + +##### Prepare SQL clause based on sqlite + +```{code-cell} ipython3 +input_sql = sqlglot.parse_one("SELECT TO_TIMESTAMP(1618088028295)", read="duckdb").sql() +``` + +##### Transpiled Result + +```{code-cell} ipython3 +conn._transpile_query(input_sql) +``` + +As you can see, output results are different + +From duckdb dialect: `'SELECT TO_TIMESTAMP(1618088028295)'` + +From sqlite dialect: `'SELECT UNIX_TO_TIME(1618088028295)'` diff --git a/doc/community/projects.md b/doc/community/projects.md new file mode 100644 index 000000000..0bcf669c5 --- /dev/null +++ b/doc/community/projects.md @@ -0,0 +1,7 @@ +# Other projects + +Check out other amazing projects brought to you by the [Ploomber](https://ploomber.io/) team! + +- [sklearn-evaluation](https://github.com/ploomber/sklearn-evaluation): Plots 📊 for evaluating ML models, experiment tracking, and more! +- [ploomber-engine](https://github.com/ploomber/ploomber-engine): A toolbox 🧰 for executing, testing, debugging, and profiling Jupyter notebooks +- [ploomber](https://github.com/ploomber/ploomber): A framework to build and deploy data pipelines ☁️ \ No newline at end of file diff --git a/doc/community/support.md b/doc/community/support.md new file mode 100644 index 000000000..8c28c4f08 --- /dev/null +++ b/doc/community/support.md @@ -0,0 +1,3 @@ +# Support + +For support, feature requests, and product updates: [join our community](https://ploomber.io/community) or follow us on [Twitter](https://twitter.com/ploomber)/[LinkedIn](https://www.linkedin.com/company/ploomber/). diff --git a/doc/community/vs.md b/doc/community/vs.md new file mode 100644 index 000000000..2d393700c --- /dev/null +++ b/doc/community/vs.md @@ -0,0 +1,21 @@ +# JupySQL vs ipython-sql + +JupySQL is an actively maintained fork of [ipython-sql](https://github.com/catherinedevlin/ipython-sql); it is a drop-in replacement for 99% cases with a lot of new features. + +## Incompatibilities + +If you're migrating from `ipython-sql` to JupySQL, these are the differences (in most cases, no code changes are needed): + +- Since `0.6` JupySQL no longer supports old versions of IPython +- Variable expansion is replaced from `{variable}`, `${variable}` to `{{variable}}` +- Variable expansion via `:variable` has been disable by default, but can be enabled with [`%config SqlMagic.named_parameters = True`](../api/configuration) +- Since `0.10.0`, loading connections from a `.ini` file using `%sql [section_name]` has been deprecated. Use `%sql --section section_name` instead. + +## New features + +- [Plotting](../plot) module that allows you to efficiently plot massive datasets without running out of memory. +- JupySQL allows you to break queries into multiple cells with the help of CTEs. [Click here](../compose) to learn more. +- Using `%sqlcmd tables` and `%sqlcmd columns --table/-t` user can quickly explore tables in the database and the columns each table has. [Click here](../user-guide/tables-columns) to learn more. +- [Polars Integration](../integrations/polars) to convert query results to `polars.DataFrame`. `%config SqlMagic.autopolars` can be used to automatically return Polars DataFrames instead of regular result sets. +- Integration tests with PostgreSQL, MariaDB, MySQL, SQLite and DuckDB. +- The configuration default value of SqlMagic.displaylimit is different, in JupySQL is `10`, whereas in ipython-sql is `None` diff --git a/doc/compose.md b/doc/compose.md new file mode 100644 index 000000000..f4855393f --- /dev/null +++ b/doc/compose.md @@ -0,0 +1,209 @@ +--- +jupytext: + notebook_metadata_filter: myst + text_representation: + extension: .md + format_name: myst + format_version: 0.13 + jupytext_version: 1.14.6 +kernelspec: + display_name: Python 3 (ipykernel) + language: python + name: python3 +myst: + html_meta: + description lang=en: Use JupySQL to organize large SQL queries in a Jupyter notebook + keywords: jupyter, sql, jupysql + property=og:locale: en_US +--- + +# Organizing Large Queries + + +```{dropdown} Required packages +~~~ +pip install jupysql matplotlib +~~~ +``` + + +```{versionchanged} 0.8.0 +``` + +```{note} +This is a beta feature, please [join our community](https://ploomber.io/community) and +let us know how we can improve it! +``` + +JupySQL allows you to break queries into multiple cells, simplifying the process of building large queries. + +- **Simplify and modularize your workflow:** JupySQL simplifies SQL queries and promotes code reusability by breaking down large queries into manageable chunks and enabling the creation of reusable query modules. +- **Seamless integration:** JupySQL flawlessly combines the power of SQL with the flexibility of Jupyter Notebooks, offering a one-stop solution for all your data analysis needs. +- **Cross-platform compatibility:** JupySQL supports popular databases like PostgreSQL, MySQL, SQLite, and more, ensuring you can work with any data source. + +## Example: record store data + +### Goal: + +Using Jupyter notebooks, make a query against an SQLite database table named 'Track' with Rock and Metal song information. Find and show the artists with the most Rock and Metal songs. Show your results in a bar chart. + + +#### Data download and initialization + +Download the SQLite database file if it doesn't exist + +```{code-cell} ipython3 +import urllib.request +from pathlib import Path + +if not Path("my.db").is_file(): + url = "https://raw.githubusercontent.com/lerocha/chinook-database/master/ChinookDatabase/DataSources/Chinook_Sqlite.sqlite" # noqa + urllib.request.urlretrieve(url, "my.db") +``` + +Initialize the SQL extension and set autolimit=3 to only retrieve a few rows + +```{code-cell} ipython3 +%load_ext sql +``` + +```{code-cell} ipython3 +%config SqlMagic.autolimit = 3 +``` + +Query the track-level information from the Track table + +```{code-cell} ipython3 +%%sql sqlite:///my.db +SELECT * FROM Track +``` + +#### Data wrangling + +Join the Track, Album, and Artist tables to get the artist name, and save the query as `tracks_with_info` + +*Note: `--save` stores the query, not the data* + +```{code-cell} ipython3 +%%sql --save tracks_with_info +SELECT t.*, a.title AS album, ar.Name as artist +FROM Track t +JOIN Album a +USING (AlbumId) +JOIN Artist ar +USING (ArtistId) +``` + +Filter genres we are interested in (Rock and Metal) and save the query as `genres_fav` + +```{code-cell} ipython3 +%%sql --save genres_fav +SELECT * FROM Genre +WHERE Name +LIKE '%rock%' +OR Name LIKE '%metal%' +``` + +Join the filtered genres and tracks, so we only get Rock and Metal tracks, and save the query as `track_fav` + + +We automatically extract the tables from the query and infer the dependencies from all the saved snippets. + + +```{code-cell} ipython3 +%%sql --save track_fav +SELECT t.* +FROM tracks_with_info t +JOIN genres_fav +ON t.GenreId = genres_fav.GenreId +``` + +Now let's find artists with the most Rock and Metal tracks, and save the query as `top_artist` + +```{code-cell} ipython3 +%%sql --save top_artist +SELECT artist, COUNT(*) FROM track_fav +GROUP BY artist +ORDER BY COUNT(*) DESC +``` + + +```{note} +A saved snippet will override an existing table with the same name during query formation. If you wish to delete a snippet please refer to [sqlcmd snippets API](api/magic-snippets.md). + +``` + +#### Data visualization + +Once we have the desired results from the query `top_artist`, we can generate a visualization using the bar method + +```{code-cell} ipython3 +top_artist = %sql SELECT * FROM top_artist +top_artist.bar() +``` + +It looks like Iron Maiden had the highest number of rock and metal songs in the table. + +We can render the full query with the `%sqlcmd snippets {name}` magic: + +```{code-cell} ipython3 +final = %sqlcmd snippets top_artist +print(final) +``` + +We can verify the retrieved query returns the same result: + +```{code-cell} ipython3 +%%sql +{{final}} +``` + +#### `--with` argument + +JupySQL also allows you to specify the snippet name explicitly by passing the `--with` argument. This is particularly useful when our parsing logic is unable to determine the table name due to dialect variations. For example, consider the below example: + +```{code-cell} ipython3 +%sql duckdb:// +``` + +```{code-cell} ipython3 +%%sql --save first_cte --no-execute +SELECT 1 AS column1, 2 AS column2 +``` + +```{code-cell} ipython3 +%%sql --save second_cte --no-execute +SELECT + sum(column1), + sum(column2) FILTER (column2 = 2) +FROM first_cte +``` + +```{code-cell} ipython3 +:tags: [raises-exception] + +%%sql +SELECT * FROM second_cte +``` + +Note that the query fails because the clause `FILTER (column2 = 2)` makes it difficult for the parser to extract the table name. While this syntax works on some dialects like `DuckDB`, the more common usage is to specify `WHERE` clause as well, like `FILTER (WHERE column2 = 2)`. + +Now let's run the same query by specifying `--with` argument. + +```{code-cell} ipython3 +%%sql --with first_cte --save second_cte --no-execute +SELECT + sum(column1), + sum(column2) FILTER (column2 = 2) +FROM first_cte +``` + +```{code-cell} ipython3 +%%sql +SELECT * FROM second_cte +``` + + +## Summary + +In the given example, we demonstrated JupySQL's usage as a tool for managing large SQL queries in Jupyter Notebooks. It effectively broke down a complex query into smaller, organized parts, simplifying the process of analyzing a record store's sales database. By using JupySQL, users can easily maintain and reuse their queries, enhancing the overall data analysis experience. diff --git a/doc/conf.py b/doc/conf.py new file mode 100644 index 000000000..49ca8fe0d --- /dev/null +++ b/doc/conf.py @@ -0,0 +1,139 @@ +from pkgmt.github import get_repo_and_branch_for_readthedocs + +repository_url, repository_branch = get_repo_and_branch_for_readthedocs( + repository_url="https://github.com/ploomber/jupysql", + default_branch="master", +) + +############################################################################### +# Auto-generated by `jupyter-book config` +# If you wish to continue using _config.yml, make edits to that file and +# re-generate this one. +############################################################################### +author = "Ploomber" +comments_config = {"hypothesis": False, "utterances": False} +copyright = "2023" +exclude_patterns = ["**.ipynb_checkpoints", ".DS_Store", "Thumbs.db", "_build"] +nb_execution_allow_errors = False +nb_execution_excludepatterns = [ + "integrations/*-connect.ipynb", + "integrations/mssql.ipynb", + "integrations/mysql.ipynb", + "integrations/mariadb.ipynb", + "integrations/clickhouse.ipynb", + "integrations/mindsdb.ipynb", + "integrations/questdb.ipynb", + "integrations/trinodb.ipynb", + "integrations/oracle.ipynb", + "integrations/snowflake.ipynb", + "integrations/redshift.ipynb", + "integrations/spark.ipynb", +] +nb_execution_in_temp = True +nb_execution_show_tb = True +nb_execution_timeout = 90 +extensions = [ + "sphinx_togglebutton", + "sphinx_copybutton", + "myst_nb", + "jupyter_book", + "sphinx_thebe", + "sphinx_comments", + "sphinx_external_toc", + "sphinx.ext.intersphinx", + "sphinx_design", + "sphinx_book_theme", + "sphinx.ext.autodoc", + "sphinx.ext.napoleon", + "sphinx.ext.autosummary", + "matplotlib.sphinxext.plot_directive", + "sphinx_jupyterbook_latex", +] +external_toc_exclude_missing = False +external_toc_path = "_toc.yml" +html_baseurl = "" +html_favicon = "" +html_logo = "square-no-bg-small.png" +html_sourcelink_suffix = "" +html_theme = "sphinx_book_theme" +html_theme_options = { + "launch_buttons": { + "notebook_interface": "jupyterlab", + "jupyterhub_url": "", + "thebe": False, + "colab_url": "", + }, + "path_to_docs": "doc", + "repository_url": repository_url, + "repository_branch": repository_branch, + "analytics": {"google_analytics_id": "G-JBZ8NNQSLN"}, + "home_page_in_toc": True, + "announcement": ( + "
" + "Deploy Streamlit apps for free on " + "" + "Ploomber Cloud!" + "
" + ), + "use_repository_button": True, + "use_edit_page_button": False, + "use_issues_button": True, +} +nb_execution_cache_path = "" +nb_execution_mode = "cache" +latex_engine = "pdflatex" +myst_enable_extensions = [ + "colon_fence", + "dollarmath", + "linkify", + "substitution", + "tasklist", +] +myst_url_schemes = ["mailto", "http", "https"] +# https://myst-parser.readthedocs.io/en/latest/syntax/optional.html#auto-generated-header-anchors +myst_heading_anchors = 2 + +nb_output_stderr = "show" +numfig = True +plot_html_show_formats = False +plot_html_show_source_link = False +plot_include_source = True +pygments_style = "sphinx" +suppress_warnings = ["misc.highlighting_failure"] +use_jupyterbook_latex = True +use_multitoc_numbering = True + + +# Adding Algolia search to jupyter-book : +# https://github.com/sphinx-doc/sphinx/issues/3812#issuecomment-491256702 +# Please note this is an old thread and they are working with v2 which is a legacy. +# In order to make it work with v3 we made some changes. +# Please see algolia.css and algolia.js files to read more about these changes. + +# Add any paths that contain custom static files (such as style sheets) here, +# relative to this directory. +html_static_path = ["_static"] + +# Load custom stylesheets to support Algolia search. +html_css_files = [ + "marketing.css", + "algolia.css", + "https://cdn.jsdelivr.net/npm/@docsearch/css@3", +] + +# Load custom javascript to support Algolia search. Note that the sequence +# defined below (external first) is intentional! +html_js_files = [ + ( + "https://cdn.jsdelivr.net/npm/@docsearch/js@3.3.3/dist/umd/index.js", + {"defer": "defer"}, + ), + ( + "algolia.js", + {"defer": "defer"}, + ), + ( + "marketing.js", + {"defer": "defer"}, + ), +] diff --git a/doc/connecting.md b/doc/connecting.md new file mode 100644 index 000000000..0171be0a9 --- /dev/null +++ b/doc/connecting.md @@ -0,0 +1,346 @@ +--- +jupytext: + formats: md:myst + notebook_metadata_filter: myst + text_representation: + extension: .md + format_name: myst + format_version: 0.13 + jupytext_version: 1.15.0 +kernelspec: + display_name: Python 3 (ipykernel) + language: python + name: python3 +myst: + html_meta: + description lang=en: Connect to a SQL database from a Jupyter notebook + keywords: jupyter, sql, jupysql + property=og:locale: en_US +--- + +# Connecting to a database + +JupySQL offers several ways to configure a database connection. In this guide, we discuss the pros and cons of each. + +## Using the connector widget + +The easiest way to connect to a database, is via the connector widget. To learn more, see: [](api/magic-connect.md) + +![create](static/create-connection.gif) + +## Connecting with a `.ini` file + +```{versionchanged} 0.10.0 +``` + +Using a `.ini` file is the recommended way to connect to databases. By default, JupySQL reads the `~/.jupysql/connections.ini` file, but you can change this setting. A `.ini` file looks like this: + +```ini +[mydb] +drivername = postgresql +username = person +password = mypass +host = localhost +port = 5432 +database = db +``` + +To learn more, see: [](user-guide/connection-file.md). + ++++ + +## Connect with a URL string + +```{important} +If you connect using a URL string, **do not hardcode your password in your notebook**, see: [](building-url-strings-securely) +``` + +Connection strings follow the [SQLAlchemy URL format](http://docs.sqlalchemy.org/en/latest/core/engines.html#database-urls). + +Database URLs have the following format: + +``` +dialect+driver://username:password@host:port/database +``` + +In-memory databases have the following format: + +``` +sqlite:// +duckdb:// +``` + +(building-url-strings-securely)= +### Building URL strings securely + +To connect more securely, you can dynamically build your URL string so your password isn't hardcoded; you can use the `getpass` function so you're prompted for your password whenever you want to connect: + +```python +from getpass import getpass + +password = getpass() +``` + +When you execute the cell above in a notebook, a text box will appear and whatever you type will be stored in the `password` variable. + +```{code-cell} ipython3 +:tags: [remove-cell] + +# this cell is hidden in the docs, only used to simulate +# the getpass() call +password = "mysupersecretpassword" +``` + +Then, you can build your connection string: + +```{code-cell} ipython3 +db_url = f"postgresql://user:{password}@localhost/database" +``` + +Create an engine and connect: + +```{code-cell} ipython3 +:tags: [remove-cell] + +# this cell is hidden in the docs, only used to fake +# the db_url +db_url = "duckdb://" +``` + +```{code-cell} ipython3 +from sqlalchemy import create_engine + +engine = create_engine(db_url) +``` + +```{code-cell} ipython3 +:tags: [remove-output] + +%load_ext sql +``` + +```{code-cell} ipython3 +%sql engine +``` + ++++ {"user_expressions": []} + +```{important} +Unlike `ipython-sql`, JupySQL doesn't allow expanding your database URL with the `$` character: + +~~~python +# this doesn't work in JupySQL! +db_url = "dialect+driver://username:password@host:port/database" +%sql $db_url +~~~ +``` + ++++ {"user_expressions": []} + +## Securely storing your password + +Using a `.ini` file has the advantage of not having to hardcode your password. However, it's still stored in a file in plain text. On the other hand, using `getpass` will always prompt you for your password, which isn't ideal when running [scheduled notebooks.](https://github.com/ploomber/ploomber-engine) + +The most secure way to store your password is to use [keyring](https://github.com/jaraco/keyring), a library that uses the operating system credentials manager to securely store your password. The caveat is that the configuration settings depend on your operating system. + +```{code-cell} ipython3 +:tags: [remove-output] + +%pip install keyring --quiet +``` + ++++ {"user_expressions": []} + +Once `keyring` is configured. Execute the following in your notebook: + +```python +import keyring + +keyring.set_password("my_database", "my_username", "my_password") +``` + ++++ {"user_expressions": []} + +Then, delete the cell above (so your password isn't hardcoded!). Now, you can retrieve your password with: + +```python +from sqlalchemy import create_engine +import keyring + +password = keyring.get_password("my_database", "my_username") +``` + +```{code-cell} ipython3 +:tags: [remove-cell] + +# this cell is hidden in the docs, only used to fake +# the password variable +password = "password" +``` + +```{code-cell} ipython3 +db_url = f"postgresql://user:{password}@localhost/database" +``` + +```{code-cell} ipython3 +:tags: [remove-cell] + +# this cell is hidden in the docs, only used to fake +# the db_url +db_url = "duckdb://" +``` + ++++ {"user_expressions": []} + +Create an engine and connect: + +```{code-cell} ipython3 +engine = create_engine(db_url) +``` + +```{code-cell} ipython3 +:tags: [remove-output] + +%load_ext sql +``` + +```{code-cell} ipython3 +%sql engine +``` + +```{tip} +If you have issues using `keyring`, send us a message on [Slack.](https://ploomber.io/community) +``` + ++++ + +## Passing custom arguments to a URL + ++++ + +Connection arguments not whitelisted by SQLALchemy can be provided with `--connection_arguments`. See [SQLAlchemy Args](https://docs.sqlalchemy.org/en/13/core/engines.html#custom-dbapi-args). + +Here's an example using SQLite: + +```{code-cell} ipython3 +:tags: [remove-output] + +%load_ext sql +``` + +```{code-cell} ipython3 +%sql --connection_arguments '{"timeout":10}' sqlite:// +``` + +## Connecting via an environment variable + ++++ + +Set the `DATABASE_URL` environment variable, and `%sql` will automatically load it. You can do this either by setting the environment variable from your terminal or in your notebook: + +```python +from getpass import getpass +from os import environ + +password = getpass() +environ["DATABASE_URL"] = f"postgresql://user:{password}@localhost/database" +``` + +```{code-cell} ipython3 +:tags: [remove-cell] + +# this cell is hidden in the docs, only used to fake +# the environment variable +from os import environ + +environ["DATABASE_URL"] = "sqlite://" +``` + +```{code-cell} ipython3 +:tags: [remove-output] + +%load_ext sql +``` + +```{code-cell} ipython3 +%sql +``` + +## Using an existing `sqlalchemy.engine.Engine` + +You can use an existing `Engine` by passing the variable name to `%sql`. + +```{code-cell} ipython3 +import pandas as pd +from sqlalchemy.engine import create_engine +``` + +```{code-cell} ipython3 +engine = create_engine("sqlite://") +``` + +```{code-cell} ipython3 +df = pd.DataFrame({"x": range(5)}) +df.to_sql("numbers", engine) +``` + +```{code-cell} ipython3 +:tags: [remove-output] + +%load_ext sql +``` + +```{code-cell} ipython3 +%sql engine +``` + +```{code-cell} ipython3 +%%sql +SELECT * FROM numbers +``` + +## DBAPI connections + +```{versionadded} 0.7.2 +``` + +If you are using a database that is not supported by SQLAlchemy but follows the [DB API 2.0 specification](https://peps.python.org/pep-0249/), you can still use JupySQL. + +```{note} +We currently support `%sql`, `%sqlplot`, and the `ggplot` API when using custom connection. However, please be advised that there may be some features/functionalities that won't be fully compatible with JupySQL. +``` + +For this example we'll generate a `DuckDB` connection, using its native `connect` method. + +First, let's import the library and initiazlie a new connection + +```{code-cell} ipython3 +import duckdb + +conn = duckdb.connect() +``` + +Now, load `%sql` and initialize it with our DuckDB connection. + +```{code-cell} ipython3 +%sql conn +``` + +Download some data + +```{code-cell} ipython3 +import urllib + +urllib.request.urlretrieve( + "https://raw.githubusercontent.com/mwaskom/seaborn-data/master/penguins.csv", + "penguins.csv", +) +``` + +You're all set + +```{code-cell} ipython3 +%sql select * from penguins.csv limit 3 +``` + +For a more detailed example, see [QuestDB tutorial](integrations/questdb.ipynb) diff --git a/doc/environment.lock.yml b/doc/environment.lock.yml new file mode 100644 index 000000000..269730da3 --- /dev/null +++ b/doc/environment.lock.yml @@ -0,0 +1,204 @@ +name: jupysql-doc +channels: + - conda-forge + - defaults +dependencies: + - _openmp_mutex=4.5 + - brotli=1.0.9 + - brotli-bin=1.0.9 + - bzip2=1.0.8 + - ca-certificates=2023.5.7 + - certifi=2023.5.7 + - contourpy=1.1.0 + - cycler=0.11.0 + - fonttools=4.41.0 + - freetype=2.12.1 + - kiwisolver=1.4.4 + - lcms2=2.15 + - ld_impl_linux-aarch64=2.40 + - lerc=4.0.0 + - libblas=3.9.0 + - libbrotlicommon=1.0.9 + - libbrotlidec=1.0.9 + - libbrotlienc=1.0.9 + - libcblas=3.9.0 + - libdeflate=1.18 + - libffi=3.4.2 + - libgcc-ng=13.1.0 + - libgfortran-ng=13.1.0 + - libgfortran5=13.1.0 + - libgomp=13.1.0 + - libjpeg-turbo=2.1.5.1 + - liblapack=3.9.0 + - libnsl=2.0.0 + - libopenblas=0.3.23 + - libpng=1.6.39 + - libsqlite=3.42.0 + - libstdcxx-ng=13.1.0 + - libtiff=4.5.1 + - libuuid=2.38.1 + - libwebp-base=1.3.1 + - libxcb=1.15 + - libzlib=1.2.13 + - matplotlib=3.7.2 + - matplotlib-base=3.7.2 + - munkres=1.1.4 + - ncurses=6.4 + - numpy=1.25.1 + - openjpeg=2.5.0 + - openssl=3.1.1 + - packaging=23.1 + - pandas=2.0.3 + - pillow=10.0.0 + - pip=23.2 + - pthread-stubs=0.4 + - pyparsing=3.0.9 + - python=3.10.12 + - python-dateutil=2.8.2 + - python-tzdata=2023.3 + - python_abi=3.10 + - pytz=2023.3 + - readline=8.2 + - setuptools=68.0.0 + - six=1.16.0 + - tk=8.6.12 + - tornado=6.3.2 + - tzdata=2023c + - unicodedata2=15.0.0 + - wheel=0.40.0 + - xorg-libxau=1.0.11 + - xorg-libxdmcp=1.1.3 + - xz=5.2.6 + - zstd=1.5.2 + - pip: + - -e .. + - accessible-pygments==0.0.4 + - alabaster==0.7.13 + - asttokens==2.2.1 + - attrs==23.1.0 + - autopep8==2.0.2 + - awscli==1.29.4 + - babel==2.12.1 + - backcall==0.2.0 + - backoff==2.2.1 + - beautifulsoup4==4.12.2 + - black==23.7.0 + - botocore==1.31.4 + - charset-normalizer==3.2.0 + - click==8.1.5 + - colorama==0.4.4 + - comm==0.1.3 + - debugpy==1.6.7 + - decorator==5.1.1 + - docutils==0.16 + - duckdb==0.8.1 + - duckdb-engine==0.9.1 + - chdb==0.13.0 + - exceptiongroup==1.1.2 + - executing==1.2.0 + - fastjsonschema==2.17.1 + - flake8==6.0.0 + - greenlet==2.0.2 + - idna==3.4 + - imagesize==1.4.1 + - importlib-metadata==6.8.0 + - iniconfig==2.0.0 + - invoke==2.2.0 + - ipykernel==6.24.0 + - ipython==8.14.0 + - ipython-genutils==0.2.0 + - ipywidgets==8.0.7 + - jedi==0.18.2 + - jinja2==3.1.2 + - jmespath==1.0.1 + - jsonschema==4.18.3 + - jsonschema-specifications==2023.6.1 + - jupyter-book==0.15.1 + - jupyter-cache==0.6.1 + - jupyter-client==8.3.0 + - jupyter-core==5.3.1 + - jupyterlab-widgets==3.0.8 + - jupytext==1.14.7 + - latexcodec==2.0.1 + - linkify-it-py==2.0.2 + - markdown-it-py==2.2.0 + - markupsafe==2.1.3 + - matplotlib-inline==0.1.6 + - mccabe==0.7.0 + - mdit-py-plugins==0.3.5 + - mdurl==0.1.2 + - memory-profiler==0.61.0 + - monotonic==1.6 + - mypy-extensions==1.0.0 + - myst-nb==0.17.2 + - myst-parser==0.18.1 + - nbclient==0.7.4 + - nbformat==5.9.1 + - nbqa==1.7.0 + - nest-asyncio==1.5.6 + - parso==0.8.3 + - pathspec==0.11.1 + - pexpect==4.8.0 + - pickleshare==0.7.5 + - pkgmt==0.7.1 + - platformdirs==3.9.1 + - ploomber-core==0.2.13 + - pluggy==1.2.0 + - polars==0.18.7 + - posthog==3.0.1 + - prettytable==3.12.0 + - prompt-toolkit==3.0.39 + - psutil==5.9.5 + - ptyprocess==0.7.0 + - pure-eval==0.2.2 + - pyarrow==12.0.1 + - pyasn1==0.5.0 + - pybtex==0.24.0 + - pybtex-docutils==1.0.2 + - pycodestyle==2.10.0 + - pydata-sphinx-theme==0.13.3 + - pyflakes==3.0.1 + - pygments==2.15.1 + - pytest==7.4.0 + - pyyaml==6.0.1 + - pyzmq==25.1.0 + - referencing==0.29.1 + - requests==2.31.0 + - rpds-py==0.8.11 + - rsa==4.7.2 + - s3transfer==0.6.1 + - snowballstemmer==2.2.0 + - soupsieve==2.4.1 + - sphinx==5.0.2 + - sphinx-book-theme==1.0.1 + - sphinx-comments==0.0.3 + - sphinx-copybutton==0.5.2 + - sphinx-design==0.3.0 + - sphinx-external-toc==0.3.1 + - sphinx-jupyterbook-latex==0.5.2 + - sphinx-multitoc-numbering==0.1.3 + - sphinx-thebe==0.2.1 + - sphinx-togglebutton==0.3.2 + - sphinxcontrib-applehelp==1.0.4 + - sphinxcontrib-bibtex==2.5.0 + - sphinxcontrib-devhelp==1.0.2 + - sphinxcontrib-htmlhelp==2.0.1 + - sphinxcontrib-jsmath==1.0.1 + - sphinxcontrib-qthelp==1.0.3 + - sphinxcontrib-serializinghtml==1.1.5 + - sqlalchemy==2.0.19 + - sqlglot==17.4.1 + - sqlparse==0.4.4 + - stack-data==0.6.2 + - tabulate==0.9.0 + - tokenize-rt==5.1.0 + - toml==0.10.2 + - tomli==2.0.1 + - traitlets==5.9.0 + - typing-extensions==4.7.1 + - uc-micro-py==1.0.2 + - urllib3==1.26.16 + - wcwidth==0.2.13 + - widgetsnbextension==4.0.8 + - zipp==3.16.2 +prefix: /opt/conda/envs/jupysql-doc diff --git a/doc/environment.yml b/doc/environment.yml new file mode 100644 index 000000000..c6f071167 --- /dev/null +++ b/doc/environment.yml @@ -0,0 +1,42 @@ +# documentation dependencies, note that readthedocs uses +# the environment.lock.yml file, which can be re-generated with +# the following script: +# docker run -it --rm continuumio/miniconda3 bash +# git clone https://github.com/ploomber/jupysql +# cd jupysql/doc +# apt update +# apt install gcc -y +# conda env create -f environment.yml +# conda env export --name jupysql-doc --no-build +# once generated, remove the line that begins "jupysql==" and replace it +# with "-e .." +name: jupysql-doc + +channels: + - conda-forge + +dependencies: + - python=3.10 + - matplotlib + - pandas + - pip + - pip: + - -e .. + - jupyter-book + # duckdb example + - duckdb>=0.7.1 + - duckdb-engine + # plot example + - memory-profiler + - pyarrow + - pkgmt>=0.1.7 + # chDB example + - chdb>=0.13.0 + # convert to polars example + - polars + # for developer guide + - pytest + # for %%sql --interact + - ipywidgets + # needed to upload and download from/to S3 for notebook cache + - awscli diff --git a/doc/howto.md b/doc/howto.md new file mode 100644 index 000000000..bc8fe04b8 --- /dev/null +++ b/doc/howto.md @@ -0,0 +1,409 @@ +--- +jupytext: + notebook_metadata_filter: myst + text_representation: + extension: .md + format_name: myst + format_version: 0.13 + jupytext_version: 1.14.7 +kernelspec: + display_name: Python 3 (ipykernel) + language: python + name: python3 +myst: + html_meta: + description lang=en: Recipes for JupySQL + keywords: jupyter, sql, jupysql + property=og:locale: en_US +--- + +```{code-cell} ipython3 +:tags: [remove-cell] + +# clean up all .db files (this cell will not be displayed in the docs) +from pathlib import Path +from glob import glob + +for file in (Path(f) for f in glob("*.db")): + if file.exists(): + print(f"Deleting: {file}") + file.unlink() +``` + ++++ {"user_expressions": []} + +# How-To + +## Query CSV files with SQL + +You can use `JupySQL` and `DuckDB` to query CSV files with SQL in a Jupyter notebook. + ++++ {"user_expressions": []} + +### Installation + +```{code-cell} ipython3 +%pip install jupysql duckdb duckdb-engine --quiet +``` + ++++ {"user_expressions": []} + +### Setup + +Load JupySQL: + +```{code-cell} ipython3 +%load_ext sql +``` + ++++ {"user_expressions": []} + +Create an in-memory DuckDB database: + +```{code-cell} ipython3 +%sql duckdb:// +``` + ++++ {"user_expressions": []} + +Download some sample data: + +```{code-cell} ipython3 +from urllib.request import urlretrieve + +_ = urlretrieve( + "https://raw.githubusercontent.com/mwaskom/seaborn-data/master/penguins.csv", + "penguins.csv", +) +``` + ++++ {"user_expressions": []} + +### Query + +```{code-cell} ipython3 +%%sql +SELECT * +FROM penguins.csv +LIMIT 3 +``` + +```{code-cell} ipython3 +%%sql +SELECT species, COUNT(*) AS count +FROM penguins.csv +GROUP BY species +ORDER BY count DESC +``` + ++++ {"user_expressions": []} + +## Convert to `polars.DataFrame` + +```{code-cell} ipython3 +%%sql results << +SELECT species, COUNT(*) AS count +FROM penguins.csv +GROUP BY species +ORDER BY count DESC +``` + +```{code-cell} ipython3 +import polars as pl +``` + +```{code-cell} ipython3 +pl.DataFrame((tuple(row) for row in results), schema=results.keys) +``` + ++++ {"user_expressions": []} + +## Register SQLite UDF + +To register a user-defined function (UDF) when using SQLite, you can use [SQLAlchemy's `@event.listens_for`](https://docs.sqlalchemy.org/en/14/dialects/sqlite.html#user-defined-functions) and SQLite's [`create_function`](https://docs.python.org/3/library/sqlite3.html#sqlite3.Connection.create_function): + +### Install JupySQL + +```{code-cell} ipython3 +%pip install jupysql --quiet +``` + ++++ {"user_expressions": []} + +### Create engine and register function + +```{code-cell} ipython3 +from sqlalchemy import create_engine +from sqlalchemy import event + + +def mysum(x, y): + return x + y + + +engine = create_engine("sqlite://") + + +@event.listens_for(engine, "connect") +def connect(conn, rec): + conn.create_function(name="MYSUM", narg=2, func=mysum) +``` + ++++ {"user_expressions": []} + +### Create connection with existing engine + +```{versionadded} 0.5.1 +Pass existing engines to `%sql` +``` + +```{code-cell} ipython3 +%load_ext sql +``` + +```{code-cell} ipython3 +%sql engine +``` + ++++ {"user_expressions": []} + +## Query + +```{code-cell} ipython3 +%%sql +SELECT MYSUM(1, 2) +``` + ++++ {"user_expressions": []} + +## Connect to a SQLite database with spaces + +Currently, due to a limitation in the argument parser, it's not possible to directly connect to SQLite databases whose path contains spaces; however, you can do it by creating the engine first. + +### Setup + +```{code-cell} ipython3 +%pip install jupysql --quiet +``` + +```{code-cell} ipython3 +%load_ext sql +``` + ++++ {"user_expressions": []} + +## Connect to db + +```{code-cell} ipython3 +from sqlalchemy import create_engine + +engine = create_engine("sqlite:///my database.db") +``` + ++++ {"user_expressions": []} + +Add some sample data: + +```{code-cell} ipython3 +import pandas as pd + +_ = pd.DataFrame({"x": range(5)}).to_sql("numbers", engine) +``` + +```{code-cell} ipython3 +%sql engine +``` + +```{code-cell} ipython3 +%%sql +SELECT * FROM numbers +``` + ++++ {"user_expressions": []} + +## Switch connections + +```{versionadded} 0.5.2 +`-A/--alias` +``` + +```{code-cell} ipython3 +# create two databases with sample data +from sqlalchemy import create_engine +import pandas as pd + +engine_one = create_engine("sqlite:///one.db") +pd.DataFrame({"x": range(5)}).to_sql("one", engine_one) + +engine_two = create_engine("sqlite:///two.db") +_ = pd.DataFrame({"x": range(5)}).to_sql("two", engine_two) +``` + +```{code-cell} ipython3 +%load_ext sql +``` + ++++ {"user_expressions": []} + +Assign alias to both connections so we can switch them by name: + +```{code-cell} ipython3 +%sql sqlite:///one.db --alias one +%sql sqlite:///two.db --alias two +``` + +```{code-cell} ipython3 +%sql +``` + ++++ {"user_expressions": []} + +Pass the alias to make it the current connection: + +```{code-cell} ipython3 +%sql one +``` + ++++ {"user_expressions": []} + +```{tip} +We highly recommend you to create a separate cell (`%sql some_alias`) when switching connections instead of switching and querying in the the same cell. +``` + +You can pass an alias and query in the same cell: + +```{code-cell} ipython3 +%%sql one +SELECT * FROM one +``` + ++++ {"user_expressions": []} + +However, this isn't supported with the line magic (e.g., `%sql one SELECT * FROM one`). + +You can also pass an alias, and assign the output to a variable, but *this is discouraged*: + +```{code-cell} ipython3 +%%sql two +result << +SELECT * FROM two +``` + +```{code-cell} ipython3 +result +``` + ++++ {"user_expressions": []} + +Once you pass an alias, it becomes the current active connection: + +```{code-cell} ipython3 +%sql +``` + ++++ {"user_expressions": []} + +Hence, we can skip it in upcoming queries: + +```{code-cell} ipython3 +%%sql +SELECT * FROM two +``` + ++++ {"user_expressions": []} + +Switch connection: + +```{code-cell} ipython3 +%%sql one +SELECT * FROM one +``` + +```{code-cell} ipython3 +%sql +``` + ++++ {"user_expressions": []} + +Close by passing the alias: + +```{code-cell} ipython3 +%sql --close one +``` + +```{code-cell} ipython3 +%sql +``` + +```{code-cell} ipython3 +%sql --close two +``` + +```{code-cell} ipython3 +%sql -l +``` + ++++ {"user_expressions": []} + +## Connect to existing `engine` + +Pass the name of the engine: + +```{code-cell} ipython3 +some_engine = create_engine("sqlite:///some.db") +``` + +```{code-cell} ipython3 +%sql some_engine +``` + ++++ {"user_expressions": []} + +## Use `%sql`/`%%sql` in Databricks + +Databricks uses the same name (`%sql`/`%%sql`) for its SQL magics; however, JupySQL exposes a `%jupysql`/`%%jupysql` alias so you can use both: + +```{code-cell} ipython3 +%jupysql duckdb:// +``` + +```{code-cell} ipython3 +%jupysql SELECT * FROM "penguins.csv" LIMIT 3 +``` + +```{code-cell} ipython3 +%%jupysql +SELECT * +FROM "penguins.csv" +LIMIT 3 +``` + ++++ {"user_expressions": []} + +## Ignore deprecation warnings + +We display warnings to let you know when the API will change so you have enough time to update your code, if you want to suppress this warnings, add this at the top of your notebook: + +```{code-cell} ipython3 +import warnings + +warnings.filterwarnings("ignore", category=FutureWarning) +``` + +## Hide connection string + +If you want to hide the connection string, pass an alias + +```{code-cell} ipython3 +%sql --close duckdb:// +``` + +```{code-cell} ipython3 +%sql duckdb:// --alias myconnection +``` + +The alias will be displayed instead of the connection string: + +```{code-cell} ipython3 +%sql SELECT * FROM 'penguins.csv' LIMIT 3 +``` diff --git a/doc/howto/benchmarking-time.md b/doc/howto/benchmarking-time.md new file mode 100644 index 000000000..dece158e3 --- /dev/null +++ b/doc/howto/benchmarking-time.md @@ -0,0 +1,48 @@ +--- +jupytext: + notebook_metadata_filter: myst + text_representation: + extension: .md + format_name: myst + format_version: 0.13 + jupytext_version: 1.14.5 +kernelspec: + display_name: Python 3 (ipykernel) + language: python + name: python3 +myst: + html_meta: + description lang=en: Display cell runtime in JupyterLab + keywords: jupyter, jupyterlab, sql + property=og:locale: en_US +--- + +# Benchmarking runtime +To record the time taken to run each cell +in JupyterLab, we suggest using `jupyterlab-execute-time` + +## Installation + +```sh +pip install jupyterlab_execute_time +``` + +## Usage +This plugin displays the metadata collected by the +JupyterLab notebook, to ensure that the time is collected +as part of the metadata, enable the record-time feature in +notebook settings +`Settings -> Notebook -> Recording timing` + +### Change notebook settings + +![syntax](../static/benchmarking-time_1.png) + +### Sample notebook + +![syntax](../static/benchmarking-time_2.png) + +Each executed cell shows the last executed time +and the runtime + +![syntax](../static/benchmarking-time_3.png) \ No newline at end of file diff --git a/doc/howto/csv.md b/doc/howto/csv.md new file mode 100644 index 000000000..d240713b6 --- /dev/null +++ b/doc/howto/csv.md @@ -0,0 +1,49 @@ +--- +jupytext: + notebook_metadata_filter: myst + cell_metadata_filter: -all + formats: md:myst + text_representation: + extension: .md + format_name: myst + format_version: 0.13 + jupytext_version: 1.14.4 +kernelspec: + display_name: Python 3 (ipykernel) + language: python + name: python3 +myst: + html_meta: + description lang=en: "Export results from a SQL query to a CSV file from Jupyter" + keywords: "jupyter, sql, jupysql, csv" + property=og:locale: "en_US" +--- + +# Export to CSV + +Result sets come with a ``.csv(filename=None)`` method. This generates +comma-separated text either as a return value (if ``filename`` is not +specified) or in a file of the given name. + +```{code-cell} ipython3 +%load_ext sql +``` + +```{code-cell} ipython3 +%%sql sqlite:// +CREATE TABLE writer (first_name, last_name, year_of_death); +INSERT INTO writer VALUES ('William', 'Shakespeare', 1616); +INSERT INTO writer VALUES ('Bertold', 'Brecht', 1956); +``` + +```{code-cell} ipython3 +result = %sql SELECT * FROM writer +result.csv(filename="writer.csv") +``` + +```{code-cell} ipython3 +import pandas as pd + +df = pd.read_csv("writer.csv") +df +``` diff --git a/doc/howto/db-drivers.md b/doc/howto/db-drivers.md new file mode 100644 index 000000000..52a0d3ef5 --- /dev/null +++ b/doc/howto/db-drivers.md @@ -0,0 +1,28 @@ +# Install database drivers + +## DuckDB + +To connect to a DuckDB database, install `duckdb-engine`: + +```sh +%pip install duckdb-engine --quiet +``` + +## PostgreSQL + +We recommend using `psycopg2` to connect to a PostgreSQL database. The most reliable +way to install it is via `conda`: + +```sh +# run this in your notebook +%conda install psycopg2 -c conda-forge --yes --quiet +``` + +If you don't have conda, you can install it with `pip`: + +```sh +# run this in your notebook +%pip install psycopg2-binary --quiet +``` + +Once installed, restart the kernel. \ No newline at end of file diff --git a/doc/howto/ggplot-interact.md b/doc/howto/ggplot-interact.md new file mode 100644 index 000000000..8047849af --- /dev/null +++ b/doc/howto/ggplot-interact.md @@ -0,0 +1,205 @@ +--- +jupytext: + cell_metadata_filter: -all + formats: md:myst + notebook_metadata_filter: myst + text_representation: + extension: .md + format_name: myst + format_version: 0.13 + jupytext_version: 1.14.6 +kernelspec: + display_name: Python 3 (ipykernel) + language: python + name: python3 +myst: + html_meta: + description lang=en: Export results from a SQL query to a CSV file from Jupyter + keywords: jupyter, sql, jupysql, csv + property=og:locale: en_US +--- + ++++ + +# Interactive ggplot + ++++ + +The ggplot API allows us to build different types of of graphics + +To make our ggplot interactive, we can use [interact](https://ipywidgets.readthedocs.io/en/stable/examples/Using%20Interact.html#using-interact) API from [Jupyter Widgets](https://ipywidgets.readthedocs.io/en/stable/index.html#jupyter-widgets) + +Interact autogenerates UI controls for function arguments, and then calls the function with those arguments when you manipulate the controls interactively. + +To use interact, you need to define: + +1. Widgeets to be controlled +2. The plot function includes ggplot with dynamic argument as +3. Invoke `interact()` API + +Let's see examples below! + ++++ + +## Examples + ++++ + +### Setup + +```{code-cell} ipython3 +%load_ext sql +%sql duckdb:// +``` + +```{code-cell} ipython3 +from sql.ggplot import ggplot, aes, geom_histogram, facet_wrap +import ipywidgets as widgets +from ipywidgets import interact +``` + +```{code-cell} ipython3 +from pathlib import Path +from urllib.request import urlretrieve + +url = "https://d37ci6vzurychx.cloudfront.net/trip-data/yellow_tripdata_2021-01.parquet" + +if not Path("yellow_tripdata_2021-01.parquet").is_file(): + urlretrieve(url, "yellow_tripdata_2021-01.parquet") +``` + +### Basic Usage (with Dropdown and Slider widgets) + +```{code-cell} ipython3 +dropdown = widgets.Dropdown( + options=["red", "blue", "green", "magenta"], + value="red", + description="Color:", + disabled=False, +) +b = widgets.IntSlider( + value=5, + min=1, + max=10, + step=1, + description="Bin:", + orientation="horizontal", +) +``` + +```{code-cell} ipython3 +def plot_fct(color, b): + ( + ggplot( + table="yellow_tripdata_2021-01.parquet", + mapping=aes(x="trip_distance", fill=color), + ) + + geom_histogram(bins=b) + ) + + +interact(plot_fct, color=dropdown, b=b) +``` + +### Categorical histogram (with Select widget) + ++++ + +#### Prepare dataset + +We also use `ggplot2` diamonds to demonstrate + +```{code-cell} ipython3 +from pathlib import Path +from urllib.request import urlretrieve + +if not Path("diamonds.csv").is_file(): + urlretrieve( + "https://raw.githubusercontent.com/tidyverse/ggplot2/main/data-raw/diamonds.csv", # noqa + "diamonds.csv", + ) +``` + +```{code-cell} ipython3 +%%sql +CREATE TABLE diamonds AS SELECT * FROM diamonds.csv +``` + +#### Multiple Columns + +```{code-cell} ipython3 +columns = widgets.SelectMultiple( + options=["cut", "color"], value=["cut"], description="Columns", disabled=False +) +``` + +```{code-cell} ipython3 +def plot(columns): + (ggplot("diamonds", aes(x=columns)) + geom_histogram()) + + +interact(plot, columns=columns) +``` + +```{code-cell} ipython3 +cmap = widgets.Dropdown( + options=["viridis", "plasma", "inferno", "magma", "cividis"], + value="plasma", + description="Colormaps:", + disabled=False, +) +``` + +```{code-cell} ipython3 +def plot(cmap): + ( + ggplot("diamonds", aes(x="price")) + + geom_histogram(bins=10, fill="cut", cmap=cmap) + ) + + +interact(plot, cmap=cmap) +``` + +#### Facet wrap (Complete Example) + +```{code-cell} ipython3 +b = widgets.IntSlider( + value=5, + min=1, + max=10, + step=1, + description="Bin:", + orientation="horizontal", +) +cmap = widgets.Dropdown( + options=["viridis", "plasma", "inferno", "magma", "cividis"], + value="plasma", + description="Colormaps:", + disabled=False, +) +show_legend = widgets.ToggleButton( + value=False, + description="Show legend", + disabled=False, + button_style="", # 'success', 'info', 'warning', 'danger' or '' + tooltip="Is show legend", +) +``` + +```{code-cell} ipython3 +def plot(b, cmap, show_legend): + ( + ggplot("diamonds", aes(x="price")) + + geom_histogram(bins=b, fill="cut", cmap=cmap) + + facet_wrap("color", legend=show_legend) + ) +``` + +```{code-cell} ipython3 +interact(plot, b=b, cmap=cmap, show_legend=show_legend) +``` + +```{code-cell} ipython3 + +``` diff --git a/doc/howto/interactive.md b/doc/howto/interactive.md new file mode 100644 index 000000000..3afd33222 --- /dev/null +++ b/doc/howto/interactive.md @@ -0,0 +1,123 @@ +--- +jupytext: + notebook_metadata_filter: myst + text_representation: + extension: .md + format_name: myst + format_version: 0.13 + jupytext_version: 1.14.5 +kernelspec: + display_name: Python 3 (ipykernel) + language: python + name: python3 +--- + +# Interactive SQL Queries + +```{versionadded} 0.7 +~~~ +pip install jupysql --upgrade +~~~ +``` + + +Interactive command allows you to visualize and manipulate widget and interact with your SQL clause. +We will demonstrate how to create widgets and dynamically query the dataset. + +```{note} +`%sql --interact` requires `ipywidgets`: `pip install ipywidgets` +``` + +## `%sql --interact {{widget_variable}}` + +First, you need to define the variable as the form of basic data type or ipywidgets Widget. +Then pass the variable name into `--interact` argument + +```{code-cell} ipython3 +%load_ext sql +import ipywidgets as widgets + +from pathlib import Path +from urllib.request import urlretrieve + +if not Path("penguins.csv").is_file(): + urlretrieve( + "https://raw.githubusercontent.com/mwaskom/seaborn-data/master/penguins.csv", + "penguins.csv", + ) +%sql duckdb:// +``` + +## Basic Data Types + +The simplest way is to declare a variable with basic data types (Numeric, Text, Boolean...), the [ipywidgets](https://ipywidgets.readthedocs.io/en/stable/examples/Using%20Interact.html?highlight=interact#Basic-interact) will autogenerates UI controls for those variables + +```{code-cell} ipython3 +body_mass_min = 3500 +%sql --interact body_mass_min SELECT * FROM penguins.csv WHERE body_mass_g > {{body_mass_min}} LIMIT 5 +``` + +```{code-cell} ipython3 +island = ( # Try to change Torgersen to Biscoe, Torgersen or Dream in the below textbox + "Torgersen" +) +%sql --interact island SELECT * FROM penguins.csv WHERE island == '{{island}}' LIMIT 5 +``` + +## `ipywidgets` Widget + +You can use widgets to build fully interactive GUIs for your SQL clause. + +See more for complete [Widget List](https://ipywidgets.readthedocs.io/en/stable/examples/Widget%20List.html) + ++++ + +### IntSlider + +```{code-cell} ipython3 +body_mass_lower_bound = widgets.IntSlider(min=2500, max=3500, step=25, value=3100) + +%sql --interact body_mass_lower_bound SELECT * FROM penguins.csv WHERE body_mass_g <= {{body_mass_lower_bound}} LIMIT 5 +``` + +### FloatSlider + +```{code-cell} ipython3 +bill_length_mm_lower_bound = widgets.FloatSlider( + min=35.0, max=45.0, step=0.1, value=40.0 +) + +%sql --interact bill_length_mm_lower_bound SELECT * FROM penguins.csv WHERE bill_length_mm <= {{bill_length_mm_lower_bound}} LIMIT 5 +``` + +## Complete Example + +To demonstrate the way to combine basic data type and ipywidgets into our interactive SQL Clause + +```{code-cell} ipython3 +body_mass_lower_bound = 3600 +show_limit = (0, 50, 1) +sex_selection = widgets.RadioButtons( + options=["MALE", "FEMALE"], description="Sex", disabled=False +) +species_selections = widgets.SelectMultiple( + options=["Adelie", "Chinstrap", "Gentoo"], + value=["Adelie", "Chinstrap"], + # rows=10, + description="Species", + disabled=False, +) +``` + +```{code-cell} ipython3 +%%sql --interact show_limit --interact body_mass_lower_bound --interact species_selections --interact sex_selection +SELECT * FROM penguins.csv +WHERE species IN{{species_selections}} AND +body_mass_g > {{body_mass_lower_bound}} AND +sex == '{{sex_selection}}' +LIMIT {{show_limit}} +``` + +```{code-cell} ipython3 + +``` diff --git a/doc/howto/json.md b/doc/howto/json.md new file mode 100644 index 000000000..c63dc0519 --- /dev/null +++ b/doc/howto/json.md @@ -0,0 +1,265 @@ +--- +jupytext: + notebook_metadata_filter: myst + text_representation: + extension: .md + format_name: myst + format_version: 0.13 + jupytext_version: 1.15.1 +kernelspec: + display_name: Python 3 (ipykernel) + language: python + name: python3 +myst: + html_meta: + description lang=en: Use JupySQL and DuckDB to query JSON files with SQL + keywords: jupyter, sql, jupysql, json, duckdb + property=og:locale: en_US +--- + +# Run SQL on JSON files + +In this tutorial, we'll show you how to query JSON with JupySQL and DuckDB. + + +First, let's install the required dependencies: + +```{code-cell} ipython3 +:tags: [remove-cell] + +# this cell won't be visible in the docs +from pathlib import Path + +paths = ["people.json", "people.jsonl", "people.csv"] + +for path in paths: + path = Path(path) + + if path.exists(): + print(f"Deleting {path}") + path.unlink() +``` + +```{code-cell} ipython3 +:tags: [hide-output] + +%pip install jupysql duckdb duckdb-engine rich --quiet +``` + +Now, let's generate some data. + +We'll write it in typical JSON format as well as [JSON Lines](https://jsonlines.org/). JSON Lines, or newline-delimited JSON, is a structured file format in which each individual line is a valid JSON object, separated by a newline character (`/n`). Our sample data contains four rows: + +```{code-cell} ipython3 +from pathlib import Path +import json + +data = [ + { + "name": "John", + "age": 25, + "friends": ["Jake", "Kelly"], + "likes": {"pizza": True, "tacos": True}, + }, + { + "name": "Jake", + "age": 20, + "friends": ["John"], + "likes": {"pizza": False, "tacos": True}, + }, + { + "name": "Kelly", + "age": 21, + "friends": ["John", "Sam"], + "likes": {"pizza": True, "tacos": True}, + }, + { + "name": "Sam", + "age": 22, + "friends": ["Kelly"], + "likes": {"pizza": False, "tacos": True}, + }, +] +``` + +Next, let's dump our json data into a `.json` file: + +```{code-cell} ipython3 +_ = Path("people.json").write_text(json.dumps(data)) +print(data) +``` + +We should also produce a `.jsonl` file. Due to its newline-delimited nature, we will need to format our data in a way such that each object in our data array is separated by `/n`. + +```{code-cell} ipython3 +lines = "" + +for d in data: + lines += json.dumps(d) + "\n" + +_ = Path("people.jsonl").write_text(lines) +``` + +```{code-cell} ipython3 +print(lines) +``` + +## Query + +```{note} +Documentation for DuckDB's JSON capabilities is available [here](https://duckdb.org/docs/extensions/json.html). +``` + +Load the extension and start a DuckDB in-memory database: + +```{code-cell} ipython3 +%load_ext sql +%sql duckdb:// +``` + +Read the JSON data: + +```{code-cell} ipython3 +%%sql +SELECT * +FROM read_json_auto('people.json') +``` + +## Extract fields + +Extract fields from a JSON record. Keep in mind when using `read_json_auto`, arrays are 1-indexed (start at 1 rather than 0): + +```{code-cell} ipython3 +%%sql +SELECT + name, + friends[1] AS first_friend, + likes.pizza AS likes_pizza, + likes.tacos AS likes_tacos +FROM read_json_auto('people.json') +``` + +[JSON lines](https://jsonlines.org/) format is also supported: + +```{code-cell} ipython3 +%%sql +SELECT + name, + friends[1] AS first_friend, + likes.pizza AS likes_pizza, + likes.tacos AS likes_tacos +FROM read_json_auto('people.jsonl') +``` + +We can also use `read_json_objects` and format our queries differently. In this case, arrays are zero-indexed: + +```{code-cell} ipython3 +%%sql +SELECT + json ->> '$.name' AS name, + json ->> '$.friends[0]' AS first_friend, + json ->> '$.likes.pizza' AS likes_pizza, + json ->> '$.likes.tacos' AS likes_tacos +FROM read_json_objects('people.jsonl', format="auto") +``` + +Looks like everybody likes tacos! + ++++ + +## Extract schema + +Infer the JSON schema: + +```{code-cell} ipython3 +%%sql +SELECT + json_structure(json), + json_structure(json ->> '$.likes'), +FROM read_json_objects('people.jsonl', format="auto") +``` + +```{code-cell} ipython3 +%%sql schema << +SELECT + json_structure(json) AS schema_all, + json_structure(json ->> '$.likes') AS schema_likes, +FROM read_json_objects('people.jsonl', format="auto") +``` + +Pretty print the inferred schema: + +```{code-cell} ipython3 +from rich import print_json + +row = schema.DataFrame().iloc[0] + +print("Schema:") +print_json(row.schema_all) + +print("\n\nSchema (likes):") +print_json(row.schema_likes) +``` + +## Store snippets + +You can use JupySQL's `--save` feature to store a SQL snippet so you can keep your queries succinct: + +```{code-cell} ipython3 +%%sql --save clean_data_json +SELECT + name, + friends[1] AS first_friend, + likes.pizza AS likes_pizza, + likes.tacos AS likes_tacos +FROM read_json_auto('people.json') +``` + +```{code-cell} ipython3 +%%sql +SELECT * FROM clean_data_json +``` + +Or using our `.jsonl` file: + +```{code-cell} ipython3 +%%sql --save clean_data_jsonl +SELECT + json ->> '$.name' AS name, + json ->> '$.friends[0]' AS first_friend, + json ->> '$.likes.pizza' AS likes_pizza, + json ->> '$.likes.tacos' AS likes_tacos +FROM read_json_objects('people.jsonl', format="auto") +``` + +```{code-cell} ipython3 +%%sql +SELECT * FROM clean_data_jsonl +``` + +## Export to CSV + +```{note} +Using `--with` isn't supported when exporting to CSV. +``` + +To export to CSV: + +```{code-cell} ipython3 +%%sql +COPY ( + SELECT + name, + friends[1] AS first_friend, + likes.pizza AS likes_pizza, + likes.tacos AS likes_tacos + FROM read_json_auto('people.json', format="auto") +) + +TO 'people.csv' (HEADER, DELIMITER ','); +``` + +```{code-cell} ipython3 +%%sql +SELECT * FROM 'people.csv' +``` diff --git a/doc/howto/postgres-install.md b/doc/howto/postgres-install.md new file mode 100644 index 000000000..5e9da79ee --- /dev/null +++ b/doc/howto/postgres-install.md @@ -0,0 +1,30 @@ +# Install PostgreSQL client + +To connect to a PostgreSQL database from Python, you need a client library. We recommend using `psycopg2`, but there are others like `pg8000`, and `asyncpg`. JupySQL supports the [following connectors.](https://docs.sqlalchemy.org/en/14/dialects/postgresql.html#dialect-postgresql) + ++++ + +## Installing `psycopg2` + +The simplest way to install `psycopg2` is with the following command: + +```sh +pip install psycopg2-binary +``` + +If you have `conda` installed, it is more reliable to use it: + +```sh +conda install psycopg2 -c conda-forge +``` + +## Installing `pgspecial` + +Ensure that you are using `pgspecial 1.x`. `pgspecial 2.x` has migrated to `psycopg3` and thus does not yield informative error messages. + +```sh +conda install "pgspecial<2" -c conda-forge +``` + + +If you have trouble getting it to work, [message us on Slack.](https://ploomber.io/community) diff --git a/doc/howto/py-scripts.md b/doc/howto/py-scripts.md new file mode 100644 index 000000000..33e371bbc --- /dev/null +++ b/doc/howto/py-scripts.md @@ -0,0 +1,105 @@ +--- +jupytext: + notebook_metadata_filter: myst + text_representation: + extension: .md + format_name: myst + format_version: 0.13 + jupytext_version: 1.14.5 +kernelspec: + display_name: Python 3 (ipykernel) + language: python + name: python3 +myst: + html_meta: + description lang=en: Embed SQL queries in .py file + keywords: jupyter, sql, jupysql + property=og:locale: en_US +--- + +# Use JupySQL in `.py` scripts + +We have seen how JupySQL allows users to run SQL queries via the `%sql` and `%%sql` magics, but what if you want to execute SQL queries in a `.py` script instead? +In this tutorial, we'll demonstrate how to embed JupySQL magics in a Python file using VSCode and Spyder. + +## Python Interactive Window in VSCode + +VSCode allows users to work with Jupyter-like code cells and run code in the Python Interactive Window. To work with these code cells, first, select the Python environment in which JupySQL is installed. To select an environment, use the **Python: Select Interpreter** command from the Command Palette. + +Once done, you can define Jupyter-like code cells within Python code using a `# %%` comment. For more details, refer [VSCode Jupyter support](https://code.visualstudio.com/docs/python/jupyter-support-py). + +Here's a code snippet that allows users to download a sample dataset and perform SQL queries on the data using JupySQL's `%%sql` cell magic. + +## Sample code + +```python +# %% +%pip install jupysql duckdb duckdb-engine --quiet +%load_ext sql +%sql duckdb:// + +# %% +from urllib.request import urlretrieve + +_ = urlretrieve( + "https://raw.githubusercontent.com/mwaskom/seaborn-data/master/penguins.csv", + "penguins.csv", +) + +# %% +%%sql +SELECT * +FROM penguins.csv +LIMIT 3 +``` + +Now let's look at the steps for running this code in VSCode. + +First, create a new file and select the file type as `Python File` as shown below: + +![file type](../static/vscode-file-type.png) + +Now, add a code cell and try to run the cell. It would prompt the user to install the `ipykernel`. + +![file ipykernel](../static/vscode-ipykernel.png) + +Ensure to select the correct Python environment for the code cell to run properly: + +![env](../static/vscode-env.png) + +Now, run the file in the interactive mode as shown below. You may also run each cell individually by clicking the `Run Cell` option. + +![run_interactive](../static/vscode-run-interactive.png) + +## Python Interactive Window in Spyder + +The Spyder IDE also supports the `# %%` format for running Python code cells interactively as we can see below: + +![spyder](../static/spyder-interactive.png) + +## Python Interactive Window in PyCharm + +The percent format is also supported by `PyCharm Professional`: + +![pycharm](../static/pycharm-interactive.png) + +[Click here](https://jupytext.readthedocs.io/en/latest/formats-scripts.html#the-percent-format) for more details on the percent format. + +## Programmatic Execution + +Users may be interested in running the scripts programmatically. This can be achieved by using `jupytext` and [ploomber-engine](https://engine.ploomber.io/en/latest/quick-start.html). `ploomber-engine` is a toolbox for executing notebooks. + +Let's say we save the code snippet in a file named `sql-analysis.py`. Run the below commands in the terminal to run it programmatically. + +```bash +pip install ploomber-engine +jupytext sql-analysis.py --to ipynb +ploomber-engine sql-analysis.ipynb output.ipynb +``` + +The `output.ipynb` should look like: + +![ploomber-engine](../static/ploomber-engine-output.png) + + + diff --git a/doc/howto/testing-columns.md b/doc/howto/testing-columns.md new file mode 100644 index 000000000..acda93ae8 --- /dev/null +++ b/doc/howto/testing-columns.md @@ -0,0 +1,95 @@ +--- +jupytext: + text_representation: + extension: .md + format_name: myst + format_version: 0.13 + jupytext_version: 1.14.4 +kernelspec: + display_name: Python 3 (ipykernel) + language: python + name: python3 +myst: + html_meta: + description lang=en: Test columns from your database in Jupyter via JupySQL + keywords: jupyter, sql, jupysql + property=og:locale: en_US +--- + + +# Testing with sqlcmd + +```{note} +This example uses `SQLite` but the same commands work for other databases. +``` + +```{code-cell} ipython3 +%load_ext sql +%sql sqlite:// +``` + +Let's create a sample table: + +```{code-cell} ipython3 +:tags: [hide-output] +%%sql sqlite:// +CREATE TABLE writer (first_name, last_name, year_of_death); +INSERT INTO writer VALUES ('William', 'Shakespeare', 1616); +INSERT INTO writer VALUES ('Bertold', 'Brecht', 1956); +``` + + +## Run Tests on Column + +Use `%sqlcmd test` to run quantitative tests on your dataset. + +For example, to see if all the values in the column birth_year are less than 2000, we can use: + +```{code-cell} ipython3 +%sqlcmd test --table writer --column year_of_death --less-than 2000 +``` + +Because both William Shakespeare and Bertold Brecht died before the year 2000, this command will return True. + +However, if we were to run: + +```{code-cell} ipython3 +:tags: [raises-exception] +%sqlcmd test --table writer --column year_of_death --greater 1700 +``` + +We see that a value that failed our test was William Shakespeare, as he died in 1616. + +We can also pass several comparator arguments to test: + +```{code-cell} ipython3 +:tags: [raises-exception] +%sqlcmd test --table writer --column year_of_death --greater-or-equal 1616 --less-than-or-equal 1956 +``` + +Here, because Shakespeare died in 1616 and Brecht in 1956, our test passes. + +However, if we search for a window between 1800 and 1900: + +```{code-cell} ipython3 +:tags: [raises-exception] +%sqlcmd test --table writer --column year_of_death --greater 1800 --less-than 1900 +``` + +The test fails, returning both Shakespeare and Brecht. + +Currently, 5 different comparator arguments are supported: `greater`, `greater-or-equal`, `less-than`, `less-than-or-equal`, and `no-nulls`. + +## Parametrizing arguments + +JupySQL supports variable expansion of arguments in the form of `{{variable}}`. Let's see an example of running tests using parametrization: + +```{code-cell} ipython3 +table = "writer" +column = "year_of_death" +limit = "2000" +``` + +```{code-cell} ipython3 +%sqlcmd test --table {{table}} --column {{column}} --less-than {{limit}} +``` \ No newline at end of file diff --git a/doc/integrations/chdb.md b/doc/integrations/chdb.md new file mode 100644 index 000000000..476977674 --- /dev/null +++ b/doc/integrations/chdb.md @@ -0,0 +1,79 @@ +--- +jupytext: + notebook_metadata_filter: myst + text_representation: + extension: .md + format_name: myst + format_version: 0.13 + jupytext_version: 1.15.1 +kernelspec: + display_name: Python 3 (ipykernel) + language: python + name: python3 +myst: + html_meta: + description lang=en: Use chDB from Jupyter using JupySQL + keywords: jupyter, sql, jupysql, chDB + property=og:locale: en_US +--- + +# chDB + +JupySQL integrates with chDB so you can run SQL queries in a Jupyter notebook. Jump into any section to learn more! + ++++ + +## Pre-requisites for `.parquet` file + +```{code-cell} ipython3 +%pip install jupysql chdb pyarrow --quiet +``` + +```{code-cell} ipython3 +from chdb import dbapi + +conn = dbapi.connect() + +%load_ext sql +%sql conn --alias chdb +``` + +### Get a sample `.parquet` file: + +```{code-cell} ipython3 +from urllib.request import urlretrieve + +_ = urlretrieve( + "https://d37ci6vzurychx.cloudfront.net/trip-data/yellow_tripdata_2021-01.parquet", + "yellow_tripdata_2021-01.parquet", +) +``` + +### Query on S3/HTTP/File + ++++ + +Query a local file + +```{code-cell} ipython3 +%%sql +SELECT + passenger_count, AVG(trip_distance) AS avg_trip_distance +FROM file("yellow_tripdata_2021-01.parquet") +GROUP BY passenger_count +``` + +Run a file over HTTP + +```{code-cell} ipython3 +%%sql +SELECT + RegionID, SUM(AdvEngineID), COUNT(*) AS c, AVG(ResolutionWidth), COUNT(DISTINCT UserID) +FROM url('https://datasets.clickhouse.com/hits_compatible/athena_partitioned/hits_0.parquet') +-- query on s3 -- +-- FROM s3('xxxx') +GROUP BY + RegionID +ORDER BY c +DESC LIMIT 10 +``` diff --git a/doc/integrations/clickhouse.ipynb b/doc/integrations/clickhouse.ipynb new file mode 100644 index 000000000..55e63a51a --- /dev/null +++ b/doc/integrations/clickhouse.ipynb @@ -0,0 +1,945 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "1d750c37-42b1-44fd-850d-2eb5c3e8e519", + "metadata": {}, + "source": [ + "# Clickhouse\n", + "\n", + "In this tutorial, we'll see how to query Clickhouse from Jupyter. Optionally, you can spin up a testing server.\n", + "\n", + "```{tip}\n", + "If you encounter issues, feel free to join our [community](https://ploomber.io/community) and we'll be happy to help!\n", + "```\n" + ] + }, + { + "cell_type": "markdown", + "id": "7289d7b0-b3fb-4789-a28e-f0c20871b95b", + "metadata": {}, + "source": [ + "## Pre-requisites\n", + "\n", + "To run this tutorial, you need to install the `clickhouse-sqlalchemy` package.\n" + ] + }, + { + "cell_type": "code", + "execution_count": 5, + "id": "991ac184-6c73-4349-ad3f-4bc8e1c4130c", + "metadata": { + "tags": [] + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Note: you may need to restart the kernel to use updated packages.\n" + ] + } + ], + "source": [ + "%pip install clickhouse-sqlalchemy --quiet" + ] + }, + { + "cell_type": "markdown", + "id": "5aee2f8a-eb94-433e-8afb-42b5f2f6c517", + "metadata": {}, + "source": [ + "## Start Clickhoouse instance\n", + "\n", + "If you don't have a Clickhouse server running or you want to spin up one for testing, you can do it with the official [Docker image](https://hub.docker.com/r/clickhouse/clickhouse-server/).\n", + "\n", + "To start the server:" + ] + }, + { + "cell_type": "code", + "execution_count": 91, + "id": "18fe0b54-da3c-4536-b24b-c77710ca3f68", + "metadata": { + "tags": [] + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "cefe171d72a8b46a529dc15105dca08e1c7cfa90aabbbcb32ffe023d22418ee9\n" + ] + } + ], + "source": [ + "%%bash\n", + "docker run --detach --name clickhouse \\\n", + " -e CLICKHOUSE_DB=my_database \\\n", + " -e CLICKHOUSE_USER=username \\\n", + " -e CLICKHOUSE_DEFAULT_ACCESS_MANAGEMENT=1 \\\n", + " -e CLICKHOUSE_PASSWORD=password \\\n", + " -p 9000:9000/tcp clickhouse/clickhouse-server" + ] + }, + { + "cell_type": "markdown", + "id": "29d89318-a5ee-4714-9cf2-b6fe5be85a86", + "metadata": {}, + "source": [ + "Ensure that the container is running:" + ] + }, + { + "cell_type": "code", + "execution_count": 92, + "id": "f5e27d59-4315-41bd-b633-e543a880a260", + "metadata": { + "tags": [] + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES\n", + "cefe171d72a8 clickhouse/clickhouse-server \"/entrypoint.sh\" 2 seconds ago Up 1 second 8123/tcp, 9009/tcp, 0.0.0.0:9000->9000/tcp clickhouse\n" + ] + } + ], + "source": [ + "%%bash\n", + "docker ps" + ] + }, + { + "cell_type": "markdown", + "id": "9f59a529-4c3e-4633-9d44-227a6a8f90ab", + "metadata": {}, + "source": [ + "## Load sample data\n", + "\n", + "We'll now uplod sample data.\n", + "\n", + "First, let's install and load JupySQL:" + ] + }, + { + "cell_type": "code", + "execution_count": 96, + "id": "0a1c8ecd-29a2-4d21-a64d-b358f2bb683e", + "metadata": { + "tags": [] + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Note: you may need to restart the kernel to use updated packages.\n", + "The sql extension is already loaded. To reload it, use:\n", + " %reload_ext sql\n" + ] + } + ], + "source": [ + "%pip install jupysql --quiet\n", + "%load_ext sql" + ] + }, + { + "cell_type": "markdown", + "id": "268ec8f0-bd22-4aa4-bae9-4450a55ab23a", + "metadata": {}, + "source": [ + "Start the connection:" + ] + }, + { + "cell_type": "code", + "execution_count": 95, + "id": "96405dc1-bdc8-4008-96f8-6ee7074ac899", + "metadata": { + "tags": [] + }, + "outputs": [], + "source": [ + "%sql clickhouse+native://username:password@localhost/my_database" + ] + }, + { + "cell_type": "markdown", + "id": "472536e5-006a-4948-968b-5a8141db0393", + "metadata": {}, + "source": [ + "Create a table:" + ] + }, + { + "cell_type": "code", + "execution_count": 97, + "id": "1437f23d-759e-4f55-a0b6-2f173887fec8", + "metadata": { + "tags": [] + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "* clickhouse+native://username:***@localhost/my_database\n", + "Done.\n" + ] + }, + { + "data": { + "text/html": [ + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
" + ], + "text/plain": [ + "[]" + ] + }, + "execution_count": 97, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "%%sql\n", + "CREATE TABLE taxi\n", + "(\n", + " VendorID Int32,\n", + " tpep_pickup_datetime DateTime,\n", + " tpep_dropoff_datetime DateTime,\n", + " passenger_count Float32,\n", + " trip_distance Float32,\n", + " RatecodeID Float32,\n", + " store_and_fwd_flag String,\n", + " PULocationID Int32,\n", + " DOLocationID Int32,\n", + " payment_type Int32,\n", + " fare_amount Float32,\n", + " extra Float32,\n", + " mta_tax Float32,\n", + " tip_amount Float32,\n", + " tolls_amount Float32,\n", + " improvement_surcharge Float32,\n", + " total_amount Float32,\n", + " congestion_surcharge Float32,\n", + " airport_fee Float32\n", + ")\n", + "ENGINE = MergeTree()\n", + "PRIMARY KEY (VendorID)" + ] + }, + { + "cell_type": "markdown", + "id": "2c4385d6-6a35-4c35-8f47-24ee40e81143", + "metadata": { + "tags": [] + }, + "source": [ + "Now, we'll load 1.4 million rows into our table.\n", + "\n", + "If you're using the Docker container, you can execute the following in a terminal to start a bash session:\n", + "\n", + "```sh\n", + "docker exec -it clickhouse bash\n", + "```\n", + "\n", + "Now, to load the data:\n", + "\n", + "```sh\n", + "apt update\n", + "apt install curl -y\n", + "\n", + "curl https://d37ci6vzurychx.cloudfront.net/trip-data/yellow_tripdata_2021-01.parquet | clickhouse-client --query=\"INSERT INTO my_database.taxi FORMAT Parquet\"\n", + "```" + ] + }, + { + "cell_type": "markdown", + "id": "d63893c0-6eef-4728-bbc1-d625b00d2d1e", + "metadata": {}, + "source": [ + "## Query\n", + "\n", + "Let's query our data!" + ] + }, + { + "cell_type": "code", + "execution_count": 98, + "id": "b3c6ef9c-c15d-4746-8a75-5dc633b5f123", + "metadata": { + "tags": [] + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "* clickhouse+native://username:***@localhost/my_database\n", + "Done.\n" + ] + }, + { + "data": { + "text/html": [ + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
VendorIDtpep_pickup_datetimetpep_dropoff_datetimepassenger_counttrip_distanceRatecodeIDstore_and_fwd_flagPULocationIDDOLocationIDpayment_typefare_amountextramta_taxtip_amounttolls_amountimprovement_surchargetotal_amountcongestion_surchargeairport_fee
12021-01-01 00:30:102021-01-01 00:36:121.02.09999990463256841.0N1424328.03.00.50.00.00.3000000119209289611.8000001907348632.50.0
12021-01-01 00:51:202021-01-01 00:52:191.00.200000002980232241.0N23815123.00.50.50.00.00.300000011920928964.3000001907348630.00.0
12021-01-01 00:43:302021-01-01 01:11:061.014.6999998092651371.0N132165142.00.50.58.6499996185302730.00.3000000119209289651.950000762939450.00.0
12021-01-01 00:15:482021-01-01 00:31:010.010.6000003814697271.0N138132129.00.50.56.0500001907348630.00.3000000119209289636.3499984741210940.00.0
12021-01-01 00:16:292021-01-01 00:24:301.01.6000000238418581.0N2246818.03.00.52.34999990463256840.00.3000000119209289614.1499996185302732.50.0
" + ], + "text/plain": [ + "[(1, datetime.datetime(2021, 1, 1, 0, 30, 10), datetime.datetime(2021, 1, 1, 0, 36, 12), 1.0, 2.0999999046325684, 1.0, 'N', 142, 43, 2, 8.0, 3.0, 0.5, 0.0, 0.0, 0.30000001192092896, 11.800000190734863, 2.5, 0.0),\n", + " (1, datetime.datetime(2021, 1, 1, 0, 51, 20), datetime.datetime(2021, 1, 1, 0, 52, 19), 1.0, 0.20000000298023224, 1.0, 'N', 238, 151, 2, 3.0, 0.5, 0.5, 0.0, 0.0, 0.30000001192092896, 4.300000190734863, 0.0, 0.0),\n", + " (1, datetime.datetime(2021, 1, 1, 0, 43, 30), datetime.datetime(2021, 1, 1, 1, 11, 6), 1.0, 14.699999809265137, 1.0, 'N', 132, 165, 1, 42.0, 0.5, 0.5, 8.649999618530273, 0.0, 0.30000001192092896, 51.95000076293945, 0.0, 0.0),\n", + " (1, datetime.datetime(2021, 1, 1, 0, 15, 48), datetime.datetime(2021, 1, 1, 0, 31, 1), 0.0, 10.600000381469727, 1.0, 'N', 138, 132, 1, 29.0, 0.5, 0.5, 6.050000190734863, 0.0, 0.30000001192092896, 36.349998474121094, 0.0, 0.0),\n", + " (1, datetime.datetime(2021, 1, 1, 0, 16, 29), datetime.datetime(2021, 1, 1, 0, 24, 30), 1.0, 1.600000023841858, 1.0, 'N', 224, 68, 1, 8.0, 3.0, 0.5, 2.3499999046325684, 0.0, 0.30000001192092896, 14.149999618530273, 2.5, 0.0)]" + ] + }, + "execution_count": 98, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "%%sql\n", + "SELECT * FROM taxi LIMIT 5" + ] + }, + { + "cell_type": "markdown", + "id": "333a522a-0915-448d-b340-9de36a6d4112", + "metadata": {}, + "source": [ + "List the tables in the database:" + ] + }, + { + "cell_type": "code", + "execution_count": 99, + "id": "35bca488-a529-457d-8816-c8063c53df50", + "metadata": { + "tags": [] + }, + "outputs": [ + { + "data": { + "text/html": [ + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
Name
taxi
" + ], + "text/plain": [ + "+------+\n", + "| Name |\n", + "+------+\n", + "| taxi |\n", + "+------+" + ] + }, + "execution_count": 99, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "%sqlcmd tables" + ] + }, + { + "cell_type": "markdown", + "id": "74602a3f-7197-442a-9326-c317dd506f83", + "metadata": {}, + "source": [ + "List columns in the taxi table:" + ] + }, + { + "cell_type": "code", + "execution_count": 100, + "id": "119d1931-bd6f-475d-868c-3a1481e4ae37", + "metadata": { + "tags": [] + }, + "outputs": [ + { + "data": { + "text/html": [ + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
nametypenullabledefaultcomment
VendorIDInt32FalseNoneNone
tpep_pickup_datetimeDateTimeFalseNoneNone
tpep_dropoff_datetimeDateTimeFalseNoneNone
passenger_countFloat32FalseNoneNone
trip_distanceFloat32FalseNoneNone
RatecodeIDFloat32FalseNoneNone
store_and_fwd_flagStringFalseNoneNone
PULocationIDInt32FalseNoneNone
DOLocationIDInt32FalseNoneNone
payment_typeInt32FalseNoneNone
fare_amountFloat32FalseNoneNone
extraFloat32FalseNoneNone
mta_taxFloat32FalseNoneNone
tip_amountFloat32FalseNoneNone
tolls_amountFloat32FalseNoneNone
improvement_surchargeFloat32FalseNoneNone
total_amountFloat32FalseNoneNone
congestion_surchargeFloat32FalseNoneNone
airport_feeFloat32FalseNoneNone
" + ], + "text/plain": [ + "+-----------------------+----------+----------+---------+---------+\n", + "| name | type | nullable | default | comment |\n", + "+-----------------------+----------+----------+---------+---------+\n", + "| VendorID | Int32 | False | None | None |\n", + "| tpep_pickup_datetime | DateTime | False | None | None |\n", + "| tpep_dropoff_datetime | DateTime | False | None | None |\n", + "| passenger_count | Float32 | False | None | None |\n", + "| trip_distance | Float32 | False | None | None |\n", + "| RatecodeID | Float32 | False | None | None |\n", + "| store_and_fwd_flag | String | False | None | None |\n", + "| PULocationID | Int32 | False | None | None |\n", + "| DOLocationID | Int32 | False | None | None |\n", + "| payment_type | Int32 | False | None | None |\n", + "| fare_amount | Float32 | False | None | None |\n", + "| extra | Float32 | False | None | None |\n", + "| mta_tax | Float32 | False | None | None |\n", + "| tip_amount | Float32 | False | None | None |\n", + "| tolls_amount | Float32 | False | None | None |\n", + "| improvement_surcharge | Float32 | False | None | None |\n", + "| total_amount | Float32 | False | None | None |\n", + "| congestion_surcharge | Float32 | False | None | None |\n", + "| airport_fee | Float32 | False | None | None |\n", + "+-----------------------+----------+----------+---------+---------+" + ] + }, + "execution_count": 100, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "%sqlcmd columns --table taxi" + ] + }, + { + "cell_type": "markdown", + "id": "f8463de0-a361-48bd-a8d9-606599eec974", + "metadata": {}, + "source": [ + "## Plotting\n", + "\n", + "Let's compute the 99th quantile of the `trip_distance` column to remove outliers:" + ] + }, + { + "cell_type": "code", + "execution_count": 101, + "id": "293620bf-2c27-4e04-b991-613c6113fc85", + "metadata": { + "tags": [] + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "* clickhouse+native://username:***@localhost/my_database\n", + "Done.\n" + ] + }, + { + "data": { + "text/html": [ + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
quantile(0.99)(trip_distance)
19.21179912567139
" + ], + "text/plain": [ + "[(19.21179912567139,)]" + ] + }, + "execution_count": 101, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "%%sql\n", + "SELECT quantile(0.99)(trip_distance)\n", + "FROM taxi" + ] + }, + { + "cell_type": "markdown", + "id": "25a61949-30db-4b47-b90f-3dee151b98a3", + "metadata": {}, + "source": [ + "We now use `--save` to store this SQL SELECT statement:" + ] + }, + { + "cell_type": "code", + "execution_count": 102, + "id": "5b8593b4-ab53-4ca9-8f03-1f8df6c8088f", + "metadata": { + "tags": [] + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "* clickhouse+native://username:***@localhost/my_database\n", + "Skipping execution...\n" + ] + } + ], + "source": [ + "%%sql --save no_outliers --no-execute\n", + "SELECT trip_distance\n", + "FROM taxi\n", + "WHERE trip_distance < 18.7" + ] + }, + { + "cell_type": "markdown", + "id": "7e371e6a-05ee-4f0b-a7fd-44cdd1bda522", + "metadata": {}, + "source": [ + "Now, we can pass it to the plotting command:" + ] + }, + { + "cell_type": "code", + "execution_count": 103, + "id": "9065fef3-be9b-4422-9711-079729bd2b67", + "metadata": { + "tags": [] + }, + "outputs": [ + { + "data": { + "text/plain": [ + "" + ] + }, + "execution_count": 103, + "metadata": {}, + "output_type": "execute_result" + }, + { + "data": { + "image/png": "iVBORw0KGgoAAAANSUhEUgAAAlUAAAHHCAYAAACWQK1nAAAAOXRFWHRTb2Z0d2FyZQBNYXRwbG90bGliIHZlcnNpb24zLjcuMCwgaHR0cHM6Ly9tYXRwbG90bGliLm9yZy88F64QAAAACXBIWXMAAA9hAAAPYQGoP6dpAABBX0lEQVR4nO3deVwVdf///+dB2RSBVBZJBCTXXChSxFwqSTQyTSu1rhIjbRFLLdOuStH6XJbWlS2mLVdiV5e2XKVdqaGIWyWauVSampampuAKKC4ovH9/9ON8PbLjKBx93G+3c7t5Zl4z85oz58SzmfeZYzPGGAEAAOCCuFR1AwAAAJcDQhUAAIAFCFUAAAAWIFQBAABYgFAFAABgAUIVAACABQhVAAAAFiBUAQAAWIBQBQAAYAFCFVANJCcny2azadeuXZd0uzabTUlJSVXehzP497//rebNm8vV1VW+vr5V3Q7OExoaqvj4ePvz5cuXy2azafny5VXWE648hCrgAhSGkHO9/fbbSk5OrpqGqsCJEyeUlJTklH+8kpKSFBoaWmbd1q1bFR8fr/DwcL333nt69913L35zFyA+Pl433XRTVbdhuVWrVikpKUlZWVlV3UqpbrrpJoeAhysHoQqwWGVC1f3336+TJ08qJCTk4jR1Efs4ceKEJkyY4JShqryWL1+ugoICvf7664qPj9c999xT1S1dkVatWqUJEyaUK1R16dJFJ0+eVJcuXS5+Y8D/j1AFVKHc3FxJUo0aNeTh4VHkrNelVl36qG4OHDggSWVe9jPG6OTJk5egI5TFxcVFHh4ecnGx5s9c4WcVKA2hCrBQaGioNm/erBUrVshms8lms9kvwxReKlyxYoUee+wx+fv7q2HDhg7zzh3LFBoaqttvv12LFy9WRESEPDw81LJlS33xxRcV7uv06dMaOXKk/Pz8VKdOHd1xxx3au3dvkbri+vjhhx8UGxur+vXry9PTU2FhYXrwwQclSbt27ZKfn58kacKECfZ9Lhyn9dNPPyk+Pl6NGzeWh4eHAgMD9eCDD+rw4cMO201KSpLNZtOOHTsUHx8vX19f+fj4aPDgwTpx4kSRPj/66CO1b99etWrV0lVXXaUuXbpo8eLFDjVff/21OnfurNq1a6tOnTqKi4vT5s2bK/zahYaGavz48ZIkPz8/h/0rPEaLFi3SDTfcIE9PT73zzjuSpN9//11333236tatq1q1aqlDhw5asGCBw7oLx/18+umnmjBhgq6++mrVqVNHd911l7Kzs3X69GmNGDFC/v7+8vLy0uDBg3X69OkK78OuXbtks9n0yiuv6N1331V4eLjc3d3Vrl07rV27tkj90qVL7a+dr6+vevfurS1btlR4u7m5uXryyScVHBwsd3d3NWvWTK+88oqMMUV6K+7s7rmvdVJSkkaPHi1JCgsLs7/XShr/V9KYqjVr1qhHjx7y8fFRrVq11LVrV3333XcONYXvx19++UX33nuvrrrqKnXq1EmSlJGRocGDB6thw4Zyd3dXgwYN1Lt3b8YhQpJUs6obAC4nU6dO1fDhw+Xl5aVnn31WkhQQEOBQ89hjj8nPz0/jxo0r8/9+t2/frv79++uRRx7RoEGDNHPmTN19991KSUnRrbfeWu6+HnroIX300Ue699571bFjRy1dulRxcXFlLnfgwAF1795dfn5+Gjt2rHx9fbVr1y57sPPz89P06dP16KOP6s4771Tfvn0lSW3atJEkpaam6vfff9fgwYMVGBiozZs3691339XmzZu1evXqImfE7rnnHoWFhWnSpElav3693n//ffn7++vll1+210yYMEFJSUnq2LGjJk6cKDc3N61Zs0ZLly5V9+7dJf01qHzQoEGKjY3Vyy+/rBMnTmj69Onq1KmTNmzYUK5xVIWmTp2qDz/8UHPnztX06dPl5eVl3z9J2rZtmwYOHKiHH35YQ4YMUbNmzZSZmamOHTvqxIkTevzxx1WvXj3NmjVLd9xxh/773//qzjvvdNjGpEmT5OnpqbFjx2rHjh1688035erqKhcXFx09elRJSUlavXq1kpOTFRYWpnHjxpW7/3PNnj1bx44d08MPPyybzabJkyerb9+++v333+Xq6ipJWrJkiXr27KnGjRsrKSlJJ0+e1Jtvvqkbb7xR69evL/drZ4zRHXfcoWXLlikhIUERERFatGiRRo8erT///FOvvfZahXrv27evfv31V82ZM0evvfaa6tevL0n2UF8eS5cuVc+ePRUZGanx48fLxcVFM2fO1C233KJvvvlG7du3d6i/++671aRJE/3jH/+wB8F+/fpp8+bNGj58uEJDQ3XgwAGlpqZq9+7dFXpf4TJlAFjq2muvNV27di0yfebMmUaS6dSpkzl79myx83bu3GmfFhISYiSZzz//3D4tOzvbNGjQwFx33XXl7mfjxo1Gknnsscccpt97771Gkhk/fnyJfcydO9dIMmvXri1x/QcPHiyynkInTpwoMm3OnDlGklm5cqV92vjx440k8+CDDzrU3nnnnaZevXr259u3bzcuLi7mzjvvNPn5+Q61BQUFxhhjjh07Znx9fc2QIUMc5mdkZBgfH58i08ujsL+DBw86TC88RikpKQ7TR4wYYSSZb775xj7t2LFjJiwszISGhtp7X7ZsmZFkWrVqZfLy8uy1AwcONDabzfTs2dNhvdHR0SYkJKTC/e/cudNIMvXq1TNHjhyxT//yyy+NJPPVV1/Zp0VERBh/f39z+PBh+7Qff/zRuLi4mAceeKDc25w3b56RZF588UWH6XfddZex2Wxmx44dDr3NnDmzyDrOf19NmTKlyOekUEhIiBk0aJD9eeFru2zZMmPMX++PJk2amNjYWPt7xZi/3qNhYWHm1ltvtU8rPN4DBw502MbRo0eNJDNlypRyvgq40nD5D7jEhgwZoho1apSrNigoyOGshre3tx544AFt2LBBGRkZ5VrHwoULJUmPP/64w/QRI0aUuWzhGKL58+frzJkz5dreuTw9Pe3/PnXqlA4dOqQOHTpIktavX1+k/pFHHnF43rlzZx0+fFg5OTmSpHnz5qmgoEDjxo0rMlam8KxXamqqsrKyNHDgQB06dMj+qFGjhqKiorRs2bIK70dpwsLCFBsb6zBt4cKFat++vf2SkSR5eXlp6NCh2rVrl3755ReH+gceeMB+pkiSoqKiZIyxX2Y9d/qePXt09uzZSvXav39/XXXVVfbnnTt3lvTXpUpJ2r9/vzZu3Kj4+HjVrVvXXtemTRvdeuut9vdSeSxcuFA1atQo8r578sknZYzR119/Xal9qKyNGzdq+/btuvfee3X48GH7+yI3N1fdunXTypUrVVBQ4LDM+e9HT09Pubm5afny5Tp69OilbB9OglAFXGJhYWHlrr3mmmuKXCJr2rSpJJV7DMcff/whFxcXhYeHO0xv1qxZmct27dpV/fr104QJE1S/fn317t1bM2fOLPe4niNHjuiJJ55QQECAPD095efnZ9//7OzsIvWNGjVyeF4YAAr/gP32229ycXFRy5YtS9zm9u3bJUm33HKL/Pz8HB6LFy+2Dzq3SnHH848//ij29W3RooV9/rnO328fHx9JUnBwcJHpBQUFxb525VHW61vYV0m9F4aQ8vjjjz8UFBSkOnXqFFnPudu6VArfF4MGDSryvnj//fd1+vTpIq/r+cfW3d1dL7/8sr7++msFBASoS5cumjx5crn/BweXP8ZUAZfYuWdvqjubzab//ve/Wr16tb766istWrRIDz74oF599VWtXr1aXl5epS5/zz33aNWqVRo9erQiIiLk5eWlgoIC9ejRo8hZAUklnsEz5wxsLkvhev/9738rMDCwyPyaNa39z54Vx7Ok/bbi9biY67NCSd80zc/Pt3Q7he+LKVOmKCIiotia89/PxR3bESNGqFevXpo3b54WLVqk559/XpMmTdLSpUt13XXXWdoznA+hCrCYlbcj2LFjh4wxDuv89ddfJancg2JDQkJUUFCg3377zeEMxLZt28rdR4cOHdShQwf93//9n2bPnq377rtPH3/8sR566KES9/fo0aNKS0vThAkTHAZWF54xqIzw8HAVFBTol19+KfEPY+EZOX9/f8XExFR6WxciJCSk2Nd369at9vnVUWFfJfVev3591a5du9zrWrJkiY4dO+Zwtur816DwbNn5954q7kzWhXy2Ct8X3t7eF/y+CA8P15NPPqknn3xS27dvV0REhF599VV99NFHF7ReOD8u/wEWq127tmV3fN63b5/mzp1rf56Tk6MPP/xQERERxZ6FKU7Pnj0lSW+88YbD9KlTp5a57NGjR4ucxSgMM4WXAGvVqiWp6B/FwrMi5y9fnu2WpE+fPnJxcdHEiROLnOkq3E5sbKy8vb31j3/8o9hxYAcPHqz09svrtttu0/fff6/09HT7tNzcXL377rsKDQ0t9fJlVWrQoIEiIiI0a9Ysh+O5adMmLV68WLfddlu513XbbbcpPz9fb731lsP01157TTabzf6+9Pb2Vv369bVy5UqHurfffrvIOgsDXWU+X5GRkQoPD9crr7yi48ePF5lfnvfFiRMndOrUKYdp4eHhqlOnTqVudYHLD2eqAItFRkZq+vTpevHFF3XNNdfI399ft9xyS6XW1bRpUyUkJGjt2rUKCAjQBx98oMzMTM2cObPc64iIiNDAgQP19ttvKzs7Wx07dlRaWpp27NhR5rKzZs3S22+/rTvvvFPh4eE6duyY3nvvPXl7e9v/wHp6eqply5b65JNP1LRpU9WtW1etWrVSq1at7GNO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/vijTp48aQ8eq1evlpeXl31cVknCw8Pl6uqqNWvW2IPH0aNH9euvv6pr164lLufp6alevXqpV69eGjZsmJo3b66ff/5Z119/fbH78d1336l3797629/+Jumv8VG//vqrWrZsWe59bNOmjfbu3atff/212LNV119/vX755Rddc8015V4ngMrj8h+Aais0NFRr1qzRrl27dOjQIfsZmvLIy8tTQkKCfvnlFy1cuFDjx49XYmKiXFxK/8+el5eXEhISNHr0aC1dulSbNm1SfHx8qcslJyfrX//6lzZt2qTff/9dH330kTw9PRUSEmLfj5UrV+rPP//UoUOHJElNmjRRamqqVq1apS1btujhhx9WZmZmufdPkrp27aouXbqoX79+Sk1N1c6dO/X1118rJSVFkjRmzBitWrVKiYmJ2rhxo7Zv364vv/ySgerARUKoAlBtPfXUU6pRo4ZatmwpPz+/IuONStOtWzc1adJEXbp0Uf/+/XXHHXcUuR1BSaZMmaLOnTurV69eiomJUadOnRQZGVliva+vr9577z3deOONatOmjZYsWaKvvvpK9erVkyRNnDhRu3btUnh4uP3+Vs8995yuv/56xcbG6qabblJgYKD69OlT7v0r9Pnnn6tdu3YaOHCgWrZsqaefftp+VqxNmzZasWKFfv31V3Xu3FnXXXedxo0bp6CgoApvB0DZbMYYU9VNAICV4uPjlZWVpXnz5lV1KwCuIJypAgAAsAChCsAVZffu3Q63GDj/UZFLjABwLi7/AbiinD17Vrt27SpxfmhoqGrW5IvRACqOUAUAAGABLv8BAABYgFAFAABgAUIVAACABQhVAAAAFiBUAQAAWIBQBQAAYAFCFQAAgAUIVQAAABb4/wBmQEKzCDegTgAAAABJRU5ErkJggg==", + "text/plain": [ + "
" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "%sqlplot histogram --table no_outliers --column trip_distance --with no_outliers" + ] + }, + { + "cell_type": "markdown", + "id": "daefb93a-f9c1-489d-876d-a2269e1d7bfd", + "metadata": {}, + "source": [ + "## Clean up\n", + "\n", + "To stop and remove the container:" + ] + }, + { + "cell_type": "code", + "execution_count": 104, + "id": "299b6951-3b86-4b11-9394-ad5fd091c577", + "metadata": { + "tags": [] + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES\n", + "cefe171d72a8 clickhouse/clickhouse-server \"/entrypoint.sh\" 51 seconds ago Up 49 seconds 8123/tcp, 9009/tcp, 0.0.0.0:9000->9000/tcp clickhouse\n" + ] + } + ], + "source": [ + "%%bash\n", + "docker container ls" + ] + }, + { + "cell_type": "code", + "execution_count": 105, + "id": "2a7e4faa-086e-4309-85af-77c033100dc0", + "metadata": { + "tags": [] + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "clickhouse\n" + ] + } + ], + "source": [ + "%%bash\n", + "docker container stop clickhouse" + ] + }, + { + "cell_type": "code", + "execution_count": 106, + "id": "864f0097-176a-4f50-b33f-e6c91524ef38", + "metadata": { + "tags": [] + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "clickhouse\n" + ] + } + ], + "source": [ + "%%bash\n", + "docker container rm clickhouse" + ] + }, + { + "cell_type": "code", + "execution_count": 107, + "id": "f561ce67-edee-405a-b2f8-13f19ad7ad34", + "metadata": { + "tags": [] + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES\n" + ] + } + ], + "source": [ + "%%bash\n", + "docker container ls" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3 (ipykernel)", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.10.11" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/doc/integrations/compatibility.md b/doc/integrations/compatibility.md new file mode 100644 index 000000000..d59760a98 --- /dev/null +++ b/doc/integrations/compatibility.md @@ -0,0 +1,133 @@ +# Compatibility + +```{note} +These table reflects the compatibility status of JupySQL `>=0.7` +``` + +## DuckDB + +**Full compatibility** + +- Running queries with `%%sql` ✅ +- CTEs with `%%sql --save NAME` ✅ +- Plotting with `%%sqlplot boxplot` ✅ +- Plotting with `%%sqlplot bar` ✅ +- Plotting with `%%sqlplot pie` ✅ +- Plotting with `%%sqlplot histogram` ✅ +- Plotting with `ggplot` API ✅ +- Profiling tables with `%sqlcmd profile` ✅ +- Listing tables with `%sqlcmd tables` ✅ +- Listing columns with `%sqlcmd columns` ✅ +- Parametrized SQL queries via `{{parameter}}` ✅ +- Interactive SQL queries via `--interact` ✅ + +## Snowflake + +- Running queries with `%%sql` ✅ +- CTEs with `%%sql --save NAME` ✅ +- Plotting with `%%sqlplot boxplot` ❓ +- Plotting with `%%sqlplot bar` ❓ +- Plotting with `%%sqlplot pie` ❓ +- Plotting with `%%sqlplot histogram` ❓ +- Plotting with `ggplot` API ❓ +- Profiling tables with `%sqlcmd profile` ❓ +- Listing tables with `%sqlcmd tables` ❓ +- Listing columns with `%sqlcmd columns` ❓ +- Parametrized SQL queries via `{{parameter}}` ✅ +- Interactive SQL queries via `--interact` ✅ + +## Redshift + +- Running queries with `%%sql` ✅ +- CTEs with `%%sql --save NAME` ✅ +- Plotting with `%%sqlplot boxplot` ✅ +- Plotting with `%%sqlplot bar` ✅ +- Plotting with `%%sqlplot pie` ✅ +- Plotting with `%%sqlplot histogram` ✅ +- Plotting with `ggplot` API ✅ +- Profiling tables with `%sqlcmd profile` ✅ +- Listing tables with `%sqlcmd tables` ✅ +- Listing columns with `%sqlcmd columns` ✅ +- Parametrized SQL queries via `{{parameter}}` ✅ +- Interactive SQL queries via `--interact` ✅ + +## PostgreSQL + +**Almost full compatibility** + +- Running queries with `%%sql` ✅ +- CTEs with `%%sql --save NAME` ✅ +- Plotting with `%%sqlplot boxplot` ✅ +- Plotting with `%%sqlplot bar` ✅ +- Plotting with `%%sqlplot pie` ✅ +- Plotting with `%%sqlplot histogram` ✅ +- Plotting with `ggplot` API ❓ +- Profiling tables with `%sqlcmd profile` ✅ +- Listing tables with `%sqlcmd tables` ✅ +- Listing columns with `%sqlcmd columns` ✅ +- Parametrized SQL queries via `{{parameter}}` ✅ +- Interactive SQL queries via `--interact` ✅ + + +## MariaDB / MySQL + +**Almost full compatibility** + +- Running queries with `%%sql` ✅ +- CTEs with `%%sql --save NAME` ✅ +- Plotting with `%%sqlplot boxplot` ❌ +- Plotting with `%%sqlplot bar` ❓ +- Plotting with `%%sqlplot pie` ❓ +- Plotting with `%%sqlplot histogram` ✅ +- Plotting with `ggplot` API ✅ (partial support) +- Profiling tables with `%sqlcmd profile` ✅ +- Listing tables with `%sqlcmd tables` ✅ +- Listing columns with `%sqlcmd columns` ✅ +- Parametrized SQL queries via `{{parameter}}` ✅ +- Interactive SQL queries via `--interact` ✅ + +## SQL Server + +- Running queries with `%%sql` ✅ +- CTEs with `%%sql --save NAME` ✅ +- Plotting with `%%sqlplot boxplot` ✅ +- Plotting with `%%sqlplot bar` ✅ +- Plotting with `%%sqlplot pie` ✅ +- Plotting with `%%sqlplot histogram` ❌ +- Plotting with `ggplot` API ✅ +- Profiling tables with `%sqlcmd profile` ✅ +- Listing tables with `%sqlcmd tables` ✅ +- Listing columns with `%sqlcmd columns` ✅ +- Parametrized SQL queries via `{{parameter}}` ✅ +- Interactive SQL queries via `--interact` ✅ + +## Oracle Database + +- Running queries with `%%sql` ✅ +- CTEs with `%%sql --save NAME` ✅ +- Plotting with `%%sqlplot boxplot` ❌ +- Plotting with `%%sqlplot bar` ❓ +- Plotting with `%%sqlplot pie` ❓ +- Plotting with `%%sqlplot histogram` ❌ +- Plotting with `ggplot` API ❌ +- Profiling tables with `%sqlcmd profile` ❌ +- Listing tables with `%sqlcmd tables` ✅ +- Listing columns with `%sqlcmd columns` ✅ +- Parametrized SQL queries via `{{parameter}}` ✅ +- Interactive SQL queries via `--interact` ✅ + +## Spark + +- Running queries with `%%sql` ✅ +- CTEs with `%%sql --save NAME` ✅ +- Plotting with `%%sqlplot boxplot` ❓ +- Plotting with `%%sqlplot bar` ✅ +- Plotting with `%%sqlplot pie` ✅ +- Plotting with `%%sqlplot histogram` ✅ +- Plotting with `ggplot` ✅ +- Profiling tables with `%sqlcmd profile` ✅ +- Listing tables with `%sqlcmd tables` ❌ +- Listing columns with `%sqlcmd columns` ❌ +- Parametrized SQL queries via `{{parameter}}` ✅ +- Interactive SQL queries via `--interact` ✅ +- Persisting Dataframes via `--persist` ✅ \ No newline at end of file diff --git a/doc/integrations/duckdb-native.md b/doc/integrations/duckdb-native.md new file mode 100644 index 000000000..c8209f211 --- /dev/null +++ b/doc/integrations/duckdb-native.md @@ -0,0 +1,289 @@ +--- +jupytext: + notebook_metadata_filter: myst + text_representation: + extension: .md + format_name: myst + format_version: 0.13 + jupytext_version: 1.14.7 +kernelspec: + display_name: Python 3 (ipykernel) + language: python + name: python3 +myst: + html_meta: + description lang=en: Use DuckDB from Jupyter using JupySQL + keywords: jupyter, sql, jupysql, duckdb, plotting + property=og:locale: en_US +--- + +# DuckDB (Native) + +```{note} +JupySQL also supports DuckDB via SQLAlchemy, to learn more, see +[the tutorial](../integrations/duckdb.md). To learn the differences, [click here.](../tutorials/duckdb-native-sqlalchemy.md) +``` + +JupySQL integrates with DuckDB so you can run SQL queries in a Jupyter notebook. Jump into any section to learn more! + ++++ + +## Pre-requisites for `.csv` file + +```{code-cell} ipython3 +%pip install jupysql duckdb --quiet +``` + +```{code-cell} ipython3 +import duckdb + +%load_ext sql +conn = duckdb.connect() +%sql conn --alias duckdb +``` + +### Load sample data + ++++ + +Get a sample `.csv` file: + +```{code-cell} ipython3 +from urllib.request import urlretrieve + +_ = urlretrieve( + "https://raw.githubusercontent.com/mwaskom/seaborn-data/master/penguins.csv", + "penguins.csv", +) +``` + +### Query + ++++ + +The data from the `.csv` file must first be registered as a table in order for the table to be listed. + +```{code-cell} ipython3 +%%sql +CREATE TABLE penguins AS SELECT * FROM penguins.csv +``` + +```{code-cell} ipython3 +%%sql +SELECT * +FROM penguins.csv +LIMIT 3 +``` + +```{code-cell} ipython3 +%%sql +SELECT species, COUNT(*) AS count +FROM penguins.csv +GROUP BY species +ORDER BY count DESC +``` + +### Plotting + +```{code-cell} ipython3 +%%sql species_count << +SELECT species, COUNT(*) AS count +FROM penguins.csv +GROUP BY species +ORDER BY count DESC +``` + +```{code-cell} ipython3 +ax = species_count.bar() +# customize plot (this is a matplotlib Axes object) +_ = ax.set_title("Num of penguins by species") +``` + +## Pre-requisites for `.parquet` file + +```{code-cell} ipython3 +%pip install jupysql duckdb pyarrow --quiet +%load_ext sql +conn = duckdb.connect() +%sql conn --alias duckdb +``` + +### Load sample data + ++++ + +Get a sample `.parquet` file: + +```{code-cell} ipython3 +from urllib.request import urlretrieve + +_ = urlretrieve( + "https://d37ci6vzurychx.cloudfront.net/trip-data/yellow_tripdata_2021-01.parquet", + "yellow_tripdata_2021-01.parquet", +) +``` + +### Query + ++++ + +Identically, to list the data from a `.parquet` file as a table, the data must first be registered as a table. + +```{code-cell} ipython3 +%%sql +CREATE TABLE tripdata AS SELECT * FROM "yellow_tripdata_2021-01.parquet" +``` + +```{code-cell} ipython3 +%%sql +SELECT tpep_pickup_datetime, tpep_dropoff_datetime, passenger_count +FROM "yellow_tripdata_2021-01.parquet" +LIMIT 3 +``` + +```{code-cell} ipython3 +%%sql +SELECT + passenger_count, AVG(trip_distance) AS avg_trip_distance +FROM "yellow_tripdata_2021-01.parquet" +GROUP BY passenger_count +ORDER BY passenger_count ASC +``` + +### Plotting + +```{code-cell} ipython3 +%%sql avg_trip_distance << +SELECT + passenger_count, AVG(trip_distance) AS avg_trip_distance +FROM "yellow_tripdata_2021-01.parquet" +GROUP BY passenger_count +ORDER BY passenger_count ASC +``` + +```{code-cell} ipython3 +ax = avg_trip_distance.plot() +# customize plot (this is a matplotlib Axes object) +_ = ax.set_title("Avg trip distance by num of passengers") +``` + +## Load sample data from a SQLite database + +If you have a large SQlite database, you can use DuckDB to perform analytical queries it with much better performance. + +```{code-cell} ipython3 +%load_ext sql +``` + +```{code-cell} ipython3 +import urllib.request +from pathlib import Path + +# download sample database +if not Path("my.db").is_file(): + url = "https://raw.githubusercontent.com/lerocha/chinook-database/master/ChinookDatabase/DataSources/Chinook_Sqlite.sqlite" # noqa + urllib.request.urlretrieve(url, "my.db") +``` + +We'll use `sqlite_scanner` extension to load a sample SQLite database into DuckDB: + +```{code-cell} ipython3 +import duckdb + +conn = duckdb.connect() +%sql conn +``` + +```{code-cell} ipython3 +%%sql +INSTALL 'sqlite_scanner'; +LOAD 'sqlite_scanner'; +CALL sqlite_attach('my.db'); +``` + +```{code-cell} ipython3 +%%sql +SELECT * FROM track LIMIT 5 +``` + +## Plotting large datasets + +```{versionadded} 0.5.2 +``` + +This section demonstrates how we can efficiently plot large datasets with DuckDB and JupySQL without blowing up our machine's memory. `%sqlplot` performs all aggregations in DuckDB. + +Let's install the required package: + +```{code-cell} ipython3 +%pip install jupysql duckdb pyarrow --quiet +``` + +Now, we download a sample data: NYC Taxi data split in 3 parquet files: + +```{code-cell} ipython3 +N_MONTHS = 3 + +# https://www1.nyc.gov/site/tlc/about/tlc-trip-record-data.page +for i in range(1, N_MONTHS + 1): + filename = f"yellow_tripdata_2021-{str(i).zfill(2)}.parquet" + if not Path(filename).is_file(): + print(f"Downloading: {filename}") + url = f"https://d37ci6vzurychx.cloudfront.net/trip-data/{filename}" + urllib.request.urlretrieve(url, filename) +``` + +In total, this contains more then 4.6M observations: + +```{code-cell} ipython3 +%%sql +SELECT count(*) FROM 'yellow_tripdata_2021-*.parquet' +``` + +Let's use JupySQL to get a histogram of `trip_distance` across all 12 files: + +```{code-cell} ipython3 +%sqlplot histogram --table yellow_tripdata_2021-*.parquet --column trip_distance --bins 50 +``` + +We have some outliers, let's find the 99th percentile: + +```{code-cell} ipython3 +%%sql +SELECT percentile_disc(0.99) WITHIN GROUP (ORDER BY trip_distance) +FROM 'yellow_tripdata_2021-*.parquet' +``` + +We now write a query to remove everything above that number: + +```{code-cell} ipython3 +%%sql --save no_outliers --no-execute +SELECT trip_distance +FROM 'yellow_tripdata_2021-*.parquet' +WHERE trip_distance < 18.93 +``` + +```{code-cell} ipython3 +%sqlplot histogram --table no_outliers --column trip_distance --bins 50 +``` + +## Querying existing dataframes + +```{code-cell} ipython3 +import pandas as pd +import duckdb + +conn = duckdb.connect() +df = pd.DataFrame({"x": range(10)}) +``` + +```{code-cell} ipython3 +%sql conn +``` + +```{code-cell} ipython3 +%%sql +SELECT * +FROM df +WHERE x > 4 +``` diff --git a/doc/integrations/duckdb.md b/doc/integrations/duckdb.md new file mode 100644 index 000000000..6e09d9c95 --- /dev/null +++ b/doc/integrations/duckdb.md @@ -0,0 +1,298 @@ +--- +jupytext: + notebook_metadata_filter: myst + text_representation: + extension: .md + format_name: myst + format_version: 0.13 + jupytext_version: 1.14.7 +kernelspec: + display_name: Python 3 (ipykernel) + language: python + name: python3 +myst: + html_meta: + description lang=en: Use DuckDB from Jupyter using JupySQL + keywords: jupyter, sql, jupysql, duckdb, plotting + property=og:locale: en_US +--- + +# DuckDB + +```{note} +JupySQL also supports DuckDB with a native connection (no SQLAlchemy needed), to learn more, see [the tutorial](../integrations/duckdb-native.md). To learn the differences, [click here.](../tutorials/duckdb-native-sqlalchemy.md) +``` + +JupySQL integrates with DuckDB so you can run SQL queries in a Jupyter notebook. Jump into any section to learn more! + ++++ + +## Pre-requisites for `.csv` file + +```{code-cell} ipython3 +%pip install jupysql duckdb duckdb-engine --quiet +%load_ext sql +%sql duckdb:// +``` + +### Load sample data + ++++ + +Get a sample `.csv` file: + +```{code-cell} ipython3 +from urllib.request import urlretrieve + +_ = urlretrieve( + "https://raw.githubusercontent.com/mwaskom/seaborn-data/master/penguins.csv", + "penguins.csv", +) +``` + +### Query + ++++ + +The data from the `.csv` file must first be registered as a table in order for the table to be listed. + +```{code-cell} ipython3 +%%sql +CREATE TABLE penguins AS SELECT * FROM penguins.csv +``` + +The cell above allows the data to now be listed as a table from the following code: + +```{code-cell} ipython3 +%sqlcmd tables +``` + +List columns in the penguins table: + +```{code-cell} ipython3 +%sqlcmd columns -t penguins +``` + +```{code-cell} ipython3 +%%sql +SELECT * +FROM penguins.csv +LIMIT 3 +``` + +```{code-cell} ipython3 +%%sql +SELECT species, COUNT(*) AS count +FROM penguins.csv +GROUP BY species +ORDER BY count DESC +``` + +### Plotting + +```{code-cell} ipython3 +%%sql species_count << +SELECT species, COUNT(*) AS count +FROM penguins.csv +GROUP BY species +ORDER BY count DESC +``` + +```{code-cell} ipython3 +ax = species_count.bar() +# customize plot (this is a matplotlib Axes object) +_ = ax.set_title("Num of penguins by species") +``` + +## Pre-requisites for `.parquet` file + +```{code-cell} ipython3 +%pip install jupysql duckdb duckdb-engine pyarrow --quiet +%load_ext sql +%sql duckdb:// +``` + +### Load sample data + ++++ + +Get a sample `.parquet` file: + +```{code-cell} ipython3 +from urllib.request import urlretrieve + +_ = urlretrieve( + "https://d37ci6vzurychx.cloudfront.net/trip-data/yellow_tripdata_2021-01.parquet", + "yellow_tripdata_2021-01.parquet", +) +``` + +### Query + ++++ + +Identically, to list the data from a `.parquet` file as a table, the data must first be registered as a table. + +```{code-cell} ipython3 +%%sql +CREATE TABLE tripdata AS SELECT * FROM "yellow_tripdata_2021-01.parquet" +``` + +The data is now able to be listed as a table from the following code: + +```{code-cell} ipython3 +%sqlcmd tables +``` + +List columns in the tripdata table: + +```{code-cell} ipython3 +%sqlcmd columns -t tripdata +``` + +```{code-cell} ipython3 +%%sql +SELECT tpep_pickup_datetime, tpep_dropoff_datetime, passenger_count +FROM "yellow_tripdata_2021-01.parquet" +LIMIT 3 +``` + +```{code-cell} ipython3 +%%sql +SELECT + passenger_count, AVG(trip_distance) AS avg_trip_distance +FROM "yellow_tripdata_2021-01.parquet" +GROUP BY passenger_count +ORDER BY passenger_count ASC +``` + +### Plotting + +```{code-cell} ipython3 +%%sql avg_trip_distance << +SELECT + passenger_count, AVG(trip_distance) AS avg_trip_distance +FROM "yellow_tripdata_2021-01.parquet" +GROUP BY passenger_count +ORDER BY passenger_count ASC +``` + +```{code-cell} ipython3 +ax = avg_trip_distance.plot() +# customize plot (this is a matplotlib Axes object) +_ = ax.set_title("Avg trip distance by num of passengers") +``` + +## Plotting large datasets + +```{versionadded} 0.5.2 +``` + +This section demonstrates how we can efficiently plot large datasets with DuckDB and JupySQL without blowing up our machine's memory. `%sqlplot` performs all aggregations in DuckDB. + +Let's install the required package: + +```{code-cell} ipython3 +%pip install jupysql duckdb duckdb-engine pyarrow --quiet +``` + +Now, we download a sample data: NYC Taxi data split in 3 parquet files: + +```{code-cell} ipython3 +from pathlib import Path +from urllib.request import urlretrieve + +N_MONTHS = 3 + +# https://www1.nyc.gov/site/tlc/about/tlc-trip-record-data.page +for i in range(1, N_MONTHS + 1): + filename = f"yellow_tripdata_2021-{str(i).zfill(2)}.parquet" + if not Path(filename).is_file(): + print(f"Downloading: {filename}") + url = f"https://d37ci6vzurychx.cloudfront.net/trip-data/{filename}" + urlretrieve(url, filename) +``` + +In total, this contains more then 4.6M observations: + +```{code-cell} ipython3 +%%sql +SELECT count(*) FROM 'yellow_tripdata_2021-*.parquet' +``` + +Let's use JupySQL to get a histogram of `trip_distance` across all 12 files: + +```{code-cell} ipython3 +%sqlplot histogram --table yellow_tripdata_2021-*.parquet --column trip_distance --bins 50 +``` + +We have some outliers, let's find the 99th percentile: + +```{code-cell} ipython3 +%%sql +SELECT percentile_disc(0.99) WITHIN GROUP (ORDER BY trip_distance) +FROM 'yellow_tripdata_2021-*.parquet' +``` + +We now write a query to remove everything above that number: + +```{code-cell} ipython3 +%%sql --save no_outliers --no-execute +SELECT trip_distance +FROM 'yellow_tripdata_2021-*.parquet' +WHERE trip_distance < 18.93 +``` + +```{code-cell} ipython3 +%sqlplot histogram --table no_outliers --column trip_distance --bins 50 +``` + +```{code-cell} ipython3 +%sqlplot boxplot --table no_outliers --column trip_distance +``` + +## Querying existing dataframes + +```{code-cell} ipython3 +import pandas as pd +from sqlalchemy import create_engine + +engine = create_engine("duckdb:///:memory:") +df = pd.DataFrame({"x": range(100)}) +``` + +```{code-cell} ipython3 +%sql engine +``` + +```{important} +If you're using DuckDB 1.1.0 or higher, you must run this before querying a data frame + +~~~sql +%sql SET python_scan_all_frames=true +~~~ +``` + +```{code-cell} ipython3 +%%sql +SELECT * +FROM df +WHERE x > 95 +``` + +## Passing parameters to connection + +```{code-cell} ipython3 +from sqlalchemy import create_engine + +some_engine = create_engine( + "duckdb:///:memory:", + connect_args={ + "preload_extensions": [], + }, +) +``` + +```{code-cell} ipython3 +%sql some_engine +``` diff --git a/doc/integrations/mariadb.ipynb b/doc/integrations/mariadb.ipynb new file mode 100644 index 000000000..de365ee98 --- /dev/null +++ b/doc/integrations/mariadb.ipynb @@ -0,0 +1,1019 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "fd3eb704", + "metadata": {}, + "source": [ + "# MariaDB\n", + "\n", + "\n", + "In this tutorial, we'll see how to query MariaDB from Jupyter. Optionally, you can spin up a testing server.\n", + "\n", + "```{tip}\n", + "If you encounter issues, feel free to join our [community](https://ploomber.io/community) and we'll be happy to help!\n", + "```\n" + ] + }, + { + "cell_type": "markdown", + "id": "4727e0b9", + "metadata": {}, + "source": [ + "## Pre-requisites\n", + "\n", + "To run this tutorial, you need to install the `mysqlclient` package.\n", + "\n", + "```{note}\n", + "We highly recommend you that you install it using `conda`, since it'll also install `mysql-connector-c`; if you want to use `pip`, then you need to install `mysql-connector-c` and then `mysqlclient`.\n", + "```" + ] + }, + { + "cell_type": "code", + "execution_count": 1, + "id": "ae033470", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Collecting package metadata (current_repodata.json): ...working... done\n", + "Solving environment: ...working... done\n", + "\n", + "## Package Plan ##\n", + "\n", + " environment location: /Users/eduardo/miniconda3/envs/jupysql\n", + "\n", + " added / updated specs:\n", + " - mysqlclient\n", + "\n", + "\n", + "The following NEW packages will be INSTALLED:\n", + "\n", + " mysql-connector-c pkgs/main/osx-arm64::mysql-connector-c-6.1.11-h4a942e0_1 \n", + " mysqlclient pkgs/main/osx-arm64::mysqlclient-2.0.3-py310hc377ac9_1 \n", + "\n", + "\n", + "Preparing transaction: ...working... done\n", + "Verifying transaction: ...working... done\n", + "Executing transaction: ...working... done\n", + "\n", + "Note: you may need to restart the kernel to use updated packages.\n" + ] + } + ], + "source": [ + "%conda install mysqlclient -c conda-forge --quiet" + ] + }, + { + "cell_type": "markdown", + "id": "dbf4706e", + "metadata": {}, + "source": [ + "## Start MariaDB instance\n", + "\n", + "If you don't have a MariaDB Server running or you want to spin up one for testing, you can do it with the official [Docker image](https://hub.docker.com/_/mariadb).\n", + "\n", + "To start the server:" + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "id": "f9c88366", + "metadata": { + "tags": [] + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "c2a0a18f9c37285ffdb17b22d75a3a8ae789a93f58a59c9c1892a4f30f7bf9a2\n" + ] + } + ], + "source": [ + "%%bash\n", + "docker run --detach --name mariadb \\\n", + " --env MARIADB_USER=user \\\n", + " --env MARIADB_PASSWORD=password \\\n", + " --env MARIADB_ROOT_PASSWORD=password \\\n", + " --env MARIADB_DATABASE=db \\\n", + " -p 3306:3306 mariadb:latest" + ] + }, + { + "cell_type": "markdown", + "id": "eaae2079", + "metadata": {}, + "source": [ + "Ensure that the container is running:" + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "id": "ec326f31-6cac-4f97-a5f6-5538e694082b", + "metadata": { + "tags": [] + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES\n", + "c2a0a18f9c37 mariadb:latest \"docker-entrypoint.s…\" 1 second ago Up Less than a second 0.0.0.0:3306->3306/tcp mariadb\n" + ] + } + ], + "source": [ + "%%bash\n", + "docker ps" + ] + }, + { + "cell_type": "markdown", + "id": "9d74d2df", + "metadata": {}, + "source": [ + "## Load sample data\n", + "\n", + "Now, let's fetch some sample data. We'll be using the [NYC taxi dataset](https://www.nyc.gov/site/tlc/about/tlc-trip-record-data.page):" + ] + }, + { + "cell_type": "code", + "execution_count": 4, + "id": "82b7d34f-aa22-4625-b2ff-cebcc70747da", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Note: you may need to restart the kernel to use updated packages.\n" + ] + } + ], + "source": [ + "%pip install pandas pyarrow --quiet" + ] + }, + { + "cell_type": "code", + "execution_count": 5, + "id": "16b1bfed", + "metadata": { + "tags": [] + }, + "outputs": [ + { + "data": { + "text/plain": [ + "(1369769, 19)" + ] + }, + "execution_count": 5, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "import pandas as pd\n", + "\n", + "df = pd.read_parquet(\n", + " \"https://d37ci6vzurychx.cloudfront.net/trip-data/yellow_tripdata_2021-01.parquet\"\n", + ")\n", + "df.shape" + ] + }, + { + "cell_type": "markdown", + "id": "f9ba5421", + "metadata": {}, + "source": [ + "As you can see, this chunk of data contains ~1.4M rows, loading the data will take about a minute:" + ] + }, + { + "cell_type": "code", + "execution_count": 6, + "id": "a3402cdf", + "metadata": { + "tags": [] + }, + "outputs": [], + "source": [ + "from sqlalchemy import create_engine\n", + "\n", + "engine = create_engine(\"mysql+mysqldb://user:password@127.0.0.1:3306/db\")\n", + "df.to_sql(name=\"taxi\", con=engine, chunksize=100_000)\n", + "engine.dispose()" + ] + }, + { + "cell_type": "markdown", + "id": "c7f25de0", + "metadata": { + "user_expressions": [] + }, + "source": [ + "## Query\n", + "\n", + "```{note}\n", + "`mysql` and `mysql+pymysql` connections (and perhaps others) don't read your client character set information from `.my.cnf.` You need to specify it in the connection string:\n", + "\n", + "~~~\n", + "mysql+pymysql://scott:tiger@localhost/foo?charset=utf8\n", + "~~~\n", + "```\n", + "\n", + "\n", + "Now, let's install JupySQL, authenticate and start querying the data!" + ] + }, + { + "cell_type": "code", + "execution_count": 7, + "id": "3df653d7", + "metadata": { + "tags": [] + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Note: you may need to restart the kernel to use updated packages.\n" + ] + } + ], + "source": [ + "%pip install jupysql --quiet\n", + "%load_ext sql\n", + "%sql mysql+mysqldb://user:password@127.0.0.1:3306/db" + ] + }, + { + "cell_type": "markdown", + "id": "4e7beda3", + "metadata": {}, + "source": [ + "```{important}\n", + "If the cell above fails, you might have some missing packages. Message us on [Slack](https://ploomber.io/community) and we'll help you!\n", + "```" + ] + }, + { + "cell_type": "markdown", + "id": "42d41200-2429-4f50-98c4-d974065f8070", + "metadata": {}, + "source": [ + "List the tables in the database:" + ] + }, + { + "cell_type": "code", + "execution_count": 13, + "id": "3ff7c2d9-20b3-4214-bbd0-8043dd78aa67", + "metadata": { + "tags": [] + }, + "outputs": [ + { + "data": { + "text/html": [ + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
Name
taxi
" + ], + "text/plain": [ + "+------+\n", + "| Name |\n", + "+------+\n", + "| taxi |\n", + "+------+" + ] + }, + "execution_count": 13, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "%sqlcmd tables" + ] + }, + { + "cell_type": "markdown", + "id": "89b881b3-ec2f-4002-ba61-2cf3c9fe2ef2", + "metadata": {}, + "source": [ + "List columns in the taxi table:" + ] + }, + { + "cell_type": "code", + "execution_count": 14, + "id": "547d41a8-2ea1-4152-a4da-653da9f4bcc9", + "metadata": { + "tags": [] + }, + "outputs": [ + { + "data": { + "text/html": [ + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
nametypedefaultcommentnullableautoincrement
indexBIGINTNoneNoneTrueFalse
VendorIDBIGINTNoneNoneTrueFalse
tpep_pickup_datetimeDATETIMENoneNoneTrue
tpep_dropoff_datetimeDATETIMENoneNoneTrue
passenger_countDOUBLENoneNoneTrue
trip_distanceDOUBLENoneNoneTrue
RatecodeIDDOUBLENoneNoneTrue
store_and_fwd_flagTEXTNoneNoneTrue
PULocationIDBIGINTNoneNoneTrueFalse
DOLocationIDBIGINTNoneNoneTrueFalse
payment_typeBIGINTNoneNoneTrueFalse
fare_amountDOUBLENoneNoneTrue
extraDOUBLENoneNoneTrue
mta_taxDOUBLENoneNoneTrue
tip_amountDOUBLENoneNoneTrue
tolls_amountDOUBLENoneNoneTrue
improvement_surchargeDOUBLENoneNoneTrue
total_amountDOUBLENoneNoneTrue
congestion_surchargeDOUBLENoneNoneTrue
airport_feeDOUBLENoneNoneTrue
" + ], + "text/plain": [ + "+-----------------------+----------+---------+---------+----------+---------------+\n", + "| name | type | default | comment | nullable | autoincrement |\n", + "+-----------------------+----------+---------+---------+----------+---------------+\n", + "| index | BIGINT | None | None | True | False |\n", + "| VendorID | BIGINT | None | None | True | False |\n", + "| tpep_pickup_datetime | DATETIME | None | None | True | |\n", + "| tpep_dropoff_datetime | DATETIME | None | None | True | |\n", + "| passenger_count | DOUBLE | None | None | True | |\n", + "| trip_distance | DOUBLE | None | None | True | |\n", + "| RatecodeID | DOUBLE | None | None | True | |\n", + "| store_and_fwd_flag | TEXT | None | None | True | |\n", + "| PULocationID | BIGINT | None | None | True | False |\n", + "| DOLocationID | BIGINT | None | None | True | False |\n", + "| payment_type | BIGINT | None | None | True | False |\n", + "| fare_amount | DOUBLE | None | None | True | |\n", + "| extra | DOUBLE | None | None | True | |\n", + "| mta_tax | DOUBLE | None | None | True | |\n", + "| tip_amount | DOUBLE | None | None | True | |\n", + "| tolls_amount | DOUBLE | None | None | True | |\n", + "| improvement_surcharge | DOUBLE | None | None | True | |\n", + "| total_amount | DOUBLE | None | None | True | |\n", + "| congestion_surcharge | DOUBLE | None | None | True | |\n", + "| airport_fee | DOUBLE | None | None | True | |\n", + "+-----------------------+----------+---------+---------+----------+---------------+" + ] + }, + "execution_count": 14, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "%sqlcmd columns --table taxi" + ] + }, + { + "cell_type": "markdown", + "id": "490e8aae-2aa1-4c19-bb2a-141849b03c2d", + "metadata": {}, + "source": [ + "Query our data:" + ] + }, + { + "cell_type": "code", + "execution_count": 8, + "id": "84902d46", + "metadata": { + "tags": [] + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "* mysql+mysqldb://user:***@127.0.0.1:3306/db\n", + "1 rows affected.\n" + ] + }, + { + "data": { + "text/html": [ + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
COUNT(*)
1369769
" + ], + "text/plain": [ + "[(1369769,)]" + ] + }, + "execution_count": 8, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "%%sql\n", + "SELECT COUNT(*) FROM taxi" + ] + }, + { + "cell_type": "markdown", + "id": "4e838b37-e679-4ffa-98b7-1dc80e909a41", + "metadata": {}, + "source": [ + "## Parametrize queries" + ] + }, + { + "cell_type": "code", + "execution_count": 9, + "id": "ff575b33-c27c-4c58-8f31-17b930454191", + "metadata": { + "tags": [] + }, + "outputs": [], + "source": [ + "threshold = 10" + ] + }, + { + "cell_type": "code", + "execution_count": 10, + "id": "58106d30-790d-4c8a-88da-86dd0b0c12bc", + "metadata": { + "tags": [] + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "* mysql+mysqldb://user:***@127.0.0.1:3306/db\n", + "1 rows affected.\n" + ] + }, + { + "data": { + "text/html": [ + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
COUNT(*)
1297415
" + ], + "text/plain": [ + "[(1297415,)]" + ] + }, + "execution_count": 10, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "%%sql\n", + "SELECT COUNT(*) FROM taxi\n", + "WHERE trip_distance < {{threshold}}" + ] + }, + { + "cell_type": "code", + "execution_count": 11, + "id": "b820944c-0016-484a-a23f-42f4290ccce8", + "metadata": { + "tags": [] + }, + "outputs": [], + "source": [ + "threshold = 0.5" + ] + }, + { + "cell_type": "code", + "execution_count": 12, + "id": "7fb73771-2251-4ed4-95ca-8f89ef0131b6", + "metadata": { + "tags": [] + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "* mysql+mysqldb://user:***@127.0.0.1:3306/db\n", + "1 rows affected.\n" + ] + }, + { + "data": { + "text/html": [ + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
COUNT(*)
73849
" + ], + "text/plain": [ + "[(73849,)]" + ] + }, + "execution_count": 12, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "%%sql\n", + "SELECT COUNT(*) FROM taxi\n", + "WHERE trip_distance < {{threshold}}" + ] + }, + { + "cell_type": "markdown", + "id": "cffcaf73-c2f3-4cc6-b777-7a2784c743e9", + "metadata": {}, + "source": [ + "## CTEs\n", + "\n", + "You can break down queries into multiple cells, JupySQL will build a CTE for you:" + ] + }, + { + "cell_type": "code", + "execution_count": 15, + "id": "7ec71402", + "metadata": { + "tags": [] + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "* mysql+mysqldb://user:***@127.0.0.1:3306/db\n", + "Skipping execution...\n" + ] + } + ], + "source": [ + "%%sql --save many_passengers --no-execute\n", + "SELECT *\n", + "FROM taxi\n", + "WHERE passenger_count > 3\n", + "-- remove top 1% outliers for better visualization\n", + "AND trip_distance < 18.93" + ] + }, + { + "cell_type": "code", + "execution_count": 16, + "id": "e5195ed0-d354-4565-937e-fb6be55e4353", + "metadata": { + "tags": [] + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "* mysql+mysqldb://user:***@127.0.0.1:3306/db\n", + "1 rows affected.\n" + ] + }, + { + "data": { + "text/html": [ + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
MIN(trip_distance)AVG(trip_distance)MAX(trip_distance)
0.02.501088981288983618.92
" + ], + "text/plain": [ + "[(0.0, 2.5010889812889836, 18.92)]" + ] + }, + "execution_count": 16, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "%%sql --save trip_stats --with many_passengers\n", + "SELECT MIN(trip_distance), AVG(trip_distance), MAX(trip_distance)\n", + "FROM many_passengers" + ] + }, + { + "cell_type": "markdown", + "id": "4e9882ea-2769-4a12-abc3-1a2bf95bfdc0", + "metadata": {}, + "source": [ + "This is what JupySQL executes:" + ] + }, + { + "cell_type": "code", + "execution_count": 17, + "id": "636803f1-7c40-4695-b950-5604dad98c33", + "metadata": { + "tags": [] + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "WITH `many_passengers` AS (\n", + "SELECT *\n", + "FROM taxi\n", + "WHERE passenger_count > 3\n", + "-- remove top 1% outliers for better visualization\n", + "AND trip_distance < 18.93)\n", + "SELECT MIN(trip_distance), AVG(trip_distance), MAX(trip_distance)\n", + "FROM many_passengers\n" + ] + } + ], + "source": [ + "query = %sqlcmd snippets trip_stats\n", + "print(query)" + ] + }, + { + "cell_type": "markdown", + "id": "8599517c-10a3-47f4-9a5d-a4d45712503a", + "metadata": {}, + "source": [ + "## Plotting" + ] + }, + { + "cell_type": "code", + "execution_count": 18, + "id": "3c7845eb-59f4-495f-a3a3-d9ff150e323e", + "metadata": { + "tags": [] + }, + "outputs": [ + { + "data": { + "text/plain": [ + "" + ] + }, + "execution_count": 18, + "metadata": {}, + "output_type": "execute_result" + }, + { + "data": { + "image/png": "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fPiwbDabRo4cqZdffllff/21du3apYEDByogIMD8ZuKtt96qbt26aciQIdq8ebPWr1+v2NhY9e3bVwEBAZKkhx56SC4uLho8eLCSkpI0f/58vfnmmxo1apTZx1NPPaWlS5dq6tSp2rdvn+Li4rR161bFxsZe7x8JAACoQCqX5sa3bt2qO++803yeG36io6MVHx+v559/XmfPntXQoUOVlpam9u3ba+nSpXJzczOXmTdvnmJjY9WlSxc5OTmpd+/eeuutt8z5drtdy5cvV0xMjFq2bKmaNWtq3LhxDvfaateunT7++GP985//1P/7f/9P9evX16JFi9SoUaPr8FMAAAAVVZm5j1Z5x320AAAof27Y+2gBAACUdwQtAAAAixC0AAAALELQAgAAsAhBCwAAwCIELQAAAIsQtAAAACxC0AIAALAIQQsAAMAiBC0AAACLELQAAAAsQtACAACwCEELAADAIgQtAAAAixC0AAAALELQAgAAsAhBCwAAwCIELQAAAIsQtAAAACxC0AIAALAIQQsAAMAiBC0AAACLELQAAAAsQtACAACwCEELAADAIgQtAAAAixC0AAAALELQAgAAsAhBCwAAwCIELQAAAIsQtAAAACxC0AIAALAIQQsAAMAiBC0AAACLELQAAAAsQtACAACwCEELAADAIgQtAAAAixC0AAAALELQAgAAsEjl0m4AJSd4zJIys52Dr0Zeh04AACjbOKIFAABgEYIWAACARQhaAAAAFiFoAQAAWISgBQAAYJEyHbSys7P14osvKiQkRO7u7qpbt65eeuklGYZh1hiGoXHjxqlWrVpyd3dXeHi4Dhw44LCekydPqn///vLy8pK3t7cGDx6sM2fOONTs3LlTHTp0kJubmwIDAzV58uTrMkYAAFBxlemg9dprr2nmzJl6++23tXfvXr322muaPHmypk+fbtZMnjxZb731lmbNmqVNmzbJw8NDERERunDhglnTv39/JSUlKSEhQYsXL9a6des0dOhQc35GRoa6du2qoKAgbdu2TVOmTFFcXJzee++96zpeAABQsdiMyw8PlTF33323/Pz89OGHH5rTevfuLXd3d82dO1eGYSggIEDPPPOMnn32WUlSenq6/Pz8FB8fr759+2rv3r0KDQ3Vli1b1KpVK0nS0qVL1aNHDx09elQBAQGaOXOmXnjhBaWkpMjFxUWS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AFiEoAUAAGARghYAAIBFCFoAyry4uDg1a9bM0m107txZI0eONJ8HBwdr2rRplm4TQMVH0AJQaq4MNwV59tln8/z9R6tt2bJFQ4cOLVQtoQxAQSqXdgMAUBDDMJSdnS1PT095enpe1237+Phc1+0BqJg4ogWgVAwaNEhr167Vm2++KZvNJpvNpvj4eNlsNn333Xdq2bKlXF1d9f333+c5dTho0CBFRUVpwoQJ8vHxkZeXl5544gllZmYWattnz57VwIED5enpqVq1amnq1Kl5ai4/SmUYhuLi4lSnTh25uroqICBAI0aMkPTXUblDhw7p6aefNschSSdOnFC/fv100003qUqVKmrcuLE++eQTh2107txZI0aM0PPPP6/q1avL399fcXFxDjVpaWl6/PHH5efnJzc3NzVq1EiLFy8253///ffq0KGD3N3dFRgYqBEjRujs2bOF+jkAsB5BC0CpePPNNxUWFqYhQ4bo999/1++//67AwEBJ0pgxY/Tqq69q7969atKkSb7Lr1y5Unv37tWaNWv0ySefaMGCBZowYUKhtv3cc89p7dq1+uqrr7R8+XKtWbNG27dvL7D+yy+/1BtvvKF3331XBw4c0KJFi9S4cWNJ0oIFC1S7dm1NnDjRHIckXbhwQS1bttSSJUu0e/duDR06VAMGDNDmzZsd1j1nzhx5eHho06ZNmjx5siZOnKiEhARJUk5Ojrp3767169dr7ty52rNnj1599VVVqlRJkvTLL7+oW7du6t27t3bu3Kn58+fr+++/V2xsbKF+DgCug+L+pWwA+Ls6depkPPXUU+bz1atXG5KMRYsWOdSNHz/eaNq0qfk8OjraqF69unH27Flz2syZMw1PT08jOzv7qts8ffq04eLiYnz22WfmtBMnThju7u4OvQQFBRlvvPGGYRiGMXXqVOOWW24xMjMz813n5bVXExkZaTzzzDPm806dOhnt27d3qLn99tuN0aNHG4ZhGMuWLTOcnJyM/fv357u+wYMHG0OHDnWY9r///c9wcnIyzp8/f81+AFiPI1oAypxWrVpds6Zp06aqUqWK+TwsLExnzpzRkSNHrrrcL7/8oszMTLVp08acVr16dTVo0KDAZR544AGdP39eN998s4YMGaKFCxfq0qVLV91Odna2XnrpJTVu3FjVq1eXp6enli1bpsOHDzvUXXnErlatWjp+/LgkaceOHapdu7ZuueWWfLfx448/Kj4+3ryGzdPTUxEREcrJyVFycvJV+wNwfXAxPIAyx8PDo7RbcBAYGKj9+/drxYoVSkhI0JNPPqkpU6Zo7dq1cnZ2zneZKVOm6M0339S0adPUuHFjeXh4aOTIkXmuI7tyeZvNppycHEmSu7v7Vfs6c+aMHn/8cfN6scvVqVOnKEMEYBGCFoBS4+Liouzs7GIt++OPP+r8+fNmGNm4caM8PT3N67wKUrduXTk7O2vTpk1mGDl16pR++uknderUqcDl3N3d1bNnT/Xs2VMxMTFq2LChdu3apRYtWuQ7jvXr1+vee+/Vww8/LOmv661++uknhYaGFnqMTZo00dGjR/XTTz/le1SrRYsW2rNnj+rVq1fodQK4vjh1CKDUBAcHa9OmTTp48KD+/PNP80hOYWRmZmrw4MHas2ePvv32W40fP16xsbFycrr6f2uenp4aPHiwnnvuOa1atUq7d+/WoEGDrrpcfHy8PvzwQ+3evVu//vqr5s6dK3d3dwUFBZnjWLdunX777Tf9+eefkqT69esrISFBGzZs0N69e/X4448rNTW10OOTpE6dOqljx47q3bu3EhISlJycrO+++05Lly6VJI0ePVobNmxQbGysduzYoQMHDuirr77iYnigDCFoASg1zz77rCpVqqTQ0FD5+PjkuX7parp06aL69eurY8eOevDBB3XPPffkuTVCQaZMmaIOHTqoZ8+eCg8PV/v27dWyZcsC6729vfX+++/rjjvuUJMmTbRixQp98803qlGjhiRp4sSJOnjwoOrWrWvef+uf//ynWrRooYiICHXu3Fn+/v6Kiooq9Phyffnll7r99tvVr18/hYaG6vnnnzePnjVp0kRr167VTz/9pA4dOqh58+YaN26cAgICirwdANawGYZhlHYTAFAUgwYNUlpamhYtWlTarQDAVXFECwAAwCIELQAVyuHDhx1ud3DloyinJwHg7+LUIYAK5dKlSzp48GCB84ODg1W5Ml+4BnB9ELQAAAAswqlDAAAAixC0AAAALELQAgAAsAhBCwAAwCIELQAAAIsQtAAAACxC0AIAALAIQQsAAMAi/x/xuOIVWacZFAAAAABJRU5ErkJggg==", + "text/plain": [ + "
" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "%sqlplot histogram --table many_passengers --column trip_distance --with many_passengers" + ] + }, + { + "cell_type": "markdown", + "id": "3544f41d", + "metadata": {}, + "source": [ + "## Clean up\n", + "\n", + "To stop and remove the container:" + ] + }, + { + "cell_type": "code", + "execution_count": 19, + "id": "6d408cc0", + "metadata": { + "tags": [] + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES\n", + "c2a0a18f9c37 mariadb:latest \"docker-entrypoint.s…\" 2 minutes ago Up 2 minutes 0.0.0.0:3306->3306/tcp mariadb\n" + ] + } + ], + "source": [ + "%%bash\n", + "docker container ls" + ] + }, + { + "cell_type": "code", + "execution_count": 20, + "id": "42c37efd-1666-42dd-a38c-1944860b9c39", + "metadata": { + "tags": [] + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "mariadb\n" + ] + } + ], + "source": [ + "%%bash\n", + "docker container stop mariadb" + ] + }, + { + "cell_type": "code", + "execution_count": 21, + "id": "6c9bce10", + "metadata": { + "tags": [] + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "mariadb\n" + ] + } + ], + "source": [ + "%%bash\n", + "docker container rm mariadb" + ] + }, + { + "cell_type": "code", + "execution_count": 22, + "id": "17d42e97-9be7-43a8-916a-56dea1ca3dda", + "metadata": { + "tags": [] + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES\n" + ] + } + ], + "source": [ + "%%bash\n", + "docker container ls" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3 (ipykernel)", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.10.11" + }, + "myst": { + "html_meta": { + "description lang=en": "Query a MariaDB database from Jupyter via JupySQL", + "keywords": "jupyter, sql, jupysql, mysql", + "property=og:locale": "en_US" + } + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/doc/integrations/mindsdb.ipynb b/doc/integrations/mindsdb.ipynb new file mode 100644 index 000000000..88f7d85e9 --- /dev/null +++ b/doc/integrations/mindsdb.ipynb @@ -0,0 +1,733 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "0b7616c7-3bb2-4238-b98e-93153b923688", + "metadata": { + "tags": [] + }, + "source": [ + "# Mindsdb\n", + "In this guide we'll show an integration with MindsDB.\n", + "\n", + "We will use Jupysql to run queries on top of MindsDB.\n", + "Train the model on top of it via SQL.\n", + "Once the model is ready, we will use sklearn-evaluation to generate plots and evaluate our model.\n", + "\n", + "MindsDB is a powerful machine learning platform that enables users to easily build and deploy predictive models. One of the key features of MindsDB is its integration with Jupysql, which allows users to connect to and query databases from Jupyter notebooks. In this article, we will take a deeper dive into the technical details of this integration, and provide examples of how it can be used in practice. We will explore a customer churn dataset and generate predictions if our customer will churn or not. Once we're done with that we will evaluate our model and see how easy it is through a single line of code.\n", + "\n", + "The integration between Jupysql and MindsDB is made possible by the use of the sqlite3 library. This library allows for easy communication between the two systems, and enables users to connect to a wide variety of databases and warehouses, including Redshift, Snowflake, Big query, DuckDB, SQLite, MySQL, and PostgreSQL. Once connected, users can run SQL queries directly from the MindsDB environment, making it easy to extract data from databases and use it to train predictive models.\n", + "\n", + "Let's look at an example of how this integration can be used. Suppose we have a database containing customer churn data, and we want to use this data to train a model that predicts if a customer will churn or not. First, we would need to connect to the database from our Jupyter notebook using the jupysql library. This can be done using the following code:\n" + ] + }, + { + "cell_type": "markdown", + "id": "9a4aa1de-e2d9-4451-9b81-475e58a83763", + "metadata": {}, + "source": [ + "## Pre-requisites" + ] + }, + { + "cell_type": "code", + "execution_count": 1, + "id": "b1f50ced-ff60-42c9-a062-7de4d22bc2d9", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Note: you may need to restart the kernel to use updated packages.\n" + ] + } + ], + "source": [ + "# Install required packages\n", + "%pip install --quiet PyMySQL jupysql sklearn-evaluation --upgrade" + ] + }, + { + "cell_type": "code", + "execution_count": 14, + "id": "fa78d6c7-bd63-404c-a3af-3731db1ad699", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "The sql extension is already loaded. To reload it, use:\n", + " %reload_ext sql\n" + ] + } + ], + "source": [ + "from sklearn_evaluation import plot\n", + "\n", + "# Import jupysql Jupyter extension to create SQL cells\n", + "%load_ext sql\n", + "%config SqlMagic.autocommit=False" + ] + }, + { + "cell_type": "markdown", + "id": "5ea94621-7988-45b2-91b5-f3714caff986", + "metadata": {}, + "source": [ + "**You'd need to make sure your MindsDB is up and reachable for the next stages. You can use either the local or the cloud version.**\n", + "\n", + "**Note:** you will need to adjust the connection string according to the instance you're trying to connect to (url, user, password).\n", + "In addition you'd need to load [the dataset file](https://github.com/mindsdb/mindsdb-examples/blob/master/classics/customer_churn/raw_data/WA_Fn-UseC_-Telco-Customer-Churn.csv) into the DB, follow this guide on [how to do it](https://docs.mindsdb.com/sql/create/file)." + ] + }, + { + "cell_type": "code", + "execution_count": 15, + "id": "a83e6b8d-74ee-48b4-8a2c-800f87be442f", + "metadata": {}, + "outputs": [], + "source": [ + "%sql mysql+pymysql://YOUR_EMAIL:YOUR_PASSWORD@cloud.mindsdb.com:3306" + ] + }, + { + "cell_type": "markdown", + "id": "6f7872b0-d19f-4b78-b954-e629f24aa6de", + "metadata": {}, + "source": [ + "## Query" + ] + }, + { + "cell_type": "code", + "execution_count": 16, + "id": "8e329923-aa88-49da-a447-f0a3b65d550e", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "* mysql+pymysql://ido%40ploomber.io:***@cloud.mindsdb.com:3306\n", + "2 rows affected.\n" + ] + }, + { + "data": { + "text/html": [ + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
Tables_in_files
churn
home_rentals
" + ], + "text/plain": [ + "[('churn',), ('home_rentals',)]" + ] + }, + "execution_count": 16, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "%sql SHOW TABLES FROM files;" + ] + }, + { + "cell_type": "code", + "execution_count": 17, + "id": "d8e26cc8-2b4b-4931-998d-a9197cc9f32c", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "* mysql+pymysql://ido%40ploomber.io:***@cloud.mindsdb.com:3306\n", + "5 rows affected.\n" + ] + }, + { + "data": { + "text/html": [ + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
customerIDgenderSeniorCitizenPartnerDependentstenurePhoneServiceMultipleLinesInternetServiceOnlineSecurityOnlineBackupDeviceProtectionTechSupportStreamingTVStreamingMoviesContractPaperlessBillingPaymentMethodMonthlyChargesTotalChargesChurn
7590-VHVEGFemale0YesNo1NoNo phone serviceDSLNoYesNoNoNoNoMonth-to-monthYesElectronic check29.8529.85No
5575-GNVDEMale0NoNo34YesNoDSLYesNoYesNoNoNoOne yearNoMailed check56.951889.5No
3668-QPYBKMale0NoNo2YesNoDSLYesYesNoNoNoNoMonth-to-monthYesMailed check53.85108.15Yes
7795-CFOCWMale0NoNo45NoNo phone serviceDSLYesNoYesYesNoNoOne yearNoBank transfer (automatic)42.31840.75No
9237-HQITUFemale0NoNo2YesNoFiber opticNoNoNoNoNoNoMonth-to-monthYesElectronic check70.7151.65Yes
" + ], + "text/plain": [ + "[('7590-VHVEG', 'Female', 0, 'Yes', 'No', 1, 'No', 'No phone service', 'DSL', 'No', 'Yes', 'No', 'No', 'No', 'No', 'Month-to-month', 'Yes', 'Electronic check', 29.85, '29.85', 'No'),\n", + " ('5575-GNVDE', 'Male', 0, 'No', 'No', 34, 'Yes', 'No', 'DSL', 'Yes', 'No', 'Yes', 'No', 'No', 'No', 'One year', 'No', 'Mailed check', 56.95, '1889.5', 'No'),\n", + " ('3668-QPYBK', 'Male', 0, 'No', 'No', 2, 'Yes', 'No', 'DSL', 'Yes', 'Yes', 'No', 'No', 'No', 'No', 'Month-to-month', 'Yes', 'Mailed check', 53.85, '108.15', 'Yes'),\n", + " ('7795-CFOCW', 'Male', 0, 'No', 'No', 45, 'No', 'No phone service', 'DSL', 'Yes', 'No', 'Yes', 'Yes', 'No', 'No', 'One year', 'No', 'Bank transfer (automatic)', 42.3, '1840.75', 'No'),\n", + " ('9237-HQITU', 'Female', 0, 'No', 'No', 2, 'Yes', 'No', 'Fiber optic', 'No', 'No', 'No', 'No', 'No', 'No', 'Month-to-month', 'Yes', 'Electronic check', 70.7, '151.65', 'Yes')]" + ] + }, + "execution_count": 17, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "%%sql \n", + "SELECT *\n", + "FROM files.churn\n", + "LIMIT 5;" + ] + }, + { + "cell_type": "code", + "execution_count": 18, + "id": "c124b1e0-510c-4dd4-ab17-9251a6f4662c", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "* mysql+pymysql://ido%40ploomber.io:***@cloud.mindsdb.com:3306\n", + "0 rows affected.\n" + ] + }, + { + "data": { + "text/plain": [ + "[]" + ] + }, + "execution_count": 18, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "%%sql\n", + "CREATE MODEL mindsdb.customer_churn_predictor\n", + "FROM files\n", + " (SELECT * FROM churn)\n", + "PREDICT Churn;" + ] + }, + { + "cell_type": "markdown", + "id": "224f6c6b-5ecc-4652-b376-5c7c66544798", + "metadata": {}, + "source": [ + "## Training the model\n", + "\n", + "Training the model have 3 different statuses: Generating, Training, Complete.\n", + "Since it's a pretty small dataset it'd take a few minutes to get to the complete status.\n", + "\n", + "Once the status is \"complete\", move on to the next section.\n", + "\n", + "**Waiting for the below cell to show complete**" + ] + }, + { + "cell_type": "code", + "execution_count": 7, + "id": "11e4e8a8-a9c0-4d33-ad37-e29d4b3b8dbf", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "* mysql+pymysql://ido%40ploomber.io:***@cloud.mindsdb.com:3306\n", + "1 rows affected.\n" + ] + }, + { + "data": { + "text/html": [ + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
status
complete
" + ], + "text/plain": [ + "[('complete',)]" + ] + }, + "execution_count": 7, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "%%sql\n", + "SELECT status\n", + "FROM mindsdb.models\n", + "WHERE name='customer_churn_predictor';" + ] + }, + { + "cell_type": "markdown", + "id": "31dd94bb-d732-4734-bb20-fff43290aa47", + "metadata": {}, + "source": [ + "Now that our model is reeady to generate predictions, we can start using it.\n", + "In the cell below we'll start by getting a single prediction.\n", + "\n", + "We are classifying if a user will churn, it's confidence and the explanation based on a few input parameters such as their internet service, if they have phone service, dependents and more." + ] + }, + { + "cell_type": "code", + "execution_count": 8, + "id": "b8bb44bf-bb37-49eb-8ae8-9b9a49cfd7bb", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "* mysql+pymysql://ido%40ploomber.io:***@cloud.mindsdb.com:3306\n", + "1 rows affected.\n" + ] + }, + { + "data": { + "text/html": [ + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
ChurnChurn_confidenceChurn_explain
Yes0.7752808988764045{"predicted_value": "Yes", "confidence": 0.7752808988764045, "anomaly": null, "truth": null, "probability_class_No": 0.4756, "probability_class_Yes": 0.5244}
" + ], + "text/plain": [ + "[('Yes', '0.7752808988764045', '{\"predicted_value\": \"Yes\", \"confidence\": 0.7752808988764045, \"anomaly\": null, \"truth\": null, \"probability_class_No\": 0.4756, \"probability_class_Yes\": 0.5244}')]" + ] + }, + "execution_count": 8, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "%%sql\n", + "SELECT Churn, Churn_confidence, Churn_explain\n", + "FROM mindsdb.customer_churn_predictor\n", + "WHERE SeniorCitizen=0\n", + "AND Partner='Yes'\n", + "AND Dependents='No'\n", + "AND tenure=1\n", + "AND PhoneService='No'\n", + "AND MultipleLines='No phone service'\n", + "AND InternetService='DSL';" + ] + }, + { + "cell_type": "markdown", + "id": "9f99eeac-f5df-4d66-9dff-72ed53ad20e6", + "metadata": {}, + "source": [ + "We can get a batch of multiple entries.\n", + "\n", + "In the cell bellow we're getting 5 rows (customers) with different parameters such as monthly charges and contract type." + ] + }, + { + "cell_type": "code", + "execution_count": 9, + "id": "c75c9ebd-df84-4dca-be61-4c3b0c29ac6a", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "* mysql+pymysql://ido%40ploomber.io:***@cloud.mindsdb.com:3306\n", + "5 rows affected.\n" + ] + }, + { + "data": { + "text/html": [ + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
customerIDContractMonthlyChargesChurn
7590-VHVEGMonth-to-month29.85Yes
5575-GNVDEOne year56.95No
3668-QPYBKMonth-to-month53.85Yes
7795-CFOCWOne year42.3No
9237-HQITUMonth-to-month70.7Yes
" + ], + "text/plain": [ + "[('7590-VHVEG', 'Month-to-month', 29.85, 'Yes'),\n", + " ('5575-GNVDE', 'One year', 56.95, 'No'),\n", + " ('3668-QPYBK', 'Month-to-month', 53.85, 'Yes'),\n", + " ('7795-CFOCW', 'One year', 42.3, 'No'),\n", + " ('9237-HQITU', 'Month-to-month', 70.7, 'Yes')]" + ] + }, + "execution_count": 9, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "%%sql\n", + "SELECT t.customerID, t.Contract, t.MonthlyCharges, m.Churn\n", + "FROM files.churn AS t\n", + "JOIN mindsdb.customer_churn_predictor AS m\n", + "LIMIT 5;" + ] + }, + { + "cell_type": "markdown", + "id": "707a4fbd-5bf0-462c-87bd-dea42b8c7a99", + "metadata": {}, + "source": [ + "## Classifier evaluation\n", + "\n", + "Now that our model is ready, we want and should evaluate it.\n", + "We will query the actual and predicted values from MindsDB to evaluate our model.\n", + "\n", + "Once we have the values we can plot them using sklearn-evaluation.\n", + "We will start first by getting all of our customers into a `pandas dataframe`.\n", + "\n", + "**Note:** Take a close look on the query below, by saving it into a variable we can compose complex and longer queries." + ] + }, + { + "cell_type": "code", + "execution_count": 10, + "id": "7c3e5df2-3ac8-4355-996b-a321ec55d52a", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "* mysql+pymysql://ido%40ploomber.io:***@cloud.mindsdb.com:3306\n", + "7043 rows affected.\n" + ] + } + ], + "source": [ + "%%sql result << SELECT t.customerID, t.Contract, t.MonthlyCharges, m.Churn, \n", + "t.Churn as actual\n", + "FROM files.churn AS t\n", + "JOIN mindsdb.customer_churn_predictor AS m;" + ] + }, + { + "cell_type": "markdown", + "id": "6ecf0d7c-5d15-41fa-a3c3-9ef39f55a2b1", + "metadata": {}, + "source": [ + "In the cell below, we're saving the query output into a dataframe.\n", + "\n", + "We then, take the predicted churn values and the actual churn values into seperate variables." + ] + }, + { + "cell_type": "code", + "execution_count": 11, + "id": "44655fd9-7008-4b4d-8b99-7732e1922e98", + "metadata": {}, + "outputs": [], + "source": [ + "df = result.DataFrame()\n", + "y_pred = df.Churn\n", + "y_test = df.actual" + ] + }, + { + "cell_type": "markdown", + "id": "c6bc42c2-32c1-4749-8f1a-bf5c2d8cdf3c", + "metadata": {}, + "source": [ + "## Plotting\n", + "Now that we have the values needed to evaluate our model, we can plot it into a confusion matrix:" + ] + }, + { + "cell_type": "code", + "execution_count": 12, + "id": "e7b17c6a-2fa6-4df4-b9cc-1ce10e7f7b31", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "" + ] + }, + "execution_count": 12, + "metadata": {}, + "output_type": "execute_result" + }, + { + "data": { + "image/png": "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", + "text/plain": [ + "
" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "plot.ConfusionMatrix.from_raw_data(y_test, y_pred, normalize=False)" + ] + }, + { + "cell_type": "markdown", + "id": "733c8295-1908-41d6-96d2-ee3ac4278270", + "metadata": {}, + "source": [ + "Additionally we can generate a classification report for our model and compare it with other different models or previous iterations." + ] + }, + { + "cell_type": "code", + "execution_count": 13, + "id": "f5cae912-eb8f-4b4e-88f7-1794cfcd9701", + "metadata": {}, + "outputs": [ + { + "data": { + "image/png": "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", + "text/plain": [ + "
" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "target_names = [\"No churn\", \"churn\"]\n", + "\n", + "report = plot.ClassificationReport.from_raw_data(\n", + " y_test, y_pred, target_names=target_names\n", + ")" + ] + }, + { + "cell_type": "markdown", + "id": "d2d978b3-5933-44e9-9651-d02c83a5a247", + "metadata": {}, + "source": [ + "## Conclusion\n", + "\n", + "In conclusion, the integration between Jupysql and MindsDB is a powerful tool for building and deploying predictive models. It allows easy data extraction and manipulation, and makes it simple to deploy models into production. This makes it a valuable tool for data scientists, machine learning engineers, and anyone looking to build predictive models. With this integration, the process of data extraction, cleaning, modeling, and deploying can all be done in one place: your Jupyter notebook. MindsDB on the other hand is making it a more efficient and streamlined process reducing the need for compute." + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3 (ipykernel)", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.10.11" + }, + "vscode": { + "interpreter": { + "hash": "afb734500600fd355917ca529030176ea0ca205570884b88f2f6f7d791fd3fbe" + } + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/doc/integrations/mssql.ipynb b/doc/integrations/mssql.ipynb new file mode 100644 index 000000000..503b1cb7d --- /dev/null +++ b/doc/integrations/mssql.ipynb @@ -0,0 +1,1390 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "e71caf17-6e95-45ec-b839-edb6b2384a47", + "metadata": {}, + "source": [ + "# Microsoft SQL Server\n", + "\n", + "In this tutorial, we'll see how to query Microsoft SQL Server from Jupyter. Optionally, you can spin up a testing server.\n", + "\n", + "```{tip}\n", + "If you encounter issues, feel free to join our [community](https://ploomber.io/community) and we'll be happy to help!\n", + "```\n", + "\n", + "## Pre-requisites\n", + "\n", + "The first step is to install the [ODBC driver for SQL Server](https://learn.microsoft.com/en-us/sql/connect/odbc/microsoft-odbc-driver-for-sql-server?view=sql-server-ver16).\n", + "\n", + "- Instructions for [Linux](https://learn.microsoft.com/en-us/sql/connect/odbc/linux-mac/installing-the-microsoft-odbc-driver-for-sql-server?view=sql-server-ver16&tabs=alpine18-install%2Calpine17-install%2Cdebian8-install%2Credhat7-13-install%2Crhel7-offline)\n", + "- Instructions for [Mac](https://learn.microsoft.com/en-us/sql/connect/odbc/linux-mac/install-microsoft-odbc-driver-sql-server-macos?view=sql-server-ver16)\n", + "\n", + "For example, if you're on a Mac, you can install the driver with `brew`:\n", + "\n", + "```sh\n", + "/bin/bash -c \"$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/master/install.sh)\"\n", + "brew tap microsoft/mssql-release https://github.com/Microsoft/homebrew-mssql-release\n", + "brew update\n", + "HOMEBREW_ACCEPT_EULA=Y brew install msodbcsql18 mssql-tools18\n", + "```" + ] + }, + { + "cell_type": "markdown", + "id": "459d2fb2-82e8-4fff-84b7-f303f4afb4d5", + "metadata": {}, + "source": [ + "## Start Microsoft SQL Server instance\n", + "\n", + "If you don't have a SQL Server running or you want to spin up one for testing, you can do it with the official [Docker image](https://hub.docker.com/_/microsoft-mssql-server).\n", + "\n", + "```{important}\n", + "If you're on a Mac with Apple Silicon (e.g., M1 processor), ensure you're running the latest Docker Desktop version. More info [here](https://bornsql.ca/blog/you-can-run-a-sql-server-docker-container-on-apple-m1-and-m2-silicon/).\n", + "```\n", + "\n", + "\n", + "To start the server:" + ] + }, + { + "cell_type": "code", + "execution_count": 5, + "id": "332eec37-b2b2-4a3b-98ec-138361752f6e", + "metadata": { + "tags": [] + }, + "outputs": [ + { + "name": "stderr", + "output_type": "stream", + "text": [ + "WARNING: The requested image's platform (linux/amd64) does not match the detected host platform (linux/arm64/v8) and no specific platform was requested\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "00721df70ea8d5f91c792a84f28f4e0fc6c0ff53f1f4d04cb6911a3a4714deba\n" + ] + } + ], + "source": [ + "%%bash\n", + "docker run -e \"ACCEPT_EULA=Y\" \\\n", + " -e \"MSSQL_SA_PASSWORD=MyPassword!\" \\\n", + " -p 1433:1433 \\\n", + " -d mcr.microsoft.com/mssql/server:2022-latest" + ] + }, + { + "cell_type": "markdown", + "id": "7d18492d-e58c-4117-ac5d-602bc7e6445c", + "metadata": {}, + "source": [ + "```{important}\n", + "Ensure you set a strong password, otherwise the container will shut down silently!\n", + "```" + ] + }, + { + "cell_type": "markdown", + "id": "7f420cee-848e-443e-997e-a6066d5fe704", + "metadata": {}, + "source": [ + "Ensure that your container is running (run the command a few seconds after running the previous one to ensure it dind't shut down silently):" + ] + }, + { + "cell_type": "code", + "execution_count": 6, + "id": "4cf0cbb5-a120-4b5c-8a49-8484ba5c01fa", + "metadata": { + "tags": [] + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES\n" + ] + } + ], + "source": [ + "%%bash\n", + "docker ps" + ] + }, + { + "cell_type": "markdown", + "id": "a8cc1b60-5d0e-45f6-bb8c-b68952a39f62", + "metadata": {}, + "source": [ + "If you have issues with the previous command, you can try with SQL Edge:" + ] + }, + { + "cell_type": "code", + "execution_count": 7, + "id": "83bff03a-cae7-4775-a2b6-d85ceb0ce440", + "metadata": { + "tags": [] + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "fabfc30490a17dc0a48313c35289218ff563070b11622bc43f07e82080b2a201\n" + ] + } + ], + "source": [ + "%%bash\n", + "docker run -e \"ACCEPT_EULA=1\" -e \"MSSQL_SA_PASSWORD=MyPassword!\" \\\n", + " -e \"MSSQL_PID=Developer\" -e \"MSSQL_USER=sa\" \\\n", + " -p 1433:1433 -d --name=sql mcr.microsoft.com/azure-sql-edge" + ] + }, + { + "cell_type": "markdown", + "id": "489d3952-e5a8-4c11-9341-b06fef290d4d", + "metadata": {}, + "source": [ + "Ensure the server is running (wait for a few seconds before running it):" + ] + }, + { + "cell_type": "code", + "execution_count": 8, + "id": "f8406efe-2939-4511-b107-daff097f3d54", + "metadata": { + "tags": [] + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES\n", + "fabfc30490a1 mcr.microsoft.com/azure-sql-edge \"/opt/mssql/bin/perm…\" 5 seconds ago Up 4 seconds 1401/tcp, 0.0.0.0:1433->1433/tcp sql\n" + ] + } + ], + "source": [ + "%%bash\n", + "docker ps" + ] + }, + { + "cell_type": "markdown", + "id": "41ed6732-6bda-4417-ae4e-8bc964196c8f", + "metadata": {}, + "source": [ + "## Installing `pyodbc`\n", + "\n", + "\n", + "`pyodbc` will allow us to connect to SQL Server. If you're on macOS or Linux, you need to install unixODBC. Note that when installing the ODBC driver on macOS using `brew`, unixODBC is also installed.\n", + "\n", + "\n", + "Install `pyodbc` with:\n", + "\n", + "```sh\n", + "pip install pyodbc\n", + "```\n", + "\n", + "```{note}\n", + "If you're on a Mac with Apple Silicon (e.g., M1 processor), you might encounter issues, if so, try thi:\n", + "\n", + "~~~sh\n", + "pip install pyodbc==4.0.34\n", + "~~~\n", + "```\n", + "\n", + "Verify a successful installation with:" + ] + }, + { + "cell_type": "code", + "execution_count": 9, + "id": "27a84060-f8f1-406c-b206-898c4975809f", + "metadata": { + "tags": [] + }, + "outputs": [], + "source": [ + "import pyodbc" + ] + }, + { + "cell_type": "markdown", + "id": "3c2fe1cf-cedc-48ea-a420-36c5c0c24980", + "metadata": {}, + "source": [ + "Verify that `pyodbc` is able to findn the SQL Server driver:" + ] + }, + { + "cell_type": "code", + "execution_count": 10, + "id": "c7d03c98-9cc3-4c56-a349-0e4f146115d9", + "metadata": { + "tags": [] + }, + "outputs": [ + { + "data": { + "text/plain": [ + "['ODBC Driver 18 for SQL Server']" + ] + }, + "execution_count": 10, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "pyodbc.drivers()" + ] + }, + { + "cell_type": "markdown", + "id": "45386e02-c70a-44a0-9635-55e1bcdbdfc7", + "metadata": {}, + "source": [ + "```{tip}\n", + "If the driver doesn't appear, uninstalling `pyodbc` and re-installing it again might fix the problem.\n", + "\n", + "If you're on a Mac with Apple Silicon, ensure you installed `pyodbc` with `pip`, since `conda` might lead to issues.\n", + "```" + ] + }, + { + "cell_type": "markdown", + "id": "1ad9f206-5dee-41ec-97a3-8b2622b6b433", + "metadata": {}, + "source": [ + "## Starting the connection\n", + "\n", + "To start the connection, execute the following, change the values to match your SQL Server's configurationo:" + ] + }, + { + "cell_type": "code", + "execution_count": 11, + "id": "b2f987d7-c60b-480c-b31c-cd2f6562c9b8", + "metadata": { + "tags": [] + }, + "outputs": [], + "source": [ + "from sqlalchemy import create_engine\n", + "from sqlalchemy.engine import URL\n", + "\n", + "connection_url = URL.create(\n", + " \"mssql+pyodbc\",\n", + " username=\"sa\",\n", + " password=\"MyPassword!\",\n", + " host=\"localhost\",\n", + " port=1433,\n", + " database=\"master\",\n", + " query={\n", + " \"driver\": \"ODBC Driver 18 for SQL Server\",\n", + " \"Encrypt\": \"yes\",\n", + " \"TrustServerCertificate\": \"yes\",\n", + " },\n", + ")\n", + "engine = create_engine(connection_url)" + ] + }, + { + "cell_type": "markdown", + "id": "8f45a6f0-a9e3-4282-843f-44169b45e4c2", + "metadata": { + "user_expressions": [] + }, + "source": [ + "```{note}\n", + "If using `pytds`, the `autocommit` feature is disabled since it's not compatible with JupySQL.\n", + "```" + ] + }, + { + "cell_type": "markdown", + "id": "354906eb-5b76-44dc-9568-c7cda37ccfbc", + "metadata": {}, + "source": [ + "Install, load the Jupyter extension and start the connection:" + ] + }, + { + "cell_type": "code", + "execution_count": 9, + "id": "d87fd635-8914-4e4e-9461-405f5ec7d581", + "metadata": { + "tags": [] + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Note: you may need to restart the kernel to use updated packages.\n" + ] + } + ], + "source": [ + "%pip install jupysql --quiet" + ] + }, + { + "cell_type": "code", + "execution_count": 12, + "id": "f62bf48d-3e7b-4d5f-99b5-4d48fce98dfc", + "metadata": { + "tags": [] + }, + "outputs": [ + { + "data": { + "text/html": [ + "Found pyproject.toml from '/Users/eduardo/dev/jupysql'" + ], + "text/plain": [ + "Found pyproject.toml from '/Users/eduardo/dev/jupysql'" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "%load_ext sql\n", + "%sql engine" + ] + }, + { + "cell_type": "markdown", + "id": "c92746b7-85d1-421b-9e70-fd7c8e4930d6", + "metadata": {}, + "source": [ + "```{note}\n", + "\n", + "If you see the following error:\n", + "\n", + "~~~\n", + "InterfaceError: (pyodbc.InterfaceError) ('IM002', '[IM002] [unixODBC][Driver Manager]Data source name not found and no default driver specified (0) (SQLDriverConnect)')\n", + "(Background on this error at: https://sqlalche.me/e/14/rvf5)\n", + "~~~\n", + "\n", + "It might be that you're missing the SQL Server ODBC driver or that `pyodbc` cannot find it.\n", + "\n", + "```\n", + "\n", + "\n", + "## Load sample data\n", + "\n", + "Let's upload some sample data:" + ] + }, + { + "cell_type": "code", + "execution_count": 13, + "id": "aea381e9-9c61-4c15-bd09-aecb87a52e74", + "metadata": { + "tags": [] + }, + "outputs": [ + { + "data": { + "text/plain": [ + "(1369769, 19)" + ] + }, + "execution_count": 13, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "import pandas as pd\n", + "\n", + "df = pd.read_parquet(\n", + " \"https://d37ci6vzurychx.cloudfront.net/trip-data/yellow_tripdata_2021-01.parquet\"\n", + ")\n", + "df.shape" + ] + }, + { + "cell_type": "code", + "execution_count": 14, + "id": "f1b4ce63-5a6c-4887-91d8-7457f08b53ed", + "metadata": { + "tags": [] + }, + "outputs": [ + { + "data": { + "text/plain": [ + "56" + ] + }, + "execution_count": 14, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "df.sample(100_000).to_sql(\n", + " name=\"taxi\", con=engine, chunksize=100_000, if_exists=\"replace\"\n", + ")" + ] + }, + { + "cell_type": "markdown", + "id": "2e6183c7", + "metadata": {}, + "source": [ + "## Query" + ] + }, + { + "cell_type": "markdown", + "id": "8ee94760-27bb-40db-9bc7-e7d5a9ec585b", + "metadata": {}, + "source": [ + "Query the new table:" + ] + }, + { + "cell_type": "code", + "execution_count": 15, + "id": "b4dfc187-015f-4394-b3d9-f9fc0a79c4e5", + "metadata": { + "tags": [] + }, + "outputs": [ + { + "data": { + "text/html": [ + "Running query in 'mssql+pyodbc://sa:***@localhost:1433/master?Encrypt=yes&TrustServerCertificate=yes&driver=ODBC+Driver+18+for+SQL+Server'" + ], + "text/plain": [ + "Running query in 'mssql+pyodbc://sa:***@localhost:1433/master?Encrypt=yes&TrustServerCertificate=yes&driver=ODBC+Driver+18+for+SQL+Server'" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "text/html": [ + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
100000
\n", + "ResultSet : to convert to pandas, call .DataFrame() or to polars, call .PolarsDataFrame()
" + ], + "text/plain": [ + "+--------+\n", + "| |\n", + "+--------+\n", + "| 100000 |\n", + "+--------+" + ] + }, + "execution_count": 15, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "%%sql\n", + "select COUNT(*) FROM taxi" + ] + }, + { + "cell_type": "markdown", + "id": "e134d0ed-5501-4425-bd97-302b07062d57", + "metadata": {}, + "source": [ + "List the tables in the database:" + ] + }, + { + "cell_type": "code", + "execution_count": 16, + "id": "cad65f83-48d5-415e-aaeb-8fa7b4e2256c", + "metadata": { + "tags": [] + }, + "outputs": [ + { + "data": { + "text/html": [ + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
Name
MSreplication_options
spt_fallback_db
spt_fallback_dev
spt_fallback_usg
spt_monitor
taxi
" + ], + "text/plain": [ + "+-----------------------+\n", + "| Name |\n", + "+-----------------------+\n", + "| MSreplication_options |\n", + "| spt_fallback_db |\n", + "| spt_fallback_dev |\n", + "| spt_fallback_usg |\n", + "| spt_monitor |\n", + "| taxi |\n", + "+-----------------------+" + ] + }, + "execution_count": 16, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "%sqlcmd tables" + ] + }, + { + "cell_type": "markdown", + "id": "7af2e627-9089-4381-9543-d923832a2dab", + "metadata": {}, + "source": [ + "List columns in the taxi table:" + ] + }, + { + "cell_type": "code", + "execution_count": 17, + "id": "aa32ce39-8314-4836-919b-0cc9259c44d5", + "metadata": { + "tags": [] + }, + "outputs": [ + { + "data": { + "text/html": [ + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
nametypenullabledefaultautoincrementcomment
indexBIGINTTrueNoneFalseNone
VendorIDBIGINTTrueNoneFalseNone
tpep_pickup_datetimeDATETIMETrueNoneFalseNone
tpep_dropoff_datetimeDATETIMETrueNoneFalseNone
passenger_countFLOATTrueNoneFalseNone
trip_distanceFLOATTrueNoneFalseNone
RatecodeIDFLOATTrueNoneFalseNone
store_and_fwd_flagVARCHAR COLLATE "SQL_Latin1_General_CP1_CI_AS"TrueNoneFalseNone
PULocationIDBIGINTTrueNoneFalseNone
DOLocationIDBIGINTTrueNoneFalseNone
payment_typeBIGINTTrueNoneFalseNone
fare_amountFLOATTrueNoneFalseNone
extraFLOATTrueNoneFalseNone
mta_taxFLOATTrueNoneFalseNone
tip_amountFLOATTrueNoneFalseNone
tolls_amountFLOATTrueNoneFalseNone
improvement_surchargeFLOATTrueNoneFalseNone
total_amountFLOATTrueNoneFalseNone
congestion_surchargeFLOATTrueNoneFalseNone
airport_feeFLOATTrueNoneFalseNone
" + ], + "text/plain": [ + "+-----------------------+------------------------------------------------+----------+---------+---------------+---------+\n", + "| name | type | nullable | default | autoincrement | comment |\n", + "+-----------------------+------------------------------------------------+----------+---------+---------------+---------+\n", + "| index | BIGINT | True | None | False | None |\n", + "| VendorID | BIGINT | True | None | False | None |\n", + "| tpep_pickup_datetime | DATETIME | True | None | False | None |\n", + "| tpep_dropoff_datetime | DATETIME | True | None | False | None |\n", + "| passenger_count | FLOAT | True | None | False | None |\n", + "| trip_distance | FLOAT | True | None | False | None |\n", + "| RatecodeID | FLOAT | True | None | False | None |\n", + "| store_and_fwd_flag | VARCHAR COLLATE \"SQL_Latin1_General_CP1_CI_AS\" | True | None | False | None |\n", + "| PULocationID | BIGINT | True | None | False | None |\n", + "| DOLocationID | BIGINT | True | None | False | None |\n", + "| payment_type | BIGINT | True | None | False | None |\n", + "| fare_amount | FLOAT | True | None | False | None |\n", + "| extra | FLOAT | True | None | False | None |\n", + "| mta_tax | FLOAT | True | None | False | None |\n", + "| tip_amount | FLOAT | True | None | False | None |\n", + "| tolls_amount | FLOAT | True | None | False | None |\n", + "| improvement_surcharge | FLOAT | True | None | False | None |\n", + "| total_amount | FLOAT | True | None | False | None |\n", + "| congestion_surcharge | FLOAT | True | None | False | None |\n", + "| airport_fee | FLOAT | True | None | False | None |\n", + "+-----------------------+------------------------------------------------+----------+---------+---------------+---------+" + ] + }, + "execution_count": 17, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "%sqlcmd columns --table taxi" + ] + }, + { + "cell_type": "markdown", + "id": "7a3316e7-8ba6-46ac-a46b-6fb3a0d4776c", + "metadata": {}, + "source": [ + "## Parametrize queries" + ] + }, + { + "cell_type": "code", + "execution_count": 18, + "id": "91e78151-7ae2-498d-8a19-3de0ee4781c7", + "metadata": { + "tags": [] + }, + "outputs": [], + "source": [ + "threshold = 10" + ] + }, + { + "cell_type": "code", + "execution_count": 19, + "id": "7947bc67-9170-4808-835d-b0bf82229022", + "metadata": { + "tags": [] + }, + "outputs": [ + { + "data": { + "text/html": [ + "Running query in 'mssql+pyodbc://sa:***@localhost:1433/master?Encrypt=yes&TrustServerCertificate=yes&driver=ODBC+Driver+18+for+SQL+Server'" + ], + "text/plain": [ + "Running query in 'mssql+pyodbc://sa:***@localhost:1433/master?Encrypt=yes&TrustServerCertificate=yes&driver=ODBC+Driver+18+for+SQL+Server'" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "text/html": [ + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
94705
\n", + "ResultSet : to convert to pandas, call .DataFrame() or to polars, call .PolarsDataFrame()
" + ], + "text/plain": [ + "+-------+\n", + "| |\n", + "+-------+\n", + "| 94705 |\n", + "+-------+" + ] + }, + "execution_count": 19, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "%%sql\n", + "SELECT COUNT(*) FROM taxi\n", + "WHERE trip_distance < {{threshold}}" + ] + }, + { + "cell_type": "code", + "execution_count": 20, + "id": "73acb44a-cb1a-44ab-bfd6-42309fc1defd", + "metadata": { + "tags": [] + }, + "outputs": [], + "source": [ + "threshold = 0.5" + ] + }, + { + "cell_type": "code", + "execution_count": 21, + "id": "d88db9b4-1efe-4b7b-b8be-f94949b3ce69", + "metadata": { + "tags": [] + }, + "outputs": [ + { + "data": { + "text/html": [ + "Running query in 'mssql+pyodbc://sa:***@localhost:1433/master?Encrypt=yes&TrustServerCertificate=yes&driver=ODBC+Driver+18+for+SQL+Server'" + ], + "text/plain": [ + "Running query in 'mssql+pyodbc://sa:***@localhost:1433/master?Encrypt=yes&TrustServerCertificate=yes&driver=ODBC+Driver+18+for+SQL+Server'" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "text/html": [ + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
5326
\n", + "ResultSet : to convert to pandas, call .DataFrame() or to polars, call .PolarsDataFrame()
" + ], + "text/plain": [ + "+------+\n", + "| |\n", + "+------+\n", + "| 5326 |\n", + "+------+" + ] + }, + "execution_count": 21, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "%%sql\n", + "SELECT COUNT(*) FROM taxi\n", + "WHERE trip_distance < {{threshold}}" + ] + }, + { + "cell_type": "markdown", + "id": "bfb89b06-c5c1-4ad3-bc4f-2f12071c559c", + "metadata": {}, + "source": [ + "## CTEs\n", + "\n", + "You can break down queries into multiple cells, JupySQL will build a CTE for you:" + ] + }, + { + "cell_type": "code", + "execution_count": 22, + "id": "2519b021-90bb-42f4-b637-7dc4e214eaad", + "metadata": { + "tags": [] + }, + "outputs": [ + { + "data": { + "text/html": [ + "Running query in 'mssql+pyodbc://sa:***@localhost:1433/master?Encrypt=yes&TrustServerCertificate=yes&driver=ODBC+Driver+18+for+SQL+Server'" + ], + "text/plain": [ + "Running query in 'mssql+pyodbc://sa:***@localhost:1433/master?Encrypt=yes&TrustServerCertificate=yes&driver=ODBC+Driver+18+for+SQL+Server'" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "text/html": [ + "Skipping execution..." + ], + "text/plain": [ + "Skipping execution..." + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "%%sql --save many_passengers --no-execute\n", + "SELECT *\n", + "FROM taxi\n", + "WHERE passenger_count > 3\n", + "-- remove top 1% outliers for better visualization\n", + "AND trip_distance < 18.93" + ] + }, + { + "cell_type": "code", + "execution_count": 23, + "id": "856de699-a460-43de-8ea2-d50ea4459340", + "metadata": { + "tags": [] + }, + "outputs": [ + { + "data": { + "text/html": [ + "Running query in 'mssql+pyodbc://sa:***@localhost:1433/master?Encrypt=yes&TrustServerCertificate=yes&driver=ODBC+Driver+18+for+SQL+Server'" + ], + "text/plain": [ + "Running query in 'mssql+pyodbc://sa:***@localhost:1433/master?Encrypt=yes&TrustServerCertificate=yes&driver=ODBC+Driver+18+for+SQL+Server'" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "text/html": [ + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
_1_2
0.02.537772020725389818.83
\n", + "ResultSet : to convert to pandas, call .DataFrame() or to polars, call .PolarsDataFrame()
" + ], + "text/plain": [ + "+-----+--------------------+-------+\n", + "| | _1 | _2 |\n", + "+-----+--------------------+-------+\n", + "| 0.0 | 2.5377720207253898 | 18.83 |\n", + "+-----+--------------------+-------+" + ] + }, + "execution_count": 23, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "%%sql --save trip_stats --with many_passengers\n", + "SELECT MIN(trip_distance), AVG(trip_distance), MAX(trip_distance)\n", + "FROM many_passengers" + ] + }, + { + "cell_type": "markdown", + "id": "6c315112-77c5-4a4c-ab19-55f80f43c88d", + "metadata": {}, + "source": [ + "This is what JupySQL executes:" + ] + }, + { + "cell_type": "code", + "execution_count": 24, + "id": "83aa9bcb-dd70-47a0-ae33-566b108dea1a", + "metadata": { + "tags": [] + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "WITH many_passengers AS (\n", + "SELECT *\n", + "FROM taxi\n", + "WHERE passenger_count > 3\n", + "-- remove top 1% outliers for better visualization\n", + "AND trip_distance < 18.93)\n", + "SELECT MIN(trip_distance), AVG(trip_distance), MAX(trip_distance)\n", + "FROM many_passengers\n" + ] + } + ], + "source": [ + "query = %sqlcmd snippets trip_stats\n", + "print(query)" + ] + }, + { + "cell_type": "markdown", + "id": "2e6388a7-e524-4c94-8b4d-6bac99622790", + "metadata": {}, + "source": [ + "## Plotting\n", + "\n", + "### Boxplot" + ] + }, + { + "cell_type": "code", + "execution_count": 31, + "id": "8785696d-5d86-4d22-b8a4-ac2a5d089e4c", + "metadata": { + "tags": [] + }, + "outputs": [ + { + "data": { + "text/html": [ + "Plotting using saved snippet : many_passengers" + ], + "text/plain": [ + "Plotting using saved snippet : many_passengers" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "text/plain": [ + "" + ] + }, + "execution_count": 31, + "metadata": {}, + "output_type": "execute_result" + }, + { + "data": { + "image/png": "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", + "text/plain": [ + "
" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "%sqlplot boxplot --table many_passengers --column trip_distance" + ] + }, + { + "cell_type": "markdown", + "id": "4829b0be-cdb3-4185-b495-84cfa58c0d86", + "metadata": {}, + "source": [ + "### Bar" + ] + }, + { + "cell_type": "code", + "execution_count": 34, + "id": "03306421-1ae0-4b89-a524-f6d4b0a79d8a", + "metadata": { + "tags": [] + }, + "outputs": [ + { + "data": { + "text/html": [ + "Removing NULLs, if there exists any from vendorid" + ], + "text/plain": [ + "Removing NULLs, if there exists any from vendorid" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "text/plain": [ + "" + ] + }, + "execution_count": 34, + "metadata": {}, + "output_type": "execute_result" + }, + { + "data": { + "image/png": "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rznOnDmjoUOHyuFwqFGjRho5cqTOnz/vVfPxxx/rnnvuUWhoqKKjozV37tyrelm9erXatWun0NBQderUSe+88871OWkAAFDn+DQ0nT17Vr1791ZwcLA2bNigf/zjH3rllVd02223mTVz587Va6+9piVLlmjXrl2qX7++EhMTdfHiRbNm6NChOnjwoDZt2qT169dr+/btGjVqlDnu8XjUr18/tWrVSrm5uXrppZc0c+ZMLV261KzZsWOHHn30UY0cOVIffvihBgwYoAEDBujAgQM35mIAAAC/ZjMMw/DVwadOnaoPPvhA77//frXjhmEoKipKzzzzjJ599llJUklJiSIiIpSenq4hQ4bo0KFDio2N1Z49e9S9e3dJUlZWlh544AH985//VFRUlBYvXqxf//rXcrvdstvt5rHXrFmj/Px8SdLgwYNVWlqq9evXm8fv1auXunbtqiVLlnznuXg8HjmdTpWUlMjhcHyv64LaETM109ct+MyxF5N83QIA1AnX8vvbp3ea1q5dq+7du+vnP/+5wsPDddddd+mNN94wx48ePSq3262EhARzn9PpVHx8vHJyciRJOTk5atSokRmYJCkhIUEBAQHatWuXWXPvvfeagUmSEhMTVVBQoLNnz5o1Vx6nqqbqOF9XVlYmj8fjtQEAgJuXT0PTZ599psWLF+uOO+7Qxo0bNWbMGD399NNavny5JMntdkuSIiIivN4XERFhjrndboWHh3uNBwUFqXHjxl411c1x5TG+qaZq/OvmzJkjp9NpbtHR0dd8/gAAoO7waWiqrKxUt27d9MILL+iuu+7SqFGj9NRTT1n6OMzXpk2bppKSEnM7fvy4r1sCAADXkU9DU/PmzRUbG+u1r3379iosLJQkRUZGSpKKioq8aoqKisyxyMhInTp1ymv88uXLOnPmjFdNdXNceYxvqqka/7qQkBA5HA6vDQAA3Lx8Gpp69+6tgoICr32ffPKJWrVqJUlq3bq1IiMjlZ2dbY57PB7t2rVLLpdLkuRyuVRcXKzc3FyzZvPmzaqsrFR8fLxZs337dl26dMms2bRpk9q2bWt+U8/lcnkdp6qm6jgAAODW5tPQNHHiRO3cuVMvvPCCjhw5ooyMDC1dulQpKSmSJJvNpgkTJuj555/X2rVrtX//fj3++OOKiorSgAEDJP3rztT999+vp556Srt379YHH3yg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", + "text/plain": [ + "
" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "%sqlplot bar --table taxi --column vendorid" + ] + }, + { + "cell_type": "markdown", + "id": "dd9005c9-8f88-4448-bc79-0916d012c42f", + "metadata": {}, + "source": [ + "### Pie" + ] + }, + { + "cell_type": "code", + "execution_count": 35, + "id": "8064ebec-e294-4019-a9c7-e1af48a1eb81", + "metadata": { + "tags": [] + }, + "outputs": [ + { + "data": { + "text/html": [ + "Removing NULLs, if there exists any from vendorid" + ], + "text/plain": [ + "Removing NULLs, if there exists any from vendorid" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "text/plain": [ + "" + ] + }, + "execution_count": 35, + "metadata": {}, + "output_type": "execute_result" + }, + { + "data": { + "image/png": "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5Dlk+IcEpUSnIDMpE5mJwW/dWnXDJamXwO3Q79VlZD0cBTIEf8CPY1XHtKEoqCpAUW0RimqKUFxbrJs300XZo5DqTkWXhC7aAHRN6orMxEzeX4gMgaNAplBWX4bi2mIU1RShqLYIp2pOobi2GB6vB7WNtahurEZtYy1qGmtQ76uHN+CF1+/93k3/bIoNLpsLTrsTLpsLLnvwm9PmhMvuQlJUElLdqUhxp6C1uzVS3ana/051pyLBlSD0d4AoNDgKZGlef3AcbIoNLrsLDhtvHEzWxlEgIiINb4pCREQajgIREWk4CkREpOEoEBGRhqNAREQajgIREWk4CkREpOEoEBGRhqNAREQajgIREWk4CkREpOEoEBGRhqNAREQajgIREWk4CkREpOEoEBGRhqNAREQajgIREWk4CkREpOEoEBGRhqNAREQajgIREWk4CkREpOEoEBGRhqNAREQajgIREWk4CkREpOEoEBGRhqNAREQajgIREWk4CkREpOEoEBGRhqNAREQajgIREWk4CkREpOEoEBGRhqNAREQajgIREWk4CkREpOEoEBGRhqNAREQajgIREWk4CkREpOEoEBGRhqNARESa/w/5aR/aWTcIZQAAAABJRU5ErkJggg==", + "text/plain": [ + "
" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "%sqlplot pie --table taxi --column vendorid" + ] + }, + { + "cell_type": "markdown", + "id": "c057eda2-2f9c-4048-a071-ae8592e03cf5", + "metadata": {}, + "source": [ + "## Clean up" + ] + }, + { + "cell_type": "code", + "execution_count": 40, + "id": "755ca85e-5015-49bb-b52d-7fd14bb85d0e", + "metadata": { + "tags": [] + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES\n", + "fabfc30490a1 mcr.microsoft.com/azure-sql-edge \"/opt/mssql/bin/perm…\" 7 minutes ago Up 7 minutes 1401/tcp, 0.0.0.0:1433->1433/tcp sql\n" + ] + } + ], + "source": [ + "%%bash\n", + "docker container ls" + ] + }, + { + "cell_type": "code", + "execution_count": 41, + "id": "66c440de-78ac-4fa2-a6e0-692588ca6be0", + "metadata": { + "tags": [] + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "sql\n" + ] + } + ], + "source": [ + "%%bash\n", + "docker container stop sql" + ] + }, + { + "cell_type": "code", + "execution_count": 42, + "id": "6eeb8cb5-18ce-48e8-8609-53db1ad78026", + "metadata": { + "tags": [] + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "sql\n" + ] + } + ], + "source": [ + "%%bash\n", + "docker container rm sql" + ] + }, + { + "cell_type": "code", + "execution_count": 43, + "id": "6fb190ad-cb88-4fed-a650-0e568bed3330", + "metadata": { + "tags": [] + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES\n" + ] + } + ], + "source": [ + "%%bash\n", + "docker container ls" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3 (ipykernel)", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.10.12" + }, + "myst": { + "html_meta": { + "description lang=en": "Query a Microsoft SQL Server from Jupyter via JupySQL", + "keywords": "jupyter, sql, jupysql, mssql, sql server", + "property=og:locale": "en_US" + } + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/doc/integrations/mysql.ipynb b/doc/integrations/mysql.ipynb new file mode 100644 index 000000000..e6e9f694d --- /dev/null +++ b/doc/integrations/mysql.ipynb @@ -0,0 +1,1077 @@ +{ + "cells": [ + { + "attachments": {}, + "cell_type": "markdown", + "id": "fd3eb704", + "metadata": {}, + "source": [ + "# MySQL\n", + "\n", + "In this tutorial, we'll see how to query MySQL from Jupyter. Optionally, you can spin up a testing server.\n", + "\n", + "```{tip}\n", + "If you encounter issues, feel free to join our [community](https://ploomber.io/community) and we'll be happy to help!\n", + "```" + ] + }, + { + "attachments": {}, + "cell_type": "markdown", + "id": "4727e0b9", + "metadata": {}, + "source": [ + "## Pre-requisites\n", + "\n", + "To run this tutorial, you need to install the `mysqlclient` package.\n", + "\n", + "```{note}\n", + "We highly recommend you that you install it using `conda`, since it'll also install `mysql-connector-c`; if you want to use `pip`, then you need to install `mysql-connector-c` and then `mysqlclient`.\n", + "```" + ] + }, + { + "cell_type": "code", + "execution_count": 1, + "id": "ae033470", + "metadata": { + "tags": [] + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Collecting package metadata (current_repodata.json): ...working... done\n", + "Solving environment: ...working... done\n", + "\n", + "# All requested packages already installed.\n", + "\n", + "\n", + "Note: you may need to restart the kernel to use updated packages.\n" + ] + } + ], + "source": [ + "%conda install mysqlclient -c conda-forge --quiet" + ] + }, + { + "attachments": {}, + "cell_type": "markdown", + "id": "dbf4706e", + "metadata": {}, + "source": [ + "## Start MySQL instance\n", + "\n", + "If you don't have a MySQL Server running or you want to spin up one for testing, you can do it with the official [Docker image](https://hub.docker.com/_/mysql).\n", + "\n", + "To start the server:" + ] + }, + { + "cell_type": "code", + "execution_count": 1, + "id": "f9c88366", + "metadata": { + "tags": [] + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "b9f7f973698a0063198a7e6358445e942de4905d18b99145a7dfc8bb947bfa97\n" + ] + } + ], + "source": [ + "%%bash\n", + "docker run --name mysql -e MYSQL_DATABASE=db \\\n", + " -e MYSQL_USER=user \\\n", + " -e MYSQL_PASSWORD=password \\\n", + " -e MYSQL_ROOT_PASSWORD=password \\\n", + " -p 3306:3306 -d mysql" + ] + }, + { + "attachments": {}, + "cell_type": "markdown", + "id": "eaae2079", + "metadata": {}, + "source": [ + "Ensure that the container is running:" + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "id": "ec326f31-6cac-4f97-a5f6-5538e694082b", + "metadata": { + "tags": [] + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES\n", + "b9f7f973698a mysql \"docker-entrypoint.s…\" 1 second ago Up Less than a second 0.0.0.0:3306->3306/tcp, 33060/tcp mysql\n" + ] + } + ], + "source": [ + "%%bash\n", + "docker ps" + ] + }, + { + "attachments": {}, + "cell_type": "markdown", + "id": "6d0eb5b6-f8bd-47d5-84c5-4369adc47b59", + "metadata": {}, + "source": [ + "We need to make a small configuration change, so do the following:\n", + " \n", + "Open a new terminal and execute: `docker exec -it mysql bash`\n", + "\n", + "Then: `mysql --user=root --password`\n", + "\n", + "When prompted for a password, type: `password`\n", + " \n", + "Once the MySQL console appears, execute:\n", + " \n", + "```sql\n", + "ALTER USER user\n", + "IDENTIFIED WITH mysql_native_password\n", + "BY 'password';\n", + "```\n", + "\n", + "Exit the MySQL console with: `exit`\n", + "Exit the container with: `exit`\n", + "\n", + "The session should look like this:\n", + "\n", + "```sh\n", + "docker exec -it mysql bash\n", + "\n", + "bash-4.4# mysql --user=root --password\n", + "Enter password:\n", + "\n", + "Welcome to the MySQL monitor. Commands end with ; or \\g.\n", + "Your MySQL connection id is 9\n", + "Server version: 8.0.31 MySQL Community Server - GPL\n", + "\n", + "Copyright (c) 2000, 2022, Oracle and/or its affiliates.\n", + "\n", + "Oracle is a registered trademark of Oracle Corporation and/or its\n", + "affiliates. Other names may be trademarks of their respective\n", + "owners.\n", + "\n", + "Type 'help;' or '\\h' for help. Type '\\c' to clear the current input statement.\n", + "\n", + "mysql> ALTER USER user\n", + " -> IDENTIFIED WITH mysql_native_password\n", + " -> BY 'password';\n", + "Query OK, 0 rows affected (0.01 sec)\n", + "\n", + "mysql> exit\n", + "Bye\n", + "bash-4.4# exit\n", + "exit\n", + "```\n" + ] + }, + { + "attachments": {}, + "cell_type": "markdown", + "id": "9d74d2df", + "metadata": {}, + "source": [ + "## Load sample data\n", + "\n", + "Now, let's fetch some sample data. We'll be using the [NYC taxi dataset](https://www.nyc.gov/site/tlc/about/tlc-trip-record-data.page):" + ] + }, + { + "cell_type": "code", + "execution_count": 7, + "id": "9f7b62d8-e0cf-4476-9f92-a45bbd526960", + "metadata": { + "tags": [] + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Note: you may need to restart the kernel to use updated packages.\n" + ] + } + ], + "source": [ + "%pip install pandas pyarrow --quiet" + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "id": "16b1bfed", + "metadata": { + "tags": [] + }, + "outputs": [ + { + "data": { + "text/plain": [ + "(1369769, 19)" + ] + }, + "execution_count": 3, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "import pandas as pd\n", + "\n", + "df = pd.read_parquet(\n", + " \"https://d37ci6vzurychx.cloudfront.net/trip-data/yellow_tripdata_2021-01.parquet\"\n", + ")\n", + "df.shape" + ] + }, + { + "attachments": {}, + "cell_type": "markdown", + "id": "f9ba5421", + "metadata": {}, + "source": [ + "As you can see, this chunk of data contains ~1.4M rows, loading the data will take about a minute:" + ] + }, + { + "cell_type": "code", + "execution_count": 4, + "id": "a3402cdf", + "metadata": { + "tags": [] + }, + "outputs": [], + "source": [ + "from sqlalchemy import create_engine\n", + "\n", + "engine = create_engine(\"mysql+mysqldb://user:password@127.0.0.1:3306/db\")\n", + "df.to_sql(name=\"taxi\", con=engine, chunksize=100_000)\n", + "engine.dispose()" + ] + }, + { + "attachments": {}, + "cell_type": "markdown", + "id": "c7f25de0", + "metadata": { + "user_expressions": [] + }, + "source": [ + "## Query\n", + "\n", + "```{note}\n", + "`mysql` and `mysql+pymysql` connections (and perhaps others) don't read your client character set information from `.my.cnf.` You need to specify it in the connection string:\n", + "\n", + "~~~\n", + "mysql+pymysql://scott:tiger@localhost/foo?charset=utf8\n", + "~~~\n", + "```\n", + "\n", + "\n", + "Now, let's install JupySQL, authenticate and start querying the data!" + ] + }, + { + "cell_type": "code", + "execution_count": 5, + "id": "3df653d7", + "metadata": { + "tags": [] + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Note: you may need to restart the kernel to use updated packages.\n" + ] + } + ], + "source": [ + "%pip install jupysql --quiet\n", + "%load_ext sql\n", + "%sql mysql+mysqldb://user:password@127.0.0.1:3306/db" + ] + }, + { + "attachments": {}, + "cell_type": "markdown", + "id": "4e7beda3", + "metadata": {}, + "source": [ + "```{important}\n", + "If the cell above fails, you might have some missing packages. Message us on [Slack](https://ploomber.io/community) and we'll help you!\n", + "```" + ] + }, + { + "cell_type": "markdown", + "id": "5c68ab3b-3bc5-456d-9c61-d06f44461ce8", + "metadata": {}, + "source": [ + "List the tables in the database:" + ] + }, + { + "cell_type": "code", + "execution_count": 11, + "id": "63511cdd-cb37-447c-a1e2-4272d12e341e", + "metadata": { + "tags": [] + }, + "outputs": [ + { + "data": { + "text/html": [ + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
Name
taxi
" + ], + "text/plain": [ + "+------+\n", + "| Name |\n", + "+------+\n", + "| taxi |\n", + "+------+" + ] + }, + "execution_count": 11, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "%sqlcmd tables" + ] + }, + { + "attachments": {}, + "cell_type": "markdown", + "id": "12215e5a-cf5b-44a3-a1e0-b187065b9f04", + "metadata": {}, + "source": [ + "List columns in the taxi table:" + ] + }, + { + "cell_type": "code", + "execution_count": 12, + "id": "510c1670-2fef-4bdf-b29e-7cca68ea7c09", + "metadata": { + "tags": [] + }, + "outputs": [ + { + "data": { + "text/html": [ + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
nametypedefaultcommentnullableautoincrement
indexBIGINTNoneNoneTrueFalse
VendorIDBIGINTNoneNoneTrueFalse
tpep_pickup_datetimeDATETIMENoneNoneTrue
tpep_dropoff_datetimeDATETIMENoneNoneTrue
passenger_countDOUBLENoneNoneTrue
trip_distanceDOUBLENoneNoneTrue
RatecodeIDDOUBLENoneNoneTrue
store_and_fwd_flagTEXTNoneNoneTrue
PULocationIDBIGINTNoneNoneTrueFalse
DOLocationIDBIGINTNoneNoneTrueFalse
payment_typeBIGINTNoneNoneTrueFalse
fare_amountDOUBLENoneNoneTrue
extraDOUBLENoneNoneTrue
mta_taxDOUBLENoneNoneTrue
tip_amountDOUBLENoneNoneTrue
tolls_amountDOUBLENoneNoneTrue
improvement_surchargeDOUBLENoneNoneTrue
total_amountDOUBLENoneNoneTrue
congestion_surchargeDOUBLENoneNoneTrue
airport_feeDOUBLENoneNoneTrue
" + ], + "text/plain": [ + "+-----------------------+----------+---------+---------+----------+---------------+\n", + "| name | type | default | comment | nullable | autoincrement |\n", + "+-----------------------+----------+---------+---------+----------+---------------+\n", + "| index | BIGINT | None | None | True | False |\n", + "| VendorID | BIGINT | None | None | True | False |\n", + "| tpep_pickup_datetime | DATETIME | None | None | True | |\n", + "| tpep_dropoff_datetime | DATETIME | None | None | True | |\n", + "| passenger_count | DOUBLE | None | None | True | |\n", + "| trip_distance | DOUBLE | None | None | True | |\n", + "| RatecodeID | DOUBLE | None | None | True | |\n", + "| store_and_fwd_flag | TEXT | None | None | True | |\n", + "| PULocationID | BIGINT | None | None | True | False |\n", + "| DOLocationID | BIGINT | None | None | True | False |\n", + "| payment_type | BIGINT | None | None | True | False |\n", + "| fare_amount | DOUBLE | None | None | True | |\n", + "| extra | DOUBLE | None | None | True | |\n", + "| mta_tax | DOUBLE | None | None | True | |\n", + "| tip_amount | DOUBLE | None | None | True | |\n", + "| tolls_amount | DOUBLE | None | None | True | |\n", + "| improvement_surcharge | DOUBLE | None | None | True | |\n", + "| total_amount | DOUBLE | None | None | True | |\n", + "| congestion_surcharge | DOUBLE | None | None | True | |\n", + "| airport_fee | DOUBLE | None | None | True | |\n", + "+-----------------------+----------+---------+---------+----------+---------------+" + ] + }, + "execution_count": 12, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "%sqlcmd columns --table taxi" + ] + }, + { + "cell_type": "markdown", + "id": "66a6ba7e-d1dd-42a3-957e-27de18fddf6f", + "metadata": {}, + "source": [ + "Query our data:" + ] + }, + { + "cell_type": "code", + "execution_count": 6, + "id": "84902d46", + "metadata": { + "tags": [] + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "* mysql+mysqldb://user:***@127.0.0.1:3306/db\n", + "1 rows affected.\n" + ] + }, + { + "data": { + "text/html": [ + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
COUNT(*)
1369769
" + ], + "text/plain": [ + "[(1369769,)]" + ] + }, + "execution_count": 6, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "%%sql\n", + "SELECT COUNT(*) FROM taxi" + ] + }, + { + "attachments": {}, + "cell_type": "markdown", + "id": "840037ae-bb0d-46f4-a982-492d268fae1f", + "metadata": {}, + "source": [ + "## Parametrize queries" + ] + }, + { + "cell_type": "code", + "execution_count": 7, + "id": "7ec71402", + "metadata": { + "tags": [] + }, + "outputs": [], + "source": [ + "threshold = 10" + ] + }, + { + "cell_type": "code", + "execution_count": 8, + "id": "78db29c3-0a15-4f66-a95e-01e3c7e22697", + "metadata": { + "tags": [] + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "* mysql+mysqldb://user:***@127.0.0.1:3306/db\n", + "1 rows affected.\n" + ] + }, + { + "data": { + "text/html": [ + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
COUNT(*)
1297415
" + ], + "text/plain": [ + "[(1297415,)]" + ] + }, + "execution_count": 8, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "%%sql\n", + "SELECT COUNT(*) FROM taxi\n", + "WHERE trip_distance < {{threshold}}" + ] + }, + { + "cell_type": "code", + "execution_count": 9, + "id": "8fd0e654-28d5-4bee-a3d3-7a59d513ef86", + "metadata": { + "tags": [] + }, + "outputs": [], + "source": [ + "threshold = 0.5" + ] + }, + { + "cell_type": "code", + "execution_count": 10, + "id": "ab4f4068-af8c-4f23-9cdf-3d0242c936f9", + "metadata": { + "tags": [] + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "* mysql+mysqldb://user:***@127.0.0.1:3306/db\n", + "1 rows affected.\n" + ] + }, + { + "data": { + "text/html": [ + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
COUNT(*)
73849
" + ], + "text/plain": [ + "[(73849,)]" + ] + }, + "execution_count": 10, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "%%sql\n", + "SELECT COUNT(*) FROM taxi\n", + "WHERE trip_distance < {{threshold}}" + ] + }, + { + "attachments": {}, + "cell_type": "markdown", + "id": "8b981aa2-e384-45f3-af24-c557e4ea0755", + "metadata": {}, + "source": [ + "## CTEs\n", + "\n", + "You can break down queries into multiple cells, JupySQL will build a CTE for you:" + ] + }, + { + "cell_type": "code", + "execution_count": 13, + "id": "46038013-e467-4e12-afb1-4005b192bbd8", + "metadata": { + "tags": [] + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "* mysql+mysqldb://user:***@127.0.0.1:3306/db\n", + "Skipping execution...\n" + ] + } + ], + "source": [ + "%%sql --save many_passengers --no-execute\n", + "SELECT *\n", + "FROM taxi\n", + "WHERE passenger_count > 3\n", + "-- remove top 1% outliers for better visualization\n", + "AND trip_distance < 18.93" + ] + }, + { + "cell_type": "code", + "execution_count": 14, + "id": "8f95765a-8b71-42c7-97d4-fd4b91695c79", + "metadata": { + "tags": [] + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "* mysql+mysqldb://user:***@127.0.0.1:3306/db\n", + "1 rows affected.\n" + ] + }, + { + "data": { + "text/html": [ + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
MIN(trip_distance)AVG(trip_distance)MAX(trip_distance)
0.02.501088981288983618.92
" + ], + "text/plain": [ + "[(0.0, 2.5010889812889836, 18.92)]" + ] + }, + "execution_count": 14, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "%%sql --save trip_stats --with many_passengers\n", + "SELECT MIN(trip_distance), AVG(trip_distance), MAX(trip_distance)\n", + "FROM many_passengers" + ] + }, + { + "attachments": {}, + "cell_type": "markdown", + "id": "11f078a7-635f-4889-9dac-f4d6c6311177", + "metadata": {}, + "source": [ + "This is what JupySQL executes:\n" + ] + }, + { + "cell_type": "code", + "execution_count": 15, + "id": "5a38ac36-1eb0-4b16-96a1-98ca584fc6c7", + "metadata": { + "tags": [] + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "WITH `many_passengers` AS (\n", + "SELECT *\n", + "FROM taxi\n", + "WHERE passenger_count > 3\n", + "-- remove top 1% outliers for better visualization\n", + "AND trip_distance < 18.93)\n", + "SELECT MIN(trip_distance), AVG(trip_distance), MAX(trip_distance)\n", + "FROM many_passengers\n" + ] + } + ], + "source": [ + "query = %sqlcmd snippets trip_stats\n", + "print(query)" + ] + }, + { + "attachments": {}, + "cell_type": "markdown", + "id": "001e178b-499e-4528-9cf7-59c619563605", + "metadata": {}, + "source": [ + "## Plotting" + ] + }, + { + "cell_type": "code", + "execution_count": 17, + "id": "7237d664-1b80-4a35-b52c-a130229be306", + "metadata": { + "tags": [] + }, + "outputs": [ + { + "data": { + "text/plain": [ + "" + ] + }, + "execution_count": 17, + "metadata": {}, + "output_type": "execute_result" + }, + { + "data": { + "image/png": "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fPiwbDabRo4cqZdffllff/21du3apYEDByogIMD8ZuKtt96qbt26aciQIdq8ebPWr1+v2NhY9e3bVwEBAZKkhx56SC4uLho8eLCSkpI0f/58vfnmmxo1apTZx1NPPaWlS5dq6tSp2rdvn+Li4rR161bFxsZe7x8JAACoQCqX5sa3bt2qO++803yeG36io6MVHx+v559/XmfPntXQoUOVlpam9u3ba+nSpXJzczOXmTdvnmJjY9WlSxc5OTmpd+/eeuutt8z5drtdy5cvV0xMjFq2bKmaNWtq3LhxDvfaateunT7++GP985//1P/7f/9P9evX16JFi9SoUaPr8FMAAAAVVZm5j1Z5x320AAAof27Y+2gBAACUdwQtAAAAixC0AAAALELQAgAAsAhBCwAAwCIELQAAAIsQtAAAACxC0AIAALAIQQsAAMAiBC0AAACLELQAAAAsQtACAACwCEELAADAIgQtAAAAixC0AAAALELQAgAAsAhBCwAAwCIELQAAAIsQtAAAACxC0AIAALAIQQsAAMAiBC0AAACLELQAAAAsQtACAACwCEELAADAIgQtAAAAixC0AAAALELQAgAAsAhBCwAAwCIELQAAAIsQtAAAACxC0AIAALAIQQsAAMAiBC0AAACLELQAAAAsQtACAACwCEELAADAIgQtAAAAixC0AAAALELQAgAAsEjl0m4AJSd4zJIys52Dr0Zeh04AACjbOKIFAABgEYIWAACARQhaAAAAFiFoAQAAWISgBQAAYJEyHbSys7P14osvKiQkRO7u7qpbt65eeuklGYZh1hiGoXHjxqlWrVpyd3dXeHi4Dhw44LCekydPqn///vLy8pK3t7cGDx6sM2fOONTs3LlTHTp0kJubmwIDAzV58uTrMkYAAFBxlemg9dprr2nmzJl6++23tXfvXr322muaPHmypk+fbtZMnjxZb731lmbNmqVNmzbJw8NDERERunDhglnTv39/JSUlKSEhQYsXL9a6des0dOhQc35GRoa6du2qoKAgbdu2TVOmTFFcXJzee++96zpeAABQsdiMyw8PlTF33323/Pz89OGHH5rTevfuLXd3d82dO1eGYSggIEDPPPOMnn32WUlSenq6/Pz8FB8fr759+2rv3r0KDQ3Vli1b1KpVK0nS0qVL1aNHDx09elQBAQGaOXOmXnjhBaWkpMjFxUWS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AFiEoAUAAGARghYAAIBFCFoAyry4uDg1a9bM0m107txZI0eONJ8HBwdr2rRplm4TQMVH0AJQaq4MNwV59tln8/z9R6tt2bJFQ4cOLVQtoQxAQSqXdgMAUBDDMJSdnS1PT095enpe1237+Phc1+0BqJg4ogWgVAwaNEhr167Vm2++KZvNJpvNpvj4eNlsNn333Xdq2bKlXF1d9f333+c5dTho0CBFRUVpwoQJ8vHxkZeXl5544gllZmYWattnz57VwIED5enpqVq1amnq1Kl5ai4/SmUYhuLi4lSnTh25uroqICBAI0aMkPTXUblDhw7p6aefNschSSdOnFC/fv100003qUqVKmrcuLE++eQTh2107txZI0aM0PPPP6/q1avL399fcXFxDjVpaWl6/PHH5efnJzc3NzVq1EiLFy8253///ffq0KGD3N3dFRgYqBEjRujs2bOF+jkAsB5BC0CpePPNNxUWFqYhQ4bo999/1++//67AwEBJ0pgxY/Tqq69q7969atKkSb7Lr1y5Unv37tWaNWv0ySefaMGCBZowYUKhtv3cc89p7dq1+uqrr7R8+XKtWbNG27dvL7D+yy+/1BtvvKF3331XBw4c0KJFi9S4cWNJ0oIFC1S7dm1NnDjRHIckXbhwQS1bttSSJUu0e/duDR06VAMGDNDmzZsd1j1nzhx5eHho06ZNmjx5siZOnKiEhARJUk5Ojrp3767169dr7ty52rNnj1599VVVqlRJkvTLL7+oW7du6t27t3bu3Kn58+fr+++/V2xsbKF+DgCug+L+pWwA+Ls6depkPPXUU+bz1atXG5KMRYsWOdSNHz/eaNq0qfk8OjraqF69unH27Flz2syZMw1PT08jOzv7qts8ffq04eLiYnz22WfmtBMnThju7u4OvQQFBRlvvPGGYRiGMXXqVOOWW24xMjMz813n5bVXExkZaTzzzDPm806dOhnt27d3qLn99tuN0aNHG4ZhGMuWLTOcnJyM/fv357u+wYMHG0OHDnWY9r///c9wcnIyzp8/f81+AFiPI1oAypxWrVpds6Zp06aqUqWK+TwsLExnzpzRkSNHrrrcL7/8oszMTLVp08acVr16dTVo0KDAZR544AGdP39eN998s4YMGaKFCxfq0qVLV91Odna2XnrpJTVu3FjVq1eXp6enli1bpsOHDzvUXXnErlatWjp+/LgkaceOHapdu7ZuueWWfLfx448/Kj4+3ryGzdPTUxEREcrJyVFycvJV+wNwfXAxPIAyx8PDo7RbcBAYGKj9+/drxYoVSkhI0JNPPqkpU6Zo7dq1cnZ2zneZKVOm6M0339S0adPUuHFjeXh4aOTIkXmuI7tyeZvNppycHEmSu7v7Vfs6c+aMHn/8cfN6scvVqVOnKEMEYBGCFoBS4+Liouzs7GIt++OPP+r8+fNmGNm4caM8PT3N67wKUrduXTk7O2vTpk1mGDl16pR++uknderUqcDl3N3d1bNnT/Xs2VMxMTFq2LChdu3apRYtWuQ7jvXr1+vee+/Vww8/LOmv661++uknhYaGFnqMTZo00dGjR/XTTz/le1SrRYsW2rNnj+rVq1fodQK4vjh1CKDUBAcHa9OmTTp48KD+/PNP80hOYWRmZmrw4MHas2ePvv32W40fP16xsbFycrr6f2uenp4aPHiwnnvuOa1atUq7d+/WoEGDrrpcfHy8PvzwQ+3evVu//vqr5s6dK3d3dwUFBZnjWLdunX777Tf9+eefkqT69esrISFBGzZs0N69e/X4448rNTW10OOTpE6dOqljx47q3bu3EhISlJycrO+++05Lly6VJI0ePVobNmxQbGysduzYoQMHDuirr77iYnigDCFoASg1zz77rCpVqqTQ0FD5+PjkuX7parp06aL69eurY8eOevDBB3XPPffkuTVCQaZMmaIOHTqoZ8+eCg8PV/v27dWyZcsC6729vfX+++/rjjvuUJMmTbRixQp98803qlGjhiRp4sSJOnjwoOrWrWvef+uf//ynWrRooYiICHXu3Fn+/v6Kiooq9Phyffnll7r99tvVr18/hYaG6vnnnzePnjVp0kRr167VTz/9pA4dOqh58+YaN26cAgICirwdANawGYZhlHYTAFAUgwYNUlpamhYtWlTarQDAVXFECwAAwCIELQAVyuHDhx1ud3DloyinJwHg7+LUIYAK5dKlSzp48GCB84ODg1W5Ml+4BnB9ELQAAAAswqlDAAAAixC0AAAALELQAgAAsAhBCwAAwCIELQAAAIsQtAAAACxC0AIAALAIQQsAAMAi/x/xuOIVWacZFAAAAABJRU5ErkJggg==", + "text/plain": [ + "
" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "%sqlplot histogram --table many_passengers --column trip_distance --with many_passengers" + ] + }, + { + "attachments": {}, + "cell_type": "markdown", + "id": "3544f41d", + "metadata": {}, + "source": [ + "## Clean up\n", + "\n", + "To stop and remove the container:" + ] + }, + { + "cell_type": "code", + "execution_count": 18, + "id": "6d408cc0", + "metadata": { + "tags": [] + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES\n", + "b9f7f973698a mysql \"docker-entrypoint.s…\" 2 minutes ago Up 2 minutes 0.0.0.0:3306->3306/tcp, 33060/tcp mysql\n" + ] + } + ], + "source": [ + "%%bash\n", + "docker container ls" + ] + }, + { + "cell_type": "code", + "execution_count": 19, + "id": "42c37efd-1666-42dd-a38c-1944860b9c39", + "metadata": { + "tags": [] + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "mysql\n" + ] + } + ], + "source": [ + "%%bash\n", + "docker container stop mysql" + ] + }, + { + "cell_type": "code", + "execution_count": 20, + "id": "6c9bce10", + "metadata": { + "tags": [] + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "mysql\n" + ] + } + ], + "source": [ + "%%bash\n", + "docker container rm mysql" + ] + }, + { + "cell_type": "code", + "execution_count": 21, + "id": "17d42e97-9be7-43a8-916a-56dea1ca3dda", + "metadata": { + "tags": [] + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES\n" + ] + } + ], + "source": [ + "%%bash\n", + "docker container ls" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3 (ipykernel)", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.10.11" + }, + "myst": { + "html_meta": { + "description lang=en": "Query a MySQL database from Jupyter via JupySQL", + "keywords": "jupyter, sql, jupysql, mysql", + "property=og:locale": "en_US" + } + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/doc/integrations/oracle.ipynb b/doc/integrations/oracle.ipynb new file mode 100644 index 000000000..c34af77fe --- /dev/null +++ b/doc/integrations/oracle.ipynb @@ -0,0 +1,772 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "95637f19-ea03-4ccf-90d4-b71d323abb27", + "metadata": {}, + "source": [ + "# Oracle Database\n", + "\n", + "This tutorial will show you how to get an Oracle instance up and running locally to test JupySQL. You can run this in a Jupyter notebook." + ] + }, + { + "cell_type": "markdown", + "id": "2148ccd5-8acd-465b-bd56-f769afb6d731", + "metadata": { + "tags": [] + }, + "source": [ + "## Pre-requisites\n", + "\n", + "To run this tutorial, you need to install following Python packages:" + ] + }, + { + "cell_type": "code", + "execution_count": 1, + "id": "9b37f055-05f6-48a1-9181-858f18184513", + "metadata": { + "tags": [] + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Note: you may need to restart the kernel to use updated packages.\n" + ] + } + ], + "source": [ + "%pip install oracledb --quiet" + ] + }, + { + "cell_type": "markdown", + "id": "103b788b-15fd-4648-83f0-e82674c63693", + "metadata": { + "tags": [] + }, + "source": [ + "## Start Oracle instance\n", + "\n", + "We use the non-official image [gvenzl/oracle-free](https://hub.docker.com/r/gvenzl/oracle-free) to initial the instance, and database users (this will take 1-2 minutes):" + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "id": "1286fc96-0365-4af5-a595-82adcdcabe8c", + "metadata": { + "tags": [] + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "cc531fdc8802c40aa666a8b3eb52debda71fc0e64bc00ef956da22314dc9b971\n" + ] + } + ], + "source": [ + "%%bash\n", + "docker run --name oracle \\\n", + " -e ORACLE_PASSWORD=ploomber_app_admin_password \\\n", + " -e APP_USER=ploomber_app \\\n", + " -e APP_USER_PASSWORD=ploomber_app_password \\\n", + " -p 1521:1521 -d gvenzl/oracle-free" + ] + }, + { + "cell_type": "markdown", + "id": "b41581e7-f25b-45cf-9f05-532d3076b3f0", + "metadata": { + "tags": [] + }, + "source": [ + "Our database is running, let’s load some data!" + ] + }, + { + "cell_type": "markdown", + "id": "bab85392-8a67-4831-b501-0aee53c8cc2a", + "metadata": {}, + "source": [ + "## Load sample data\n", + "\n", + "Now, let's fetch some sample data. We'll be using the [iris.csv](https://www.nyc.gov/site/tlc/about/tlc-trip-record-data.page):" + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "id": "c394c8ba-a84c-401e-8ef9-c3d05ed36fc8", + "metadata": { + "tags": [] + }, + "outputs": [ + { + "data": { + "text/plain": [ + "(150, 5)" + ] + }, + "execution_count": 2, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "import pandas as pd\n", + "\n", + "df = pd.read_csv(\n", + " \"https://github.com/Muhd-Shahid/Write-Raw-File-into-Database-Server/raw/main/iris.csv\", # noqa: E501\n", + " index_col=False,\n", + ")\n", + "df.shape" + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "id": "f032ed1a-919a-4a20-a122-86c547192c3f", + "metadata": { + "tags": [] + }, + "outputs": [], + "source": [ + "from sqlalchemy import create_engine\n", + "from sqlalchemy.types import Float\n", + "\n", + "engine = create_engine(\n", + " \"oracle+oracledb://ploomber_app:ploomber_app_password@localhost:1521/?service_name=FREEPDB1\" # noqa: E501\n", + ")\n", + "df.to_sql(\n", + " name=\"iris\",\n", + " con=engine,\n", + " chunksize=1000,\n", + " if_exists=\"replace\",\n", + " index=False,\n", + " dtype={\n", + " \"sepal_length\": Float(),\n", + " \"sepal_width\": Float(),\n", + " \"petal_length\": Float(),\n", + " \"petal_width\": Float(),\n", + " },\n", + ")\n", + "engine.dispose()" + ] + }, + { + "cell_type": "markdown", + "id": "45cea9dc-84d6-4336-b97b-d07c72e5039e", + "metadata": {}, + "source": [ + "## Query\n", + "\n", + "Now, let's start JupySQL, authenticate, and start querying the data!" + ] + }, + { + "cell_type": "code", + "execution_count": 4, + "id": "536942de-15b0-47c3-9d29-76714555f5c9", + "metadata": { + "tags": [] + }, + "outputs": [], + "source": [ + "%load_ext sql" + ] + }, + { + "cell_type": "code", + "execution_count": 5, + "id": "cd4a3a3f-3132-4386-87b5-2ca2c116afa1", + "metadata": { + "tags": [] + }, + "outputs": [], + "source": [ + "%sql oracle+oracledb://ploomber_app:ploomber_app_password@localhost:1521/?service_name=FREEPDB1" + ] + }, + { + "cell_type": "markdown", + "id": "1f0cf0b7-c27b-4b20-b6b4-10edeaf9d91b", + "metadata": {}, + "source": [ + "List the tables in the database:" + ] + }, + { + "cell_type": "code", + "execution_count": 6, + "id": "1c9fc715-7dae-43a6-a695-31b787329d91", + "metadata": { + "tags": [] + }, + "outputs": [ + { + "data": { + "text/html": [ + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
Name
iris
" + ], + "text/plain": [ + "+------+\n", + "| Name |\n", + "+------+\n", + "| iris |\n", + "+------+" + ] + }, + "execution_count": 6, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "%sqlcmd tables" + ] + }, + { + "cell_type": "markdown", + "id": "c7ba0f68-5e44-4879-a051-3ba4e61d0642", + "metadata": {}, + "source": [ + "Query some data in iris table" + ] + }, + { + "cell_type": "code", + "execution_count": 11, + "id": "68d9ab32-dab5-45e9-8093-a99e933430eb", + "metadata": { + "tags": [] + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "* oracle+oracledb://ploomber_app:***@localhost:1521/?service_name=FREEPDB1\n", + "0 rows affected.\n" + ] + }, + { + "data": { + "text/html": [ + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
sepal_lengthsepal_widthpetal_lengthpetal_widthspecies
6.72.55.81.8virginica
7.23.66.12.5virginica
6.53.25.12.0virginica
6.42.75.31.9virginica
6.83.05.52.1virginica
" + ], + "text/plain": [ + "+--------------+-------------+--------------+-------------+-----------+\n", + "| sepal_length | sepal_width | petal_length | petal_width | species |\n", + "+--------------+-------------+--------------+-------------+-----------+\n", + "| 6.7 | 2.5 | 5.8 | 1.8 | virginica |\n", + "| 7.2 | 3.6 | 6.1 | 2.5 | virginica |\n", + "| 6.5 | 3.2 | 5.1 | 2.0 | virginica |\n", + "| 6.4 | 2.7 | 5.3 | 1.9 | virginica |\n", + "| 6.8 | 3.0 | 5.5 | 2.1 | virginica |\n", + "+--------------+-------------+--------------+-------------+-----------+" + ] + }, + "execution_count": 11, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "%sql SELECT * FROM iris FETCH NEXT 5 ROWS ONLY" + ] + }, + { + "cell_type": "markdown", + "id": "930552ed-c9d3-4e89-99d7-5e2c42bd3f28", + "metadata": {}, + "source": [ + "Query our data:" + ] + }, + { + "cell_type": "code", + "execution_count": 11, + "id": "8b58af21-cec8-4ebc-bfa5-be5a58d31f07", + "metadata": { + "tags": [] + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "* oracle+oracledb://ploomber_app:***@localhost:1521/?service_name=FREEPDB1\n", + "0 rows affected.\n" + ] + }, + { + "data": { + "text/html": [ + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
COUNT(*)
150
" + ], + "text/plain": [ + "+----------+\n", + "| COUNT(*) |\n", + "+----------+\n", + "| 150 |\n", + "+----------+" + ] + }, + "execution_count": 11, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "%%sql\n", + "SELECT COUNT(*) FROM iris" + ] + }, + { + "cell_type": "markdown", + "id": "354002fa-2ee8-4667-8c18-d650058cca62", + "metadata": {}, + "source": [ + "## Parametrize queries" + ] + }, + { + "cell_type": "code", + "execution_count": 12, + "id": "497e68aa-2acd-4642-aed7-b7014a27875b", + "metadata": { + "tags": [] + }, + "outputs": [], + "source": [ + "threshold = 5.0" + ] + }, + { + "cell_type": "code", + "execution_count": 13, + "id": "3e713450-67a4-4142-8876-0eca4deca5af", + "metadata": { + "tags": [] + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "* oracle+oracledb://ploomber_app:***@localhost:1521/?service_name=FREEPDB1\n", + "0 rows affected.\n" + ] + }, + { + "data": { + "text/html": [ + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
COUNT(*)
22
" + ], + "text/plain": [ + "+----------+\n", + "| COUNT(*) |\n", + "+----------+\n", + "| 22 |\n", + "+----------+" + ] + }, + "execution_count": 13, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "%%sql\n", + "SELECT COUNT(*) FROM iris\n", + "WHERE sepal_length < {{threshold}}" + ] + }, + { + "cell_type": "markdown", + "id": "7499ae1d-6ad6-4eb3-80d1-0dbad8bd69c3", + "metadata": {}, + "source": [ + "## CTEs" + ] + }, + { + "cell_type": "code", + "execution_count": 13, + "id": "f3ade8af-05e3-4ea4-bf0e-461406161064", + "metadata": { + "tags": [] + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "* oracle+oracledb://ploomber_app:***@localhost:1521/?service_name=FREEPDB1\n", + "Skipping execution...\n" + ] + } + ], + "source": [ + "%%sql --save saved_cte --no-execute\n", + "SELECT * FROM iris\n", + "WHERE sepal_length > 6.0" + ] + }, + { + "cell_type": "code", + "execution_count": 14, + "id": "33f66588-5f52-478a-b077-dc1387b4f50d", + "metadata": { + "tags": [] + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "* oracle+oracledb://ploomber_app:***@localhost:1521/?service_name=FREEPDB1\n", + "0 rows affected.\n" + ] + }, + { + "data": { + "text/html": [ + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
sepal_lengthsepal_widthpetal_lengthpetal_widthspecies
6.72.55.81.8virginica
7.23.66.12.5virginica
6.53.25.12.0virginica
6.42.75.31.9virginica
6.83.05.52.1virginica
" + ], + "text/plain": [ + "+--------------+-------------+--------------+-------------+-----------+\n", + "| sepal_length | sepal_width | petal_length | petal_width | species |\n", + "+--------------+-------------+--------------+-------------+-----------+\n", + "| 6.7 | 2.5 | 5.8 | 1.8 | virginica |\n", + "| 7.2 | 3.6 | 6.1 | 2.5 | virginica |\n", + "| 6.5 | 3.2 | 5.1 | 2.0 | virginica |\n", + "| 6.4 | 2.7 | 5.3 | 1.9 | virginica |\n", + "| 6.8 | 3.0 | 5.5 | 2.1 | virginica |\n", + "+--------------+-------------+--------------+-------------+-----------+" + ] + }, + "execution_count": 14, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "%sql --with saved_cte SELECT * FROM saved_cte FETCH NEXT 5 ROWS ONLY" + ] + }, + { + "cell_type": "markdown", + "id": "055043c2-7590-4c8c-a74d-49d27f0f2865", + "metadata": {}, + "source": [ + "This is what JupySQL executes:" + ] + }, + { + "cell_type": "code", + "execution_count": 18, + "id": "ce01a806-133f-4b9d-b7dc-ffe3ad00e54c", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "WITH\n", + "SELECT * FROM iris\n", + "WHERE sepal_length > 6.0\n" + ] + } + ], + "source": [ + "query = %sqlcmd snippets saved_cte\n", + "print(query)" + ] + }, + { + "cell_type": "markdown", + "id": "bb5dcc49-4af9-40ad-a3e2-4ae83d223200", + "metadata": {}, + "source": [ + "## Clean up\n", + "\n", + "To stop and remove the container:" + ] + }, + { + "cell_type": "code", + "execution_count": 19, + "id": "0817041d-25aa-4caa-b62e-eef3cf2c5aa0", + "metadata": { + "tags": [] + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES\n", + "cc531fdc8802 gvenzl/oracle-free \"container-entrypoin…\" 7 minutes ago Up 7 minutes 0.0.0.0:1521->1521/tcp, :::1521->1521/tcp oracle\n" + ] + } + ], + "source": [ + "! docker container ls" + ] + }, + { + "cell_type": "code", + "execution_count": 21, + "id": "7f462c66-8f11-49c3-b32c-8323a1fd7043", + "metadata": { + "tags": [] + }, + "outputs": [], + "source": [ + "%%capture out\n", + "! docker container ls --filter name=oracle --quiet" + ] + }, + { + "cell_type": "code", + "execution_count": 24, + "id": "2a319a89-49df-47ef-af29-19377b39af66", + "metadata": { + "tags": [] + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Container id: cc531fdc8802\n" + ] + } + ], + "source": [ + "container_id = out.stdout.strip()\n", + "print(f\"Container id: {container_id}\")" + ] + }, + { + "cell_type": "markdown", + "id": "19d7df0b-7d3b-4da5-9c2e-7e4e1cb9c7de", + "metadata": {}, + "source": [ + "Remove the container" + ] + }, + { + "cell_type": "code", + "execution_count": 28, + "id": "2ec3464e-a108-4710-aa84-14f138e60c25", + "metadata": { + "tags": [] + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "cc531fdc8802\n", + "cc531fdc8802\n" + ] + } + ], + "source": [ + "! docker container stop {container_id}\n", + "! docker container rm {container_id}" + ] + }, + { + "cell_type": "code", + "execution_count": 29, + "id": "7ab5d851-735f-44eb-8d59-57b3b1149c13", + "metadata": { + "tags": [] + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES\n" + ] + } + ], + "source": [ + "! docker container ls" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "2d0d7fec-c32e-489c-bdfa-5a5832f4c7ab", + "metadata": {}, + "outputs": [], + "source": [] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3 (ipykernel)", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.10.11" + }, + "myst": { + "html_meta": { + "description lang=en": "Query a Oracle database from Jupyter via JupySQL", + "keywords": "jupyter, sql, jupysql, postgres", + "property=og:locale": "en_US" + } + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/doc/integrations/pandas.md b/doc/integrations/pandas.md new file mode 100644 index 000000000..b2cd887d1 --- /dev/null +++ b/doc/integrations/pandas.md @@ -0,0 +1,149 @@ +--- +jupytext: + cell_metadata_filter: -all + formats: md:myst + notebook_metadata_filter: myst + text_representation: + extension: .md + format_name: myst + format_version: 0.13 + jupytext_version: 1.14.7 +kernelspec: + display_name: Python 3 (ipykernel) + language: python + name: python3 +myst: + html_meta: + description lang=en: Convert outputs from SQL queries to pandas data frames using + JupySQL + keywords: jupyter, sql, jupysql, pandas + property=og:locale: en_US +--- + +# Pandas + +If you have installed [`pandas`](http://pandas.pydata.org/), you can use a result set's `.DataFrame()` method. + ++++ + +## Load sample data + +Let's create some sample data: + +```{code-cell} ipython3 +%load_ext sql +``` + +```{code-cell} ipython3 +%%sql sqlite:// +CREATE TABLE writer (first_name, last_name, year_of_death); +INSERT INTO writer VALUES ('William', 'Shakespeare', 1616); +INSERT INTO writer VALUES ('Bertold', 'Brecht', 1956); +``` + +## Convert to `pandas.DataFrame` + ++++ + +Query the sample data and convert to `pandas.DataFrame`: + +```{code-cell} ipython3 +result = %sql SELECT * FROM writer WHERE year_of_death > 1900 +``` + +```{code-cell} ipython3 +df = result.DataFrame() +type(df) +``` + +```{code-cell} ipython3 +df +``` + +Or using the cell magic: + +```{code-cell} ipython3 +%%sql result << +SELECT * FROM writer WHERE year_of_death > 1900 +``` + +```{code-cell} ipython3 +result.DataFrame() +``` + +## Convert automatically + +```{code-cell} ipython3 +%config SqlMagic.autopandas = True +df = %sql SELECT * FROM writer +type(df) +``` + +```{code-cell} ipython3 +df +``` + +## Uploading a dataframe to the database + +```{versionadded} 0.7.0 + We are using SQLAlchemy 2.x to support this feature. If you are using Python 3.7, please upgrade to Python 3.8+. Alternatively, you might use Python 3.7 and downgrade to SQlAlchemy 1.x +``` + ++++ + +### `--persist` + +The `--persist` argument, with the name of a DataFrame object in memory, +will create a table name in the database from the named DataFrame. Or use `--append` to add rows to an existing table by that name. + +```{code-cell} ipython3 +%sql --persist df +``` + +```{code-cell} ipython3 +%sql SELECT * FROM df; +``` + +### `--persist-replace` + +The `--persist-replace` performs the similar functionality with `--persist`, +but it will drop the existing table before inserting the new table + +#### Declare the dataframe again + +```{code-cell} ipython3 +df = %sql SELECT * FROM writer LIMIT 1 +df +``` + +#### Use `--persist-replace` + +```{code-cell} ipython3 +%sql --persist-replace df +``` + +#### df table is overridden + +```{code-cell} ipython3 +%sql SELECT * FROM df; +``` + +### `--persist` in schema + +A schema can also be specified when persisting a dataframe. + +```{code-cell} ipython3 +%%sql duckdb:// +CREATE SCHEMA IF NOT EXISTS schema1; +CREATE TABLE numbers (num INTEGER); +INSERT INTO numbers VALUES (1); +INSERT INTO numbers VALUES (2); +``` + +```{code-cell} ipython3 +results = %sql SELECT * FROM numbers; +``` + +```{code-cell} ipython3 +%sql --persist schema1.results +``` diff --git a/doc/integrations/polars.md b/doc/integrations/polars.md new file mode 100644 index 000000000..b2b5dfd49 --- /dev/null +++ b/doc/integrations/polars.md @@ -0,0 +1,84 @@ +--- +jupytext: + cell_metadata_filter: -all + formats: md:myst + notebook_metadata_filter: myst + text_representation: + extension: .md + format_name: myst + format_version: 0.13 + jupytext_version: 1.14.7 +kernelspec: + display_name: Python 3 (ipykernel) + language: python + name: python3 +myst: + html_meta: + description lang=en: Convert outputs from SQL queries to polars data frames using + JupySQL + keywords: jupyter, sql, jupysql, polars + property=og:locale: en_US +--- + +# Polars + +If you have installed [`polars`](https://www.pola.rs/), you can use a result set's `.PolarsDataFrame()` method. + ++++ + +## Load sample data + +Let's create some sample data: + +```{code-cell} ipython3 +%load_ext sql +``` + +```{code-cell} ipython3 +%%sql sqlite:// +CREATE TABLE writer (first_name, last_name, year_of_death); +INSERT INTO writer VALUES ('William', 'Shakespeare', 1616); +INSERT INTO writer VALUES ('Bertold', 'Brecht', 1956); +``` + +## Convert to `polars.DataFrame` + ++++ + +Query the sample data and convert to `polars.DataFrame`: + +```{code-cell} ipython3 +result = %sql SELECT * FROM writer WHERE year_of_death > 1900 +``` + +```{code-cell} ipython3 +df = result.PolarsDataFrame() +type(df) +``` + +```{code-cell} ipython3 +df +``` + +Or using the cell magic: + +```{code-cell} ipython3 +%%sql result << +SELECT * FROM writer WHERE year_of_death > 1900 +``` + +```{code-cell} ipython3 +result.PolarsDataFrame() +``` + +## Convert automatically + +```{code-cell} ipython3 +%config SqlMagic.autopolars = True +df = %sql SELECT * FROM writer +type(df) +``` + +```{code-cell} ipython3 +df +``` diff --git a/doc/integrations/postgres-connect.ipynb b/doc/integrations/postgres-connect.ipynb new file mode 100644 index 000000000..e033d9f8e --- /dev/null +++ b/doc/integrations/postgres-connect.ipynb @@ -0,0 +1,1073 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# PostgreSQL\n", + "\n", + "This tutorial will show you how to get a PostgreSQL instance up and running locally to test JupySQL. You can run this in a Jupyter notebook." + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Pre-requisites\n", + "\n", + "To run this tutorial, you need to install following Python packages:" + ] + }, + { + "cell_type": "code", + "execution_count": 1, + "metadata": { + "tags": [] + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Note: you may need to restart the kernel to use updated packages.\n" + ] + } + ], + "source": [ + "%pip install jupysql pandas pyarrow --quiet" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "You also need a PostgreSQL connector. Here's a list of [supported connectors.](https://docs.sqlalchemy.org/en/14/dialects/postgresql.html#dialect-postgresql) We recommend using `psycopg2`. The easiest way to install it is via:" + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "metadata": { + "tags": [] + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Note: you may need to restart the kernel to use updated packages.\n" + ] + } + ], + "source": [ + "%pip install psycopg2-binary --quiet" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "```{tip}\n", + "If you have issues, check out our [installation guide](../howto/postgres-install.md) or [message us on Slack.](https://ploomber.io/community)\n", + "```\n", + "\n", + "You also need Docker installed and running to start the PostgreSQL instance." + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Start PostgreSQL instance\n", + "\n", + "We fetch the official image, create a new database, and user (this will take 1-2 minutes):" + ] + }, + { + "cell_type": "code", + "execution_count": 11, + "metadata": { + "tags": [] + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "94fa1f440c4c8e632b59fc630dd513c4d653c95c964fd4deddf3430db1223c1b\n" + ] + } + ], + "source": [ + "%%bash\n", + "docker run --name postgres -e POSTGRES_DB=db \\\n", + " -e POSTGRES_USER=user \\\n", + " -e POSTGRES_PASSWORD=password \\\n", + " -p 5432:5432 -d postgres" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Our database is running, let's load some data!" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Load sample data\n", + "\n", + "Now, let's fetch some sample data. We'll be using the [NYC taxi dataset](https://www.nyc.gov/site/tlc/about/tlc-trip-record-data.page):" + ] + }, + { + "cell_type": "code", + "execution_count": 1, + "metadata": { + "tags": [] + }, + "outputs": [ + { + "data": { + "text/plain": [ + "(1369769, 19)" + ] + }, + "execution_count": 1, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "import pandas as pd\n", + "\n", + "df = pd.read_parquet(\n", + " \"https://d37ci6vzurychx.cloudfront.net/trip-data/yellow_tripdata_2021-01.parquet\"\n", + ")\n", + "df.shape" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "As you can see, this chunk of data contains ~1.4M rows, loading the data will take about a minute:" + ] + }, + { + "cell_type": "code", + "execution_count": 13, + "metadata": { + "tags": [] + }, + "outputs": [], + "source": [ + "from sqlalchemy import create_engine\n", + "\n", + "engine = create_engine(\"postgresql://user:password@localhost/db\")\n", + "df.to_sql(name=\"taxi\", con=engine, chunksize=100_000)\n", + "engine.dispose()" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Query\n", + "\n", + "Now, let's start JupySQL, authenticate and start querying the data!" + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "metadata": { + "tags": [] + }, + "outputs": [], + "source": [ + "%load_ext sql" + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "metadata": { + "tags": [] + }, + "outputs": [], + "source": [ + "%sql postgresql://user:password@localhost/db" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "```{important}\n", + "If the cell above fails, you might have some missing packages. Message us on [Slack](https://ploomber.io/community) and we'll help you!\n", + "```" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "List the tables in the database:" + ] + }, + { + "cell_type": "code", + "execution_count": 4, + "metadata": { + "tags": [] + }, + "outputs": [ + { + "data": { + "text/html": [ + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
Name
taxi
" + ], + "text/plain": [ + "+------+\n", + "| Name |\n", + "+------+\n", + "| taxi |\n", + "+------+" + ] + }, + "execution_count": 4, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "%sqlcmd tables" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "List columns in the taxi table:" + ] + }, + { + "cell_type": "code", + "execution_count": 5, + "metadata": { + "tags": [] + }, + "outputs": [ + { + "data": { + "text/html": [ + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
nametypenullabledefaultautoincrementcomment
indexBIGINTTrueNoneFalseNone
VendorIDBIGINTTrueNoneFalseNone
tpep_pickup_datetimeTIMESTAMPTrueNoneFalseNone
tpep_dropoff_datetimeTIMESTAMPTrueNoneFalseNone
passenger_countDOUBLE_PRECISIONTrueNoneFalseNone
trip_distanceDOUBLE_PRECISIONTrueNoneFalseNone
RatecodeIDDOUBLE_PRECISIONTrueNoneFalseNone
store_and_fwd_flagTEXTTrueNoneFalseNone
PULocationIDBIGINTTrueNoneFalseNone
DOLocationIDBIGINTTrueNoneFalseNone
payment_typeBIGINTTrueNoneFalseNone
fare_amountDOUBLE_PRECISIONTrueNoneFalseNone
extraDOUBLE_PRECISIONTrueNoneFalseNone
mta_taxDOUBLE_PRECISIONTrueNoneFalseNone
tip_amountDOUBLE_PRECISIONTrueNoneFalseNone
tolls_amountDOUBLE_PRECISIONTrueNoneFalseNone
improvement_surchargeDOUBLE_PRECISIONTrueNoneFalseNone
total_amountDOUBLE_PRECISIONTrueNoneFalseNone
congestion_surchargeDOUBLE_PRECISIONTrueNoneFalseNone
airport_feeDOUBLE_PRECISIONTrueNoneFalseNone
" + ], + "text/plain": [ + "+-----------------------+------------------+----------+---------+---------------+---------+\n", + "| name | type | nullable | default | autoincrement | comment |\n", + "+-----------------------+------------------+----------+---------+---------------+---------+\n", + "| index | BIGINT | True | None | False | None |\n", + "| VendorID | BIGINT | True | None | False | None |\n", + "| tpep_pickup_datetime | TIMESTAMP | True | None | False | None |\n", + "| tpep_dropoff_datetime | TIMESTAMP | True | None | False | None |\n", + "| passenger_count | DOUBLE_PRECISION | True | None | False | None |\n", + "| trip_distance | DOUBLE_PRECISION | True | None | False | None |\n", + "| RatecodeID | DOUBLE_PRECISION | True | None | False | None |\n", + "| store_and_fwd_flag | TEXT | True | None | False | None |\n", + "| PULocationID | BIGINT | True | None | False | None |\n", + "| DOLocationID | BIGINT | True | None | False | None |\n", + "| payment_type | BIGINT | True | None | False | None |\n", + "| fare_amount | DOUBLE_PRECISION | True | None | False | None |\n", + "| extra | DOUBLE_PRECISION | True | None | False | None |\n", + "| mta_tax | DOUBLE_PRECISION | True | None | False | None |\n", + "| tip_amount | DOUBLE_PRECISION | True | None | False | None |\n", + "| tolls_amount | DOUBLE_PRECISION | True | None | False | None |\n", + "| improvement_surcharge | DOUBLE_PRECISION | True | None | False | None |\n", + "| total_amount | DOUBLE_PRECISION | True | None | False | None |\n", + "| congestion_surcharge | DOUBLE_PRECISION | True | None | False | None |\n", + "| airport_fee | DOUBLE_PRECISION | True | None | False | None |\n", + "+-----------------------+------------------+----------+---------+---------------+---------+" + ] + }, + "execution_count": 5, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "%sqlcmd columns --table taxi" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Query our data:" + ] + }, + { + "cell_type": "code", + "execution_count": 6, + "metadata": { + "tags": [] + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "* postgresql://user:***@localhost/db\n", + "1 rows affected.\n" + ] + }, + { + "data": { + "text/html": [ + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
count
1369769
" + ], + "text/plain": [ + "[(1369769,)]" + ] + }, + "execution_count": 6, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "%%sql\n", + "SELECT COUNT(*) FROM taxi" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Parametrize queries" + ] + }, + { + "cell_type": "code", + "execution_count": 7, + "metadata": { + "tags": [] + }, + "outputs": [], + "source": [ + "threshold = 10" + ] + }, + { + "cell_type": "code", + "execution_count": 8, + "metadata": { + "tags": [] + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "* postgresql://user:***@localhost/db\n", + "1 rows affected.\n" + ] + }, + { + "data": { + "text/html": [ + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
count
1297415
" + ], + "text/plain": [ + "[(1297415,)]" + ] + }, + "execution_count": 8, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "%%sql\n", + "SELECT COUNT(*) FROM taxi\n", + "WHERE trip_distance < {{threshold}}" + ] + }, + { + "cell_type": "code", + "execution_count": 9, + "metadata": { + "tags": [] + }, + "outputs": [], + "source": [ + "threshold = 0.5" + ] + }, + { + "cell_type": "code", + "execution_count": 10, + "metadata": { + "tags": [] + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "* postgresql://user:***@localhost/db\n", + "1 rows affected.\n" + ] + }, + { + "data": { + "text/html": [ + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
count
73849
" + ], + "text/plain": [ + "[(73849,)]" + ] + }, + "execution_count": 10, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "%%sql\n", + "SELECT COUNT(*) FROM taxi\n", + "WHERE trip_distance < {{threshold}}" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## CTEs" + ] + }, + { + "cell_type": "code", + "execution_count": 15, + "metadata": { + "tags": [] + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "* postgresql://user:***@localhost/db\n", + "Skipping execution...\n" + ] + } + ], + "source": [ + "%%sql --save many_passengers --no-execute\n", + "SELECT *\n", + "FROM taxi\n", + "WHERE passenger_count > 3\n", + "-- remove top 1% outliers for better visualization\n", + "AND trip_distance < 18.93" + ] + }, + { + "cell_type": "code", + "execution_count": 16, + "metadata": { + "tags": [] + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "* postgresql://user:***@localhost/db\n", + "1 rows affected.\n" + ] + }, + { + "data": { + "text/html": [ + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
minavgmax
0.02.501088981288975618.92
" + ], + "text/plain": [ + "[(0.0, 2.5010889812889756, 18.92)]" + ] + }, + "execution_count": 16, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "%%sql --save trip_stats --with many_passengers\n", + "SELECT MIN(trip_distance), AVG(trip_distance), MAX(trip_distance)\n", + "FROM many_passengers" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "This is what JupySQL executes:" + ] + }, + { + "cell_type": "code", + "execution_count": 17, + "metadata": { + "tags": [] + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "WITH \"many_passengers\" AS (\n", + "SELECT *\n", + "FROM taxi\n", + "WHERE passenger_count > 3\n", + "-- remove top 1% outliers for better visualization\n", + "AND trip_distance < 18.93)\n", + "SELECT MIN(trip_distance), AVG(trip_distance), MAX(trip_distance)\n", + "FROM many_passengers\n" + ] + } + ], + "source": [ + "query = %sqlcmd snippets trip_stats\n", + "print(query)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Plotting" + ] + }, + { + "cell_type": "code", + "execution_count": 19, + "metadata": { + "tags": [] + }, + "outputs": [ + { + "data": { + "text/plain": [ + "" + ] + }, + "execution_count": 19, + "metadata": {}, + "output_type": "execute_result" + }, + { + "data": { + "image/png": "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aaJff/1VQ4cO1X//+9/yKhcotmt65uZyHnroIX377bdatGiR426Gnj17Ov7XumLFCjVs2FCff/65wsLCFBoaqqFDh+r33393ceVwlYtnGq7UggULnJY/+eQTSXIE7Mtp2rSp6tatq4ULF8oY42g/fPiwEhISSlRLQECAhgwZovvvv1/79u1znFrJv+354nHn/9L/623Rxhh9+OGHJdrvX8XExOjHH3/U2rVrL9mnT58+On78uHJzcxUZGVngdbX/I9K0aVPVq1dPn3zyidPP/MyZM1q8eLHjDqryVJJjJEnvv/9+odsZPny4UlJStGTJEn300Udavny53nrrratQMXBlrumZm6L8/PPPWrhwoX755RcFBQVJkp5++mmtWrVKc+fO1SuvvKIDBw7o8OHD+vTTTzV//nzl5uZq7Nixuueee4r8xxfW1apVKy1atEhxcXFq2LChvL291apVq1Jty8vLS9OmTdPp06d14403KiEhQS+99JJiYmJ06623Fmsbbm5u+uc//6mhQ4fqb3/7mx599FGdPHlSkyZNKtZpqfbt26tPnz5q3bq1atSooeTkZP373/92+gWdP75XX31VMTExcnd3V+vWrdW9e3d5eXnp/vvv17PPPqtz585p1qxZBU7JlMSYMWMUFxenO++8U+PHj9dNN92ks2fPasOGDerTp4+6dOmi++67TwsWLFCvXr00evRo3XTTTfL09NQvv/yidevW6c4779Tf/va3UtdwOW5ubnrttdf04IMPqk+fPho2bJiys7P1+uuv6+TJk5o6depV2/elhIeHS5I++OADVa9eXd7e3goLC1OzZs3UqFEjjR8/XsYY+fv7a8WKFYqPjy+wjdmzZ+s///mP5s6dq5YtW6ply5YaOXKknnvuOd1yyy2cjkfF4tLLmSsQSWbp0qWO5f/93/81koyPj4/Ty8PDw/Tv398YY8yjjz5qJJl9+/Y51ktKSjKSzA8//FDeQ0AFcOjQIRMdHW2qV69uJDnuRsq/I+rTTz8tsM6l7pby8fExO3fuNJ07dzZVqlQx/v7+5vHHHzenT58ucV2zZ882119/vfHy8jJNmjQxc+bMMYMHD77s3VLjx483kZGRpkaNGsZut5uGDRuasWPHmoyMDEef7OxsM3ToUFO7dm1js9mMJHPw4EFjjDErVqwwbdq0Md7e3qZevXrmmWeeMV9++WWB8RZ2N1L+z+HiGk+cOGFGjx5tGjRoYDw9PU2dOnVM7969nf7OnT9/3rzxxhuOfVerVs00a9bMDBs2zOzfv7/EP7+i7pZ6/fXXC11n2bJlpn379sbb29v4+PiYbt26mW+//dapT/6dRb/99luBcfv4+BSrjuKaPn26CQsLM+7u7kaSmTt3rjHGmL1795ru3bub6tWrmxo1aph7773XpKSkOH0Wdu7caapUqWIGDx7stM1z586ZiIgIExoaak6cOOE0JsCVbMb8Zd70Gmaz2bR06VLHd8bExcXpwQcf1J49e+Tu7u7Ut1q1agoMDNTEiRP1yiuvOD2Q7OzZs6patapWr17NXQQotSFDhuizzz7T6dOnXV0KAFQ6nJa6hLZt2yo3N1fp6enq2LFjoX1uueUWXbhwQT///LMaNWokSY7bW/96ASUAACg/13S4OX36tNNdAQcPHtSOHTvk7++vJk2a6MEHH9SgQYM0bdo0tW3bVhkZGVq7dq1atWqlXr166fbbb1e7du308MMPa/r06crLy9OIESPUvXt3NWnSxIUjw7UgLy+vwFcTXMzD45r+Kw7gGnVNn5Zav369unTpUqB98ODBmjdvns6fP6+XXnpJ8+fP19GjR1WzZk1FRUVp8uTJjosojx07pieffFKrV6+Wj4+PYmJiNG3atEr3rcGofIYMGaKPP/64yD7X8F9vANewazrcAJXZoUOHLvtAusjIyHKqBgAqDsINAACwFB7iBwAALOWau9owLy9Px44dU/Xq1Uv0KHwAAOA6xhidOnVKQUFBTl9KW5hrLtwcO3ZMwcHBri4DAACUwpEjRy77BbrXXLjJ/9K8I0eOyNfX18XVAACA4sjKylJwcHCxvvz2mgs3+aeifH19CTcAAFQyxbmkhAuKAQCApRBuAACApRBuAACApbg03GzcuFF9+/ZVUFCQbDabli1bVux1v/32W3l4eOiGG264avUBAIDKx6Xh5syZM2rTpo3efffdEq2XmZmpQYMGqVu3blepMgAAUFm59G6pmJgYxcTElHi9YcOG6YEHHpC7u3uJZnsAAID1VbprbubOnauff/5ZEydOLFb/7OxsZWVlOb0AAIB1Vapws3//fo0fP14LFiyQh0fxJp1iY2Pl5+fnePF0YgAArK3ShJvc3Fw98MADmjx5spo0aVLs9SZMmKDMzEzH68iRI1exSgAA4GqV5gnFp06d0tatW7V9+3aNHDlS0p9fgmmMkYeHh1avXq2uXbsWWM9ut8tut5d3uQAAwEUqTbjx9fXVrl27nNpmzpyptWvX6rPPPlNYWJiLKgMAABWJS8PN6dOn9dNPPzmWDx48qB07dsjf318NGjTQhAkTdPToUc2fP19ubm4KDw93Wr9OnTry9vYu0A4AAK5dLg03W7duVZcuXRzL48aNkyQNHjxY8+bNU2pqqlJSUlxVHgAAqIRsxhjj6iLKU1ZWlvz8/JSZmcm3ggMAUEmU5Pd3pblbCgAAoDgqzQXFlUXo+C9cXQIs4NDU3q4uAQAqLWZuAACApRBuAACApRBuAACApRBuAACApRBuAACApRBuAACApRBuAACApRBuAACApRBuAACApRBuAACApRBuAACApRBuAACApRBuAACApRBuAACApRBuAACApRBuAACApRBuAACApRBuAACApRBuAACApRBuAACApRBuAACApRBuAACApRBuAACApRBuAACApRBuAACApRBuAACApRBuAACApRBuAACApRBuAACApRBuAACApRBuAACApRBuAACApRBuAACApRBuAACApRBuAACApRBuAACApRBuAACApRBuAACApbg03GzcuFF9+/ZVUFCQbDabli1bVmT/JUuWqHv37qpdu7Z8fX0VFRWlr776qnyKBQAAlYJLw82ZM2fUpk0bvfvuu8Xqv3HjRnXv3l0rV65UUlKSunTpor59+2r79u1XuVIAAFBZeLhy5zExMYqJiSl2/+nTpzstv/LKK/rvf/+rFStWqG3btmVcHQAAqIxcGm6uVF5enk6dOiV/f/9L9snOzlZ2drZjOSsrqzxKAwAALlKpLyieNm2azpw5o/79+1+yT2xsrPz8/Byv4ODgcqwQAACUt0obbhYuXKhJkyYpLi5OderUuWS/CRMmKDMz0/E6cuRIOVYJAADKW6U8LRUXF6dHHnlEn376qW6//fYi+9rtdtnt9nKqDAAAuFqlm7lZuHChhgwZok8++US9e/d2dTkAAKCCcenMzenTp/XTTz85lg8ePKgdO3bI399fDRo00IQJE3T06FHNnz9f0p/BZtCgQXr77bd18803Ky0tTZJUpUoV+fn5uWQMAACgYnHpzM3WrVvVtm1bx23c48aNU9u2bfXiiy9KklJTU5WSkuLo//777+vChQsaMWKE6tat63iNHj3aJfUDAICKx6UzN507d5Yx5pLvz5s3z2l5/fr1V7cgAABQ6VW6a24AAACKQrgBAACWQrgBAACWQrgBAACWQrgBAACWQrgBAACWQrgBAACWQrgBAACWQrgBAACWQrgBAACWQrgBAACWQrgBAACWQrgBAACWQrgBAACWQrgBAACWQrgBAACWQrgBAACWQrgBAACWQrgBAACWQrgBAACWQrgBAACWQrgBAACWQrgBAACWQrgBAACWQrgBAACWQrgBAACWQrgBAACWQrgBAACWQrgBAACWQrgBAACWQrgBAACWQrgBAACWQrgBAACWQrgBAACWQrgBAACWQrgBAACWQrgBAACWQrgBAACWQrgBAACWQrgBAACW4tJws3HjRvXt21dBQUGy2WxatmzZZdfZsGGDIiIi5O3trYYNG+q99967+oUCAIBKw6Xh5syZM2rTpo3efffdYvU/ePCgevXqpY4dO2r79u16/vnnNWrUKC1evPgqVwoAACoLD1fuPCYmRjExMcXu/95776lBgwaaPn26JKl58+baunWr3njjDd19991XqUoAAFCZVKprbhITExUdHe3U1qNHD23dulXnz593UVUAAKAicenMTUmlpaUpICDAqS0gIEAXLlxQRkaG6tatW2Cd7OxsZWdnO5azsrKuep0AAMB1KtXMjSTZbDanZWNMoe35YmNj5efn53gFBwdf9RoBAIDrVKpwExgYqLS0NKe29PR0eXh4qGbNmoWuM2HCBGVmZjpeR44cKY9SAQCAi1Sq01JRUVFasWKFU9vq1asVGRkpT0/PQtex2+2y2+3lUR4AAKgAXDpzc/r0ae3YsUM7duyQ9Oet3jt27FBKSoqkP2ddBg0a5Og/fPhwHT58WOPGjVNycrLmzJmjjz76SE8//bQrygcAABWQS2dutm7dqi5dujiWx40bJ0kaPHiw5s2bp9TUVEfQkaSwsDCtXLlSY8eO1YwZMxQUFKR33nmH28ABAICDzeRfkXuNyMrKkp+fnzIzM+Xr61vm2w8d/0WZbxPXnkNTe7u6BACoUEry+7tSXVAMAABwOYQbAABgKYQbAABgKYQbAABgKYQbAABgKYQbAABgKYQbAABgKYQbAABgKYQbAABgKYQbAABgKYQbAABgKYQbAABgKYQbAABgKYQbAABgKYQbAABgKYQbAABgKYQbAABgKYQbAABgKYQbAABgKYQbAABgKYQbAABgKYQbAABgKYQbAABgKYQbAABgKYQbAABgKYQbAABgKYQbAABgKYQbAABgKYQbAABgKYQbAABgKYQbAABgKYQbAABgKYQbAABgKYQbAABgKYQbAABgKYQbAABgKYQbAABgKYQbAABgKYQbAABgKYQbAABgKYQbAABgKS4PNzNnzlRYWJi8vb0VERGhTZs2Fdl/wYIFatOmjapWraq6devqoYce0vHjx8upWgAAUNG5NNzExcVpzJgxeuGFF7R9+3Z17NhRMTExSklJKbT/N998o0GDBumRRx7Rnj179Omnn2rLli0aOnRoOVcOAAAqKpeGmzfffFOPPPKIhg4dqubNm2v69OkKDg7WrFmzCu2/efNmhYaGatSoUQoLC9Ott96qYcOGaevWreVcOQAAqKhcFm5ycnKUlJSk6Ohop/bo6GglJCQUuk6HDh30yy+/aOXKlTLG6Ndff9Vnn32m3r17X3I/2dnZysrKcnoBAADrclm4ycjIUG5urgICApzaAwIClJaWVug6HTp00IIFCzRgwAB5eXkpMDBQ1113nf71r39dcj+xsbHy8/NzvIKDg8t0HAAAoGJx+QXFNpvNadkYU6At3969ezVq1Ci9+OKLSkpK0qpVq3Tw4EENHz78ktufMGGCMjMzHa8jR46Uaf0AAKBi8XDVjmvVqiV3d/cCszTp6ekFZnPyxcbG6pZbbtEzzzwjSWrdurV8fHzUsWNHvfTSS6pbt26Bdex2u+x2e9kPAAAAVEilmrlp2LBhobdfnzx5Ug0bNizWNry8vBQREaH4+Hin9vj4eHXo0KHQdf744w+5uTmX7O7uLunPGR8AAIBShZtDhw4pNze3QHt2draOHj1a7O2MGzdOs2fP1pw5c5ScnKyxY8cqJSXFcZppwoQJGjRokKN/3759tWTJEs2aNUsHDhzQt99+q1GjRummm25SUFBQaYYCAAAspkSnpZYvX+7481dffSU/Pz/Hcm5urtasWaPQ0NBib2/AgAE6fvy4pkyZotTUVIWHh2vlypUKCQmRJKWmpjo982bIkCE6deqU3n33XT311FO67rrr1LVrV7366qslGQYAALAwmynB+Zz8U0I2m63AaSBPT0+FhoZq2rRp6tOnT9lWWYaysrLk5+enzMxM+fr6lvn2Q8d/UebbxLXn0NRLP94AAK5FJfn9XaKZm7y8PElSWFiYtmzZolq1apW+SgAAgKugVHdLHTx4sKzrAAAAKBOlvhV8zZo1WrNmjdLT0x0zOvnmzJlzxYUBAACURqnCzeTJkzVlyhRFRkaqbt26l3zoHgAAQHkrVbh57733NG/ePA0cOLCs6wEAALgipXrOTU5OziUftAcAAOBKpQo3Q4cO1SeffFLWtQAAAFyxUp2WOnfunD744AN9/fXXat26tTw9PZ3ef/PNN8ukOAAAgJIqVbjZuXOnbrjhBknS7t27nd7j4mIAAOBKpQo369atK+s6AAAAykSprrkBAACoqEo1c9OlS5ciTz+tXbu21AUBAABciVKFm/zrbfKdP39eO3bs0O7duzV48OCyqAsAAKBUShVu3nrrrULbJ02apNOnT19RQQAAAFeiTK+5+fvf/873SgEAAJcq03CTmJgob2/vstwkAABAiZTqtNRdd93ltGyMUWpqqrZu3ap//OMfZVIYAABAaZQq3Pj5+Tktu7m5qWnTppoyZYqio6PLpDAAAIDSKFW4mTt3blnXAQAAUCZKFW7yJSUlKTk5WTabTS1atFDbtm3Lqi4AAIBSKVW4SU9P13333af169fruuuukzFGmZmZ6tKlixYtWqTatWuXdZ0AAADFUqq7pZ588kllZWVpz549+v3333XixAnt3r1bWVlZGjVqVFnXCAAAUGylmrlZtWqVvv76azVv3tzR1qJFC82YMYMLigEAgEuVauYmLy9Pnp6eBdo9PT2Vl5d3xUUBAACUVqnCTdeuXTV69GgdO3bM0Xb06FGNHTtW3bp1K7PiAAAASqpU4ebdd9/VqVOnFBoaqkaNGqlx48YKCwvTqVOn9K9//ausawQAACi2Ul1zExwcrG3btik+Pl4//PCDjDFq0aKFbr/99rKuDwAAoERKNHOzdu1atWjRQllZWZKk7t2768knn9SoUaN04403qmXLltq0adNVKRQAAKA4ShRupk+frkcffVS+vr4F3vPz89OwYcP05ptvlllxAAAAJVWicPP999+rZ8+el3w/OjpaSUlJV1wUAABAaZUo3Pz666+F3gKez8PDQ7/99tsVFwUAAFBaJQo39erV065duy75/s6dO1W3bt0rLgoAAKC0ShRuevXqpRdffFHnzp0r8N7Zs2c1ceJE9enTp8yKAwAAKKkS3Qr+P//zP1qyZImaNGmikSNHqmnTprLZbEpOTtaMGTOUm5urF1544WrVCgAAcFklCjcBAQFKSEjQ448/rgkTJsgYI0my2Wzq0aOHZs6cqYCAgKtSKAAAQHGU+CF+ISEhWrlypU6cOKGffvpJxhhdf/31qlGjxtWoDwAAoERK9YRiSapRo4ZuvPHGsqwFAADgipXqu6UAAAAqKsINAACwFMINAACwFJeHm5kzZyosLEze3t6KiIi47BdvZmdn64UXXlBISIjsdrsaNWqkOXPmlFO1AACgoiv1BcVlIS4uTmPGjNHMmTN1yy236P3331dMTIz27t2rBg0aFLpO//799euvv+qjjz5S48aNlZ6ergsXLpRz5QAAoKKymfyH1bhA+/bt1a5dO82aNcvR1rx5c/Xr10+xsbEF+q9atUr33XefDhw4IH9//1LtMysrS35+fsrMzCz0282vVOj4L8p8m7j2HJra29UlAECFUpLf3y47LZWTk6OkpCRFR0c7tUdHRyshIaHQdZYvX67IyEi99tprqlevnpo0aaKnn35aZ8+eveR+srOzlZWV5fQCAADW5bLTUhkZGcrNzS3wROOAgAClpaUVus6BAwf0zTffyNvbW0uXLlVGRoaeeOIJ/f7775e87iY2NlaTJ08u8/oBAEDF5PILim02m9OyMaZAW768vDzZbDYtWLBAN910k3r16qU333xT8+bNu+TszYQJE5SZmel4HTlypMzHAAAAKg6XzdzUqlVL7u7uBWZp0tPTL/n9VHXr1lW9evXk5+fnaGvevLmMMfrll190/fXXF1jHbrfLbreXbfEAAKDCctnMjZeXlyIiIhQfH+/UHh8frw4dOhS6zi233KJjx47p9OnTjrYff/xRbm5uql+//lWtFwAAVA4uPS01btw4zZ49W3PmzFFycrLGjh2rlJQUDR8+XNKfp5QGDRrk6P/AAw+oZs2aeuihh7R3715t3LhRzzzzjB5++GFVqVLFVcMAAAAViEufczNgwAAdP35cU6ZMUWpqqsLDw7Vy5UqFhIRIklJTU5WSkuLoX61aNcXHx+vJJ59UZGSkatasqf79++ull15y1RAAAEAF49Ln3LgCz7lBZcBzbgDAWaV4zg0AAMDVQLgBAACWQrgBAACWQrgBAACWQrgBAACWQrgBAACWQrgBAACWQrgBAACWQrgBAACWQrgBAACWQrgBAACWQrgBAACWQrgBAACWQrgBAACWQrgBAACWQrgBAACWQrgBAACWQrgBAACWQrgBAACWQrgBAACWQrgBAACWQrgBAACWQrgBAACWQrgBAACWQrgBAACWQrgBAACWQrgBAACWQrgBAACWQrgBAACWQrgBAACWQrgBAACWQrgBAACWQrgBAACWQrgBAACWQrgBAACWQrgBAACWQrgBAACWQrgBAACWQrgBAACWQrgBAACWQrgBAACW4vJwM3PmTIWFhcnb21sRERHatGlTsdb79ttv5eHhoRtuuOHqFggAACoVl4abuLg4jRkzRi+88IK2b9+ujh07KiYmRikpKUWul5mZqUGDBqlbt27lVCkAAKgsXBpu3nzzTT3yyCMaOnSomjdvrunTpys4OFizZs0qcr1hw4bpgQceUFRUVDlVCgAAKguXhZucnBwlJSUpOjraqT06OloJCQmXXG/u3Ln6+eefNXHixGLtJzs7W1lZWU4vAABgXS4LNxkZGcrNzVVAQIBTe0BAgNLS0gpdZ//+/Ro/frwWLFggDw+PYu0nNjZWfn5+jldwcPAV1w4AACoul19QbLPZnJaNMQXaJCk3N1cPPPCAJk+erCZNmhR7+xMmTFBmZqbjdeTIkSuuGQAAVFzFm/64CmrVqiV3d/cCszTp6ekFZnMk6dSpU9q6dau2b9+ukSNHSpLy8vJkjJGHh4dWr16trl27FljPbrfLbrdfnUEAAIAKx2UzN15eXoqIiFB8fLxTe3x8vDp06FCgv6+vr3bt2qUdO3Y4XsOHD1fTpk21Y8cOtW/fvrxKBwAAFZjLZm4kady4cRo4cKAiIyMVFRWlDz74QCkpKRo+fLikP08pHT16VPPnz5ebm5vCw8Od1q9Tp468vb0LtAMAgGuXS8PNgAEDdPz4cU2ZMkWpqakKDw/XypUrFRISIklKTU297DNvAAAA/spmjDGuLqI8ZWVlyc/PT5mZmfL19S3z7YeO/6LMt4lrz6GpvV1dAgBUKCX5/e3yu6UAAADKEuEGAABYCuEGAABYCuEGAABYCuEGAABYCuEGAABYCuEGAABYCuEGAABYCuEGAABYCuEGAABYCuEGAABYCuEGAABYCuEGAABYCuEGAABYCuEGAABYCuEGAABYCuEGAABYCuEGAABYCuEGAABYCuEGAABYCuEGAABYCuEGAABYCuEGAABYCuEGAABYCuEGAABYCuEGAABYCuEGAABYCuEGAABYCuEGAABYCuEGAABYCuEGAABYCuEGAABYCuEGAABYCuEGAABYCuEGAABYCuEGAABYCuEGAABYCuEGAABYCuEGAABYCuEGAABYisvDzcyZMxUWFiZvb29FRERo06ZNl+y7ZMkSde/eXbVr15avr6+ioqL01VdflWO1AACgonNpuImLi9OYMWP0wgsvaPv27erYsaNiYmKUkpJSaP+NGzeqe/fuWrlypZKSktSlSxf17dtX27dvL+fKAQBARWUzxhhX7bx9+/Zq166dZs2a5Whr3ry5+vXrp9jY2GJto2XLlhowYIBefPHFYvXPysqSn5+fMjMz5evrW6q6ixI6/osy3yauPYem9nZ1CQBQoZTk97fLZm5ycnKUlJSk6Ohop/bo6GglJCQUaxt5eXk6deqU/P39L9knOztbWVlZTi8AAGBdLgs3GRkZys3NVUBAgFN7QECA0tLSirWNadOm6cyZM+rfv/8l+8TGxsrPz8/xCg4OvqK6AQBAxebyC4ptNpvTsjGmQFthFi5cqEmTJikuLk516tS5ZL8JEyYoMzPT8Tpy5MgV1wwAACouD1ftuFatWnJ3dy8wS5Oenl5gNudicXFxeuSRR/Tpp5/q9ttvL7Kv3W6X3W6/4noBAEDl4LKZGy8vL0VERCg+Pt6pPT4+Xh06dLjkegsXLtSQIUP0ySefqHdvLroEAADOXDZzI0njxo3TwIEDFRkZqaioKH3wwQdKSUnR8OHDJf15Suno0aOaP3++pD+DzaBBg/T222/r5ptvdsz6VKlSRX5+fi4bBwAAqDhcGm4GDBig48ePa8qUKUpNTVV4eLhWrlypkJAQSVJqaqrTM2/ef/99XbhwQSNGjNCIESMc7YMHD9a8efPKu3wAAFABufQ5N67Ac25QGfCcGwBwVimecwMAAHA1EG4AAIClEG4AAIClEG4AAIClEG4AAIClEG4AAIClEG4AAIClEG4AAIClEG4AAIClEG4AAIClEG4AAIClEG4AAIClEG4AAIClEG4AAIClEG4AAIClEG4AAIClEG4AAIClEG4AAIClEG4AAIClEG4AAIClEG4AAIClEG4AAIClEG4AAIClEG4AAIClEG4AAIClEG4AAIClEG4AAIClEG4AAIClEG4AAIClEG4AAIClEG4AAIClEG4AAIClEG4AAIClEG4AAIClEG4AAIClEG4AAIClEG4AAIClEG4AAIClEG4AAIClEG4AAICluDzczJw5U2FhYfL29lZERIQ2bdpUZP8NGzYoIiJC3t7eatiwod57771yqhQAAFQGLg03cXFxGjNmjF544QVt375dHTt2VExMjFJSUgrtf/DgQfXq1UsdO3bU9u3b9fzzz2vUqFFavHhxOVcOAAAqKpsxxrhq5+3bt1e7du00a9YsR1vz5s3Vr18/xcbGFuj/3HPPafny5UpOTna0DR8+XN9//70SExOLtc+srCz5+fkpMzNTvr6+Vz6Ii4SO/6LMt4lrz6GpvV1dAgBUKCX5/e2ymZucnBwlJSUpOjraqT06OloJCQmFrpOYmFigf48ePbR161adP3/+qtUKAAAqDw9X7TgjI0O5ubkKCAhwag8ICFBaWlqh66SlpRXa/8KFC8rIyFDdunULrJOdna3s7GzHcmZmpqQ/E+DVkJf9x1XZLq4tV+vzCQCVVf6/i8U54eSycJPPZrM5LRtjCrRdrn9h7fliY2M1efLkAu3BwcElLRUoN37TXV0BAFRMp06dkp+fX5F9XBZuatWqJXd39wKzNOnp6QVmZ/IFBgYW2t/Dw0M1a9YsdJ0JEyZo3LhxjuW8vDz9/vvvqlmzZpEhqrxlZWUpODhYR44cuSrXAlUUjNNaGKe1XAvjvBbGKFlznMYYnTp1SkFBQZft67Jw4+XlpYiICMXHx+tvf/uboz0+Pl533nlnoetERUVpxYoVTm2rV69WZGSkPD09C13HbrfLbrc7tV133XVXVvxV5Ovra5kPYlEYp7UwTmu5FsZ5LYxRst44Lzdjk8+lt4KPGzdOs2fP1pw5c5ScnKyxY8cqJSVFw4cPl/TnrMugQYMc/YcPH67Dhw9r3LhxSk5O1pw5c/TRRx/p6aefdtUQAABABePSa24GDBig48ePa8qUKUpNTVV4eLhWrlypkJAQSVJqaqrTM2/CwsK0cuVKjR07VjNmzFBQUJDeeecd3X333a4aAgAAqGBcfkHxE088oSeeeKLQ9+bNm1egrVOnTtq2bdtVrqr82e12TZw4scApNKthnNbCOK3lWhjntTBG6doZ56W49CF+AAAAZc3l3y0FAABQlgg3AADAUgg3AADAUgg3AADAUgg3ZSw0NFQ2m83pNX78eKc+KSkp6tu3r3x8fFSrVi2NGjVKOTk5Tn127dqlTp06qUqVKqpXr56mTJlS4Ps0NmzYoIiICHl7e6thw4Z67733CtSzePFitWjRQna7XS1atNDSpUvLftAlMHPmTIWFhcnb21sRERHatGmTS+vJN2nSpALHLTAw0PG+MUaTJk1SUFCQqlSpos6dO2vPnj1O28jOztaTTz6pWrVqycfHR3fccYd++eUXpz4nTpzQwIED5efnJz8/Pw0cOFAnT5506lOcz0dxbdy4UX379lVQUJBsNpuWLVvm9H5FG1dxPvelGeeQIUMKHN+bb765Uo0zNjZWN954o6pXr646deqoX79+2rdvn1MfKxzP4ozTCsdz1qxZat26teMhe1FRUfryyy8d71vhWLqUQZkKCQkxU6ZMMampqY7XqVOnHO9fuHDBhIeHmy5dupht27aZ+Ph4ExQUZEaOHOnok5mZaQICAsx9991ndu3aZRYvXmyqV69u3njjDUefAwcOmKpVq5rRo0ebvXv3mg8//NB4enqazz77zNEnISHBuLu7m1deecUkJyebV155xXh4eJjNmzeXzw/jIosWLTKenp7mww8/NHv37jWjR482Pj4+5vDhwy6p568mTpxoWrZs6XTc0tPTHe9PnTrVVK9e3SxevNjs2rXLDBgwwNStW9dkZWU5+gwfPtzUq1fPxMfHm23btpkuXbqYNm3amAsXLjj69OzZ04SHh5uEhASTkJBgwsPDTZ8+fRzvF+fzURIrV640L7zwglm8eLGRZJYuXer0fkUaV3E+96Ud5+DBg03Pnj2dju/x48ed+lT0cfbo0cPMnTvX7N692+zYscP07t3bNGjQwJw+fdrRxwrHszjjtMLxXL58ufniiy/Mvn37zL59+8zzzz9vPD09ze7duy1zLF2JcFPGQkJCzFtvvXXJ91euXGnc3NzM0aNHHW0LFy40drvdZGZmGmOMmTlzpvHz8zPnzp1z9ImNjTVBQUEmLy/PGGPMs88+a5o1a+a07WHDhpmbb77Zsdy/f3/Ts2dPpz49evQw9913X6nHdyVuuukmM3z4cKe2Zs2amfHjx7uknr+aOHGiadOmTaHv5eXlmcDAQDN16lRH27lz54yfn5957733jDHGnDx50nh6eppFixY5+hw9etS4ubmZVatWGWOM2bt3r5HkFC4TExONJPPDDz8YY4r3+Siti3/pV7RxFedzX5pxGvPnL8M777zzkutUxnGmp6cbSWbDhg3GGOsez4vHaYw1j6cxxtSoUcPMnj3bsseyPHFa6ip49dVXVbNmTd1www16+eWXnab3EhMTFR4e7vTFXz169FB2draSkpIcfTp16uT08KUePXro2LFjOnTokKNPdHS003579OihrVu36vz580X2SUhIKNPxFkdOTo6SkpIK1BMdHe2Segqzf/9+BQUFKSwsTPfdd58OHDggSTp48KDS0tKcarfb7erUqZOj9qSkJJ0/f96pT1BQkMLDwx19EhMT5efnp/bt2zv63HzzzfLz83Pqc7nPR1mpaOMqzuf+Sqxfv1516tRRkyZN9Oijjyo9Pd3xXmUcZ2ZmpiTJ399fknWP58XjzGel45mbm6tFixbpzJkzioqKsuyxLE+EmzI2evRoLVq0SOvWrdPIkSM1ffp0pycwp6WlFfjW8xo1asjLy8vxjeeF9clfvlyfCxcuKCMjo8g+F3+zennIyMhQbm5uhannYu3bt9f8+fP11Vdf6cMPP1RaWpo6dOig48ePO+orqva0tDR5eXmpRo0aRfapU6dOgX3XqVOnyON68eejrFS0cRXnc19aMTExWrBggdauXatp06Zpy5Yt6tq1q7KzsyvlOI0xGjdunG699VaFh4c7rWul41nYOCXrHM9du3apWrVqstvtGj58uJYuXaoWLVpY8liWN5d//UJlMGnSJE2ePLnIPlu2bFFkZKTGjh3raGvdurVq1Kihe+65xzGbI0k2m63A+sYYp/aL+5j/78KtsuhT2P7LS0WrJ19MTIzjz61atVJUVJQaNWqkjz/+2HGhYmlqv9xxLW2fslSRxlWcz3RpDBgwwPHn8PBwRUZGKiQkRF988YXuuuuuS65XUcc5cuRI7dy5U998802B96x0PC81Tqscz6ZNm2rHjh06efKkFi9erMGDB2vDhg1FbreyHsvyxsxNMYwcOVLJyclFvv76v4q/yv/F+NNPP0mSAgMDCyTdEydO6Pz5844kXFif/CnXy/Xx8PBwhKhL9bk4gZeHWrVqyd3dvcLUczk+Pj5q1aqV9u/f77hrqqjaAwMDlZOToxMnThTZ59dffy2wr99++63I43rx56OsVLRxFedzX1bq1q2rkJAQ7d+/37HvyjLOJ598UsuXL9e6detUv359R7vVjuelxlmYyno8vby81Lhx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", + "text/plain": [ + "
" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "%sqlplot histogram --table taxi --column trip_distance" + ] + }, + { + "cell_type": "code", + "execution_count": 20, + "metadata": { + "tags": [] + }, + "outputs": [ + { + "data": { + "text/plain": [ + "" + ] + }, + "execution_count": 20, + "metadata": {}, + "output_type": "execute_result" + }, + { + "data": { + "image/png": "iVBORw0KGgoAAAANSUhEUgAAAlYAAAGxCAYAAACgDPi4AAAAOXRFWHRTb2Z0d2FyZQBNYXRwbG90bGliIHZlcnNpb24zLjcuMSwgaHR0cHM6Ly9tYXRwbG90bGliLm9yZy/bCgiHAAAACXBIWXMAAA9hAAAPYQGoP6dpAABL8klEQVR4nO3de1hVZd7/8Q8gbBFxJyonRSFJy0ArLZUiJPM0ohFaM+rDoz2di8zUGm1+TeqUNKY2TY0dZpqsptGuUbQ0M/EYjmg+lilpZiXiAcQIwSPo5v790cOatqDidgmC79d17etir/Xda333ttgf7rXWvbyMMUYAAAC4YN513QAAAEBDQbACAACwCcEKAADAJgQrAAAAmxCsAAAAbEKwAgAAsAnBCgAAwCYEKwAAAJsQrAAAAGxCsALqodmzZ8vLy8tt2axZszR79uzz2s7q1avl5eWl1atX29dcDYwaNUqRkZFuy7y8vDRp0qTz2s6SJUvO+zWXil69emnUqFHnrPvpp5/0m9/8RsHBwfLy8lJycvJF7+1CREZGuv2bbNu2TZMmTVJubu5F3/ekSZOq/H9R088ZsEujum4AgD1mzZqlli1bnteXyA033KDs7Gx16tTp4jVWQ9nZ2WrTps15vWbJkiX6y1/+Um/DVU384Q9/0IIFC/T3v/9d7du3V1BQUF23dF62bdumyZMnq1evXlXCtN3uu+8+9e/f/6LuAzgXghVwGTp58qS8vLzUrFkz9ejRo67bkaRLpo9LTU5Ojtq3b68RI0actc7lcunUqVNyOBy11Nmlp02bNucdzgG7cSgQaAAiIyP19ddfa82aNfLy8pKXl5c1OlB5uO+9997TuHHj1Lp1azkcDn333XfVHgocNWqUmjZtqq+//lq9e/dWQECAWrVqpbS0NB07duy8e5s9e7Y6duwoh8Oha665Ru+++261dacfCjx27JjGjx+vqKgoNW7cWEFBQerWrZvmzJlj9fmXv/zFem3lo/KQ01/+8hfdeuutCg4OVkBAgGJjYzVt2jSdPHnSbb+9evVSTEyMNm7cqPj4eDVp0kRXXnmlXnjhBVVUVLjVHjp0SOPGjdOVV14ph8Oh4OBg/epXv9I333xj1ZSXl+u5557T1VdfLYfDoVatWumee+7RwYMHz+tzy83NlZeXl5YvX67t27db72/16tXWumnTpum5555TVFSUHA6HVq1aJUn66KOP1LNnTzVp0kSBgYHq06ePsrOz3bZfedhsy5Ytuuuuu+R0OhUUFKSxY8fq1KlT2rFjh/r376/AwEBFRkZq2rRp59W/9PO//V133SVJSkxMtN5D5SHrzMxM3XHHHWrTpo0aN26s6OhoPfjgg/rxxx+tbZw4cULXX3+9oqOjVVJSYi0vKChQaGioevXqJZfL5faegDplANR7X3zxhbnyyivN9ddfb7Kzs012drb54osvjDHGrFq1ykgyrVu3NkOHDjUfffSRWbx4sSkqKrLWrVq1ytrWyJEjjZ+fn2nbtq15/vnnzbJly8ykSZNMo0aNTFJS0nn19fbbbxtJ5o477jCLFi0y//jHP0x0dLSJiIgw7dq1c6uVZJ599lnr+YMPPmiaNGliZs6caVatWmUWL15sXnjhBfPKK68YY4z57rvvzNChQ40k6z1nZ2ebEydOGGOMeeKJJ8xrr71mli5dalauXGleeukl07JlS3PPPfe47TchIcG0aNHCXHXVVeb11183mZmZ5pFHHjGSzDvvvGPVlZaWmmuvvdYEBASYKVOmmE8//dTMnz/fPP7442blypXGGGNcLpfp37+/CQgIMJMnTzaZmZnmb3/7m2ndurXp1KmTOXbsWI0/uxMnTpjs7Gxz/fXXmyuvvNJ6fyUlJWbXrl3Wv2liYqKZN2+eWbZsmdm1a5d5//33jSTTt29fs3DhQvPBBx+Yrl27Gj8/P5OVlWVt/9lnnzWSTMeOHc0f/vAHk5mZaZ566ikjyaSlpZmrr77a/PnPfzaZmZnmnnvuMZLM/Pnza9y/McYUFhaaqVOnGknmL3/5i/UeCgsLjTHGvPbaayY9Pd189NFHZs2aNeadd94xXbp0MR07djTl5eXWdr799lsTGBhoUlJSrM/5tttuM8HBwWb//v1V3hNQl/gvEGggrr32WpOQkFBleWV4uvXWW8+47vRgJcm8/PLLbrXPP/+8kWTWrl1bo35cLpcJDw83N9xwg6moqLCW5+bmGl9f33MGq5iYGJOcnHzWfTz66KM1+iJ1uVzm5MmT5t133zU+Pj7mp59+stYlJCQYSWbDhg1ur+nUqZPp16+f9XzKlClGksnMzDzjfubMmVNtANm4caORZGbNmnXOXk+XkJBgrr32WrdllcGqffv2bgGk8jOPjY01LpfLWn748GETHBxs4uLirGWVIWTGjBlu277uuuuMJJORkWEtO3nypGnVqpUVbM7Hv/71ryr/jVWnoqLCnDx50uzevdtIMh9++KHb+g8++MBIMn/605/M73//e+Pt7W2WLVvmVkOwwqWAQ4HAZWLIkCHnVX/6OT3Dhw+XJOtw07ns2LFD+/fv1/Dhw90Oz7Rr105xcXHnfP1NN92kTz75RBMmTNDq1at1/Pjx8+he+vLLLzV48GC1aNFCPj4+8vX11X//93/L5XLp22+/dasNDQ3VTTfd5Lasc+fO2r17t/X8k08+UYcOHXT77befcZ+LFy/WFVdcoUGDBunUqVPW47rrrlNoaKjtV18OHjxYvr6+1vPKzzw1NVXe3v/59d60aVMNGTJE69evr3I4Nykpye35NddcIy8vLw0YMMBa1qhRI0VHR7t9HnYoLCzUQw89pIiICDVq1Ei+vr5q166dJGn79u1utXfffbcefvhhPfnkk3ruuef09NNPq0+fPrb2A9iBYAVcJsLCwmpc26hRI7Vo0cJtWWhoqCSpqKioRtuorKt8XXXbOps///nP+u1vf6uFCxcqMTFRQUFBSk5O1s6dO8/52ry8PMXHx2vfvn16+eWXlZWVpY0bN1rnZJ0e0k5/r5LkcDjc6g4ePHjOE6MPHDigQ4cOyc/PT76+vm6PgoICt3OH7HD6v2nlZ17dv3V4eLgqKipUXFzstvz0qwz9/PzUpEkTNW7cuMryEydO2NG2JKmiokJ9+/ZVRkaGnnrqKa1YsUKff/651q9fL6nqv5Ek/c///I9OnjypRo0aafTo0bb1AtiJqwKBy8T5nNR76tQpFRUVuQWOgoICSdWHkOpU1lW+7peqW3a6gIAATZ48WZMnT9aBAwes0atBgwa5nSxenYULF+ro0aPKyMiwRkAkafPmzTXqvTqtWrXS3r17z1rTsmVLtWjRQkuXLq12fWBgoMf7r87p/6aVn3l+fn6V2v3798vb21vNmze3tQdP5eTk6KuvvtLs2bM1cuRIa/l3331Xbf3Ro0eVmpqqDh066MCBA7rvvvv04Ycf1la7QI0xYgU0EKePsFyo999/3+35P//5T0k/X0VXEx07dlRYWJjmzJkjY4y1fPfu3Vq3bt159RISEqJRo0Zp2LBh2rFjh3U4q3JqgdPfd2Xg+OXUA8YY/fWvfz2v/f7SgAED9O2332rlypVnrElKSlJRUZFcLpe6detW5dGxY0eP918THTt2VOvWrfXPf/7T7TM/evSo5s+fb10pWJvO599Ikt54441qt/PQQw8pLy9PGRkZeuutt/TRRx/ppZdeuggdAxeGESuggYiNjdXcuXP1wQcf6Morr1Tjxo0VGxvr0bb8/Pw0Y8YMHTlyRDfeeKPWrVun5557TgMGDNAtt9xSo214e3vrD3/4g+677z7deeeduv/++3Xo0CFNmjSpRocCu3fvrqSkJHXu3FnNmzfX9u3b9d5777mFg8r398c//lEDBgyQj4+POnfurD59+sjPz0/Dhg3TU089pRMnTui1116rchjsfIwZM0YffPCB7rjjDk2YMEE33XSTjh8/rjVr1igpKUmJiYn6zW9+o/fff1+/+tWv9Pjjj+umm26Sr6+v9u7dq1WrVumOO+7QnXfe6XEP5+Lt7a1p06ZpxIgRSkpK0oMPPqiysjK9+OKLOnTokF544YWLtu8ziYmJkSS9+eabCgwMVOPGjRUVFaWrr75a7du314QJE2SMUVBQkBYtWqTMzMwq2/jb3/6mf/zjH3r77bd17bXX6tprr1VaWpp++9vf6uabb65yfhxQp+r23HkAdsnNzTV9+/Y1gYGBRpJ11V3llX//+te/qrzmTFcFBgQEmC1btphevXoZf39/ExQUZB5++GFz5MiR8+7rb3/7m7nqqquMn5+f6dChg/n73/9uRo4cec6rAidMmGC6detmmjdvbhwOh7nyyivNE088YX788UerpqyszNx3332mVatWxsvLy0gyu3btMsYYs2jRItOlSxfTuHFj07p1a/Pkk0+aTz75pMr7re6qu8rP4fQei4uLzeOPP27atm1rfH19TXBwsBk4cKD55ptvrJqTJ0+a6dOnW/tu2rSpufrqq82DDz5odu7ced6f39muCnzxxRerfc3ChQtN9+7dTePGjU1AQIDp3bu3+fe//+1WU3kF3cGDB6u874CAgBr1UVN/+tOfTFRUlPHx8TGSzNtvv22MMWbbtm2mT58+JjAw0DRv3tzcddddJi8vz+2/hS1bthh/f38zcuRIt22eOHHCdO3a1URGRpri4mK39wTUJS9jfjFeDOCyN2rUKM2bN09Hjhyp61YAoN7hHCsAAACbcI4VgPNWUVFR5XYvp2vUiF8vAC4/HAoEcN5GjRqld95556w1/GoBcDkiWAE4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vfarr77S8ePHrfCxfv16NW3aVG3atDnr66Kjo+Xr66v169db4aO4uFjffvutEhISzvg6f39/DR48WIMHD9ajjz6qq6++Wlu3btUNN9xQ7fvIysrSHXfcof/6r/+S9HMI2rlzp6655poav8fOnTtr7969+vbbb6sdtbrhhhv09ddfKzo6usbbBOA5DgUCuKRFRkZqw4YNys3N1Y8//miN1NREeXm57r33Xm3btk2ffPKJnn32WaWlpcnb++y/+po2bap7771XTz75pFasWKGcnByNGjXqrK+bPXu23nrrLeXk5OiHH37Qe++9J39/f7Vr1856H5999pn27dunH3/8UdLPAS4zM1Pr1q3T9u3b9eCDD6qgoKDG70+SEhISdOutt2rIkCHKzMzUrl279Mknn2jp0qWSpN/+9rfKzs7Wo48+qs2bN2vnzp366KOP9Nhjj53XfgDUDMEKwCVt/Pjx8vHxUadOndSqVasq5x+dTe/evXXVVVfp1ltv1d13361BgwZVmargTF588UXdeuutGjx4sG6//Xbdcsst6tq16xnrr7jiCv31r3/VzTffrM6dO2vFihVatGiRWrRoIUmaMmWKcnNz1b59e2v+q2eeeUY33HCD+vXrp169eik0NFTJyck1fn+V5s+frxtvvFHDhg1Tp06d9NRTT1mjY507d9aaNWu0c+dOxcfH6/rrr9czzzyjsLCw894PgHPzMp6cTAAAl7hRo0bp0KFDWrhwYV23AuAywogVAACATQhWAC47eXl5btMPnP44n8ONAPBLHAoEcNk5deqUcnNzz7g+MjJSjRpx0TSA80ewAgAAsAmHAgEAAGxCsAIAALAJwQoAAMAmBCsAAACbEKwAAABsQrACAACwCcEKAADAJv8fWhP1y4pXsegAAAAASUVORK5CYII=", + "text/plain": [ + "
" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "%sqlplot boxplot --table taxi --column trip_distance" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Executing Meta-Commands\n", + "\n", + "JupySQL supports `psql`-style \\\"backslash\\\" [meta-commands](https://www.postgresql.org/docs/9.6/static/app-psql.html#APP-PSQL-META-COMMANDS) (``\\d``, ``\\dt``, etc.). To run these, [PGSpecial](https://pypi.python.org/pypi/pgspecial) must be installed— information on how to do so can be found [here](../howto/postgres-install.md#installing-pgspecial). Example:" + ] + }, + { + "cell_type": "code", + "execution_count": 14, + "metadata": { + "tags": [] + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "* postgresql://user:***@localhost/db\n", + "1 rows affected.\n" + ] + }, + { + "data": { + "text/html": [ + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
SchemaNameTypeOwner
publictaxitableuser
" + ], + "text/plain": [ + "[('public', 'taxi', 'table', 'user')]" + ] + }, + "execution_count": 14, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "%sql \\dt" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Clean up\n", + "\n", + "To stop and remove the container:" + ] + }, + { + "cell_type": "code", + "execution_count": 4, + "metadata": { + "tags": [] + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES\n", + "4a6478b19d45 mariadb:latest \"docker-entrypoint.s…\" 21 minutes ago Up 21 minutes 0.0.0.0:3306->3306/tcp mariadb\n" + ] + } + ], + "source": [ + "! docker container ls" + ] + }, + { + "cell_type": "code", + "execution_count": 5, + "metadata": { + "tags": [] + }, + "outputs": [], + "source": [ + "%%capture out\n", + "! docker container ls --filter ancestor=postgres --quiet" + ] + }, + { + "cell_type": "code", + "execution_count": 6, + "metadata": { + "tags": [] + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Container id: \n" + ] + } + ], + "source": [ + "container_id = out.stdout.strip()\n", + "print(f\"Container id: {container_id}\")" + ] + }, + { + "cell_type": "code", + "execution_count": 7, + "metadata": { + "tags": [] + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "\"docker container stop\" requires at least 1 argument.\n", + "See 'docker container stop --help'.\n", + "\n", + "Usage: docker container stop [OPTIONS] CONTAINER [CONTAINER...]\n", + "\n", + "Stop one or more running containers\n" + ] + } + ], + "source": [ + "! docker container stop {container_id}" + ] + }, + { + "cell_type": "code", + "execution_count": 8, + "metadata": { + "tags": [] + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "\"docker container rm\" requires at least 1 argument.\n", + "See 'docker container rm --help'.\n", + "\n", + "Usage: docker container rm [OPTIONS] CONTAINER [CONTAINER...]\n", + "\n", + "Remove one or more containers\n" + ] + } + ], + "source": [ + "! docker container rm {container_id}" + ] + }, + { + "cell_type": "code", + "execution_count": 9, + "metadata": { + "tags": [] + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES\n", + "4a6478b19d45 mariadb:latest \"docker-entrypoint.s…\" 21 minutes ago Up 21 minutes 0.0.0.0:3306->3306/tcp mariadb\n" + ] + } + ], + "source": [ + "! docker container ls" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3 (ipykernel)", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.10.11" + }, + "myst": { + "html_meta": { + "description lang=en": "Query a PostgreSQL database from Jupyter via JupySQL", + "keywords": "jupyter, sql, jupysql, postgres", + "property=og:locale": "en_US" + } + } + }, + "nbformat": 4, + "nbformat_minor": 4 +} diff --git a/doc/integrations/questdb.ipynb b/doc/integrations/questdb.ipynb new file mode 100644 index 000000000..87e37459e --- /dev/null +++ b/doc/integrations/questdb.ipynb @@ -0,0 +1,597 @@ +{ + "cells": [ + { + "attachments": {}, + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# QuestDB\n", + "This tutorial will show you how to get a QuestDB instance up and running locally to test JupySQL. You can run this in a Jupyter notebook." + ] + }, + { + "attachments": {}, + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Pre-requisites\n", + "\n", + "To run this tutorial, you need to install following Python packages:" + ] + }, + { + "cell_type": "code", + "execution_count": 30, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Note: you may need to restart the kernel to use updated packages.\n" + ] + } + ], + "source": [ + "%pip install jupysql pandas pyarrow --quiet" + ] + }, + { + "attachments": {}, + "cell_type": "markdown", + "metadata": {}, + "source": [ + "You also need a PostgreSQL connector. We recommend using `psycopg2`. The easiest way to install it is via:" + ] + }, + { + "cell_type": "code", + "execution_count": 31, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Note: you may need to restart the kernel to use updated packages.\n" + ] + } + ], + "source": [ + "%pip install psycopg2-binary --quiet" + ] + }, + { + "attachments": {}, + "cell_type": "markdown", + "metadata": {}, + "source": [ + "You also need Docker installed and running to start the QuestDB instance." + ] + }, + { + "attachments": {}, + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Start QuestDB instance\n", + "\n", + "We fetch the official image, create a new database, and user (this will take 1-2 minutes):" + ] + }, + { + "cell_type": "code", + "execution_count": 32, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "0322c413699420adb1ccb136bc602d0a6514276df34778c90e60cf423ab8aac6\n" + ] + } + ], + "source": [ + "%%bash\n", + "docker run --detach --name questdb_ \\\n", + " -p 9000:9000 -p 9009:9009 -p 8812:8812 -p 9003:9003 questdb/questdb:7.1" + ] + }, + { + "attachments": {}, + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Our database is running, let's load some data!" + ] + }, + { + "attachments": {}, + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Load sample data\n", + "\n", + "Now, let's fetch some sample data. We'll be using the [Penguins dataset](https://raw.githubusercontent.com/mwaskom/seaborn-data/master/penguins.csv):" + ] + }, + { + "cell_type": "code", + "execution_count": 33, + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "('penguins.csv', )" + ] + }, + "execution_count": 33, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "import urllib.request\n", + "\n", + "urllib.request.urlretrieve(\n", + " \"https://raw.githubusercontent.com/mwaskom/seaborn-data/master/penguins.csv\",\n", + " \"penguins.csv\",\n", + ")" + ] + }, + { + "attachments": {}, + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Let's create a new table `penguins` in our QuestDB instance and load this csv file into it (this will take about a minute)" + ] + }, + { + "cell_type": "code", + "execution_count": 34, + "metadata": {}, + "outputs": [], + "source": [ + "import requests\n", + "\n", + "with open(\"penguins.csv\", \"rb\") as csv:\n", + " file_data = csv.read()\n", + " files = {\"data\": (\"penguins\", file_data)}\n", + " response = requests.post(\"http://127.0.0.1:9000/imp\", files=files)" + ] + }, + { + "attachments": {}, + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Query\n", + "\n", + "Now, let's start JupySQL, authenticate and start querying the data!" + ] + }, + { + "cell_type": "code", + "execution_count": 35, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "The sql extension is already loaded. To reload it, use:\n", + " %reload_ext sql\n" + ] + } + ], + "source": [ + "%load_ext sql" + ] + }, + { + "attachments": {}, + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Create a new connection using `psycopg2`" + ] + }, + { + "cell_type": "code", + "execution_count": 36, + "metadata": {}, + "outputs": [], + "source": [ + "import psycopg2 as pg\n", + "\n", + "engine = pg.connect(\n", + " \"dbname='qdb' user='admin' host='127.0.0.1' port='8812' password='quest'\"\n", + ")" + ] + }, + { + "attachments": {}, + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Initialize the connection" + ] + }, + { + "cell_type": "code", + "execution_count": 37, + "metadata": {}, + "outputs": [], + "source": [ + "%sql engine" + ] + }, + { + "attachments": {}, + "cell_type": "markdown", + "metadata": {}, + "source": [ + "```{important}\n", + "If the cell above fails, you might have some missing packages. Message us on [Slack](https://ploomber.io/community) and we'll help you!\n", + "```" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "```{note} \n", + "QuestDB now supports a connection string via [sqlalchemy](https://github.com/questdb/questdb/pull/3080#issuecomment-1478334048):\n", + "\n", + "`%sql postgresql+psycopg2://admin:quest@localhost:8812/qdb` \n", + "```" + ] + }, + { + "attachments": {}, + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Let's run our first queries to count and fetch some data" + ] + }, + { + "cell_type": "code", + "execution_count": 38, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "* \"\"\n", + "1 rows affected.\n" + ] + }, + { + "data": { + "text/html": [ + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
count
344
" + ], + "text/plain": [ + "[(344,)]" + ] + }, + "execution_count": 38, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "%%sql\n", + "SELECT COUNT(*) FROM penguins" + ] + }, + { + "cell_type": "code", + "execution_count": 39, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "* \"\"\n", + "5 rows affected.\n" + ] + }, + { + "data": { + "text/html": [ + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
speciesislandbill_length_mmbill_depth_mmflipper_length_mmbody_mass_gsex
AdelieTorgersen39.118.71813750MALE
AdelieTorgersen39.517.41863800FEMALE
AdelieTorgersen40.318.01953250FEMALE
AdelieTorgersenNoneNoneNoneNoneNone
AdelieTorgersen36.719.31933450FEMALE
" + ], + "text/plain": [ + "[('Adelie', 'Torgersen', 39.1, 18.7, 181, 3750, 'MALE'),\n", + " ('Adelie', 'Torgersen', 39.5, 17.4, 186, 3800, 'FEMALE'),\n", + " ('Adelie', 'Torgersen', 40.3, 18.0, 195, 3250, 'FEMALE'),\n", + " ('Adelie', 'Torgersen', None, None, None, None, None),\n", + " ('Adelie', 'Torgersen', 36.7, 19.3, 193, 3450, 'FEMALE')]" + ] + }, + "execution_count": 39, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "%sql select * from penguins limit 5" + ] + }, + { + "attachments": {}, + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Plotting\n", + "\n", + "To utilize JupySQL ggplot API, it is crucial to have valid data, so let's remove null values." + ] + }, + { + "cell_type": "code", + "execution_count": 40, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "* \"\"\n", + "Skipping execution...\n" + ] + } + ], + "source": [ + "%%sql --save no_nulls --no-execute\n", + "SELECT *\n", + "FROM penguins\n", + "WHERE body_mass_g IS NOT NULL and\n", + "sex IS NOT NULL" + ] + }, + { + "cell_type": "code", + "execution_count": 41, + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "" + ] + }, + "execution_count": 41, + "metadata": {}, + "output_type": "execute_result" + }, + { + "data": { + "image/png": "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oTVr1ig3N1fh4eF6/fXX5e3tXeJ6DcPQnDlz9NZbbykjI0O9evXS0qVLFRQUVBW7BTjcwvgDVbat6f3alntMnz591KVLFy1atKjY5QEBAZo2bZqmTZsm6ffX2q9bt06DBg1SamqqWrVqpe+++05dunQpdTvbtm1TaGiozp07p4YNG5a7zsp2tf0GUHUcegYoISFBkyZNUmJiouLj43X58mX1799fOTk51j7Tp0/Xf//7X33wwQdKSEjQiRMndO+995a63pdfflmvvfaali1bpp07d6p+/foKDw/XpUuX7L1LACrBt99+q/Hjxzu6jArbtm2bLBaLMjIyHF0KgBI49AzQ5s2bbebj4uLUtGlT7d69W7fffrsyMzP19ttva/Xq1brzzjslSStWrFD79u2VmJioW2+9tcg6DcPQokWL9Oyzz2rgwIGSpHfeeUfe3t5av369HnzwQfvvGIBr4uXl5egSANRwTnUPUGZmpiSpcePGkqTdu3fr8uXLCgsLs/Zp166dWrRooR07dhS7jpSUFKWnp9uM8fT0VI8ePUock5ubq6ysLJsJgH399ttvioyMlKenp5o0aaLZs2fLMAxJv18Cs9dloq+++kq9e/dW3bp15e/vrylTpticdQ4ICNCLL76oMWPGqEGDBmrRooXefPNNm3Vs375dXbp0kbu7u7p166b169fLYrEoKSlJqampCg0NlSQ1atRIFotFo0aNso4tKCjQ3/72NzVu3Fg+Pj6Kjo4uc+0Wi0VvvPGG/vKXv6hevXpq3769duzYoUOHDqlPnz6qX7++evbsqcOHD1vHREdHq0uXLlq+fLlatGih6667To8//rjy8/P18ssvy8fHR02bNtXf//73ih1QoJpymvcAFRQUaNq0aerVq5duvPFGSVJ6errq1KlT5Nq9t7e30tPTi11PYfuV9wiVNiYmJkZz5869xj0wsa0xv/8ZGuXYOlCtrFy5UmPHjtU333yjXbt2afz48WrRooXGjRtnt20ePnxYAwYM0AsvvKDly5frl19+UWRkpCIjI7VixQprv1deeUXPP/+8nn76af3nP//RxIkTdccddyg4OFhZWVm65557dNddd2n16tU6evSo9V4lSfL399f//d//6b777lNycrI8PDxUt25dm/2eMWOGdu7cqR07dmjUqFHq1auX+vXrV6Z9eP7557VgwQItWLBA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+uabb/Tqq69qwYIF+te//lWp2zh27Jjuvfde3XPPPUpKStKjjz6qWbNm2fQ5fPiwBgwYoPvuu0979+7V2rVr9dVXXykyMtKm3/z589W5c2d99913mjVrlqZOnar4+HhJv1+yk6QVK1bo5MmT1vnC9a9fv14ff/yxPv74YyUkJOill14q8z6sXLlSTZo00TfffKPJkydr4sSJGjJkiHr27Kk9e/aof//+euSRR4qEmejoaC1ZskTbt2/XsWPH9MADD2jRokVavXq1PvnkE3322WdavHhxuY4nUJMQgAA4hL+/vxYuXKjg4GANHz5ckydP1sKFCyt1G0uXLlXr1q31yiuvWLczatQomz4xMTEaPny4pk2bpqCgIPXs2VOvvfaa3nnnHV269Md3YfXq1UuzZs1S27ZtNXnyZN1///3Wegsv2TVs2FA+Pj42l/AKCgoUFxenG2+8Ub1799YjjzyiLVu2lHkfOnfurGeffVZBQUGKioqSu7u7mjRponHjxikoKEjPPfeczp49q71799qMe+GFF9SrVy/ddNNNGjt2rBISErR06VLddNNN6t27t+6//35t3bq1vIcUqDEIQAAc4tZbb5XFYrHOh4SE6ODBg8rPz6+0bfz444/q0aOHTVtISIjN/Pfff6+4uDibb18PDw9XQUGBUlJSShwXEhKiH3/88ao1BAQE2HzHl6+vr06fPl3mfejUqZP1ZxcXF11//fXq2LGjtc3b21uSiqzzz+O8vb1Vr149BQYG2rSVpw6gpuEmaACmlp2drccee0xTpkwpsqxFixbXvH5XV1ebeYvFooKCgmsa/+e2whB55Tqv7HOtdQA1DQEIgEPs3LnTZj4xMVFBQUFycXGptG20b99eH330UZHt/NnNN9+sH374QW3atCl1XVeOS0xMVPv27a3zrq6ulXr2CoB9cQkMgEOkpaVpxowZSk5O1nvvvafFixdr6tSplbqNCRMm6ODBg3rqqaeUnJys1atXKy4uzqbPzJkztX37dkVGRiopKUkHDx7Uhg0bitwE/fXXX+vll1/WgQMHFBsbqw8++MCm3oCAAG3ZskXp6ek6d+5cpe4HgMrHGSCgJqoGL7McMWKELl68qO7du8vFxUVTp07V+PHjK3UbLVq00P/93/9p+vTpWrx4sbp3764XX3xRY8aMsfbp1KmTEhIS9Mwzz6h3794yDEOtW7fW0KFDbdb1xBNPaNeuXZo7d648PDy0YMEChYeHW5e/8sormjFjht566y01a9as2MftATgPi1H44g1YZWVlydPTU5mZmfLw8HB0OVWnom+C5g3SDnHp0iWlpKSoVatWcnd3d3Q5NVpAQICmTZtW6td31ESnsv54Cu5yXq5+Tjuq78656mLB1S9TTu/X1p6lAcUqz9/fXAIDAACmQwACUG1NmDDB5vH1P08TJkxwdHmlSktLK7H26667TmlpaY4uEajRuAcIQLU1b948Pfnkk8Uuq8zL1/a4n8fPz6/Ub7T38/Or9G0C+AMBCEC11bRpUzVt2tTRZVRI7dq1r/roPQD74RIYUM3xHAOqhPVzZim1G1BdEICAaqrwzb58ozeqQl7uJeUbhvIKCECoGbgEBlRTLi4uatiwofX7nOrVq2fz3VrAtbqclysZhvJyL+nsmV904kIt5RdzBijxyNkibQvjD5R5OzwyD0cgAAHVmI+Pj6SiX4QJVIasi5clSfmGoRMXaunYpToOrgioPAQgoBqzWCzy9fVV06ZNdfnyZUeXgxom7usUSRblFViKPfMDVGcEIKAGcHFxqdQvEQUklemNz0B1xU3QAADAdBwagL788kvdc8898vPzk8Vi0fr1622WWyyWYqf58+eXuM7o6Ogi/du1a2fnPQEAANWJQwNQTk6OOnfurNjY2GKXnzx50mZavny5LBaL7rvvvlLXe8MNN9iM++qrr+xRPgAAqKYceg9QRESEIiIiSlxe+IRLoQ0bNig0NFSBgYGlrrd27dpFxgIAABSqNvcAnTp1Sp988onGjh171b4HDx6Un5+fAgMDNXz48Kt+qWBubq6ysrJsJgAAUHNVmwC0cuVKNWjQQPfee2+p/Xr06KG4uDht3rxZS5cuVUpKinr37q3z58+XOCYmJkaenp7Wyd/fv7LLBwAATqTaBKDly5dr+PDhcnd3L7VfRESEhgwZok6dOik8PFwbN25URkaG3n///RLHREVFKTMz0zodO3assssHAABOpFq8B+j//b//p+TkZK1du7bcYxs2bKi2bdvq0KFDJfZxc3OTm5vbtZQIAACqkWpxBujtt99W165d1blz53KPzc7O1uHDh+Xr62uHygAAQHXk0ACUnZ2tpKQkJSUlSZJSUlKUlJRkc9NyVlaWPvjgAz366KPFrqNv375asmSJdf7J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AAA4N6c+A3Sl7OxsHT58WL6+vsUuDwkJ0ZYtW2za4uPjFRISUhXlAQCAasKpA9CTTz6phIQEpaamavv27Ro8eLBcXFw0bNgwSdKIESMUFfXHvSRTp07V5s2b9corr+inn35SdHS0du3apcjISEftAgAAcEJOfQns+PHjGjZsmM6ePSsvLy/ddtttSkxMlJeXlyQpLS1NtWr9keF69uyp1atX69lnn9XTTz+toKAgrV+/XjfeeKOjdgEAADghpw5Aa9asKXX5tm3birQNGTJEQ4YMsVNFAACgJnDqS2AAAAD2QAACAACmQwACAACmQwACAACmQwACAACmQwACAACmQwACAACmQwACAACmQwACAACmQwACAACmQwACAACm49TfBYZSbI0p/ueaonCfQqMcWwfsZmH8gTL1Szxy1s6VADAjzgABAADTIQABAADTIQABAADTIQABAADTIQABAADTIQABAADTIQABAADTIQABAADTIQABAADTIQABAADTIQABAADTIQABAADTIQABAADTIQABAADTIQABAADTIQABAADTqe3oAiBpa0zN2k5Z/LmWwp9Do0rvW9Jy1BiJR84WaTt+7oIDKjEXjnHZFHecEo8U7bcw/kCRtun92tqjJFwDzgABAADTIQABAADTIQABAADTIQABAADTceoAFBMTo1tuuUUNGjRQ06ZNNWjQICUnJ5c6Ji4uThaLxWZyd3evoooBAEB14NQBKCEhQZMmTVJiYqLi4+N1+fJl9e/fXzk5OaWO8/Dw0MmTJ63T0aNHq6hiAABQHTj1Y/CbN2+2mY+Li1PTpk21e/du3X777SWOs1gs8vHxsXd5AACgmnLqM0BXyszMlCQ1bty41H7Z2dlq2bKl/P39NXDgQO3fv7/U/rm5ucrKyrKZAABAzVVtAlBBQYGmTZumXr166cYbbyyxX3BwsJYvX64NGzZo1apVKigoUM+ePXX8+PESx8TExMjT09M6+fv722MXAACAk6g2AWjSpEnat2+f1qxZU2q/kJAQjRgxQl26dNEdd9yhDz/8UF5eXnrjjTdKHBMVFaXMzEzrdOzYscouHwAAOBGnvgeoUGRkpD7++GN9+eWXat68ebnGurq66qabbtKhQ4dK7OPm5iY3N7drLRMAAFQTTn0GyDAMRUZGat26dfriiy/UqlWrcq8jPz9f//vf/+Tr62uH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", + "text/plain": [ + "
" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "from sql.ggplot import ggplot, aes, geom_histogram\n", + "\n", + "(\n", + " ggplot(\n", + " table=\"no_nulls\",\n", + " with_=\"no_nulls\",\n", + " mapping=aes(x=[\"bill_length_mm\", \"bill_depth_mm\"]),\n", + " )\n", + " + geom_histogram(bins=50)\n", + ")" + ] + }, + { + "attachments": {}, + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Clean up" + ] + }, + { + "cell_type": "code", + "execution_count": 42, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES\n", + "0322c4136994 questdb/questdb:7.1 \"/docker-entrypoint.…\" 10 seconds ago Up 9 seconds 0.0.0.0:8812->8812/tcp, 0.0.0.0:9000->9000/tcp, 0.0.0.0:9003->9003/tcp, 0.0.0.0:9009->9009/tcp questdb_\n" + ] + } + ], + "source": [ + "! docker container ls" + ] + }, + { + "cell_type": "code", + "execution_count": 43, + "metadata": {}, + "outputs": [], + "source": [ + "%%capture out\n", + "! docker ps -a -q --filter=\"name=questdb\" --quiet" + ] + }, + { + "cell_type": "code", + "execution_count": 44, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Container id: 0322c4136994\n" + ] + } + ], + "source": [ + "container_id = out.stdout.strip()\n", + "print(f\"Container id: {container_id}\")" + ] + }, + { + "cell_type": "code", + "execution_count": 45, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "0322c4136994\n" + ] + } + ], + "source": [ + "! docker container stop {container_id}" + ] + }, + { + "cell_type": "code", + "execution_count": 46, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "0322c4136994\n" + ] + } + ], + "source": [ + "! docker container rm {container_id}" + ] + }, + { + "cell_type": "code", + "execution_count": 47, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES\n" + ] + } + ], + "source": [ + "! docker container ls" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "jupysql311", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.11.3" + }, + "orig_nbformat": 4 + }, + "nbformat": 4, + "nbformat_minor": 2 +} diff --git a/doc/integrations/redshift.ipynb b/doc/integrations/redshift.ipynb new file mode 100644 index 000000000..7ca3b00c5 --- /dev/null +++ b/doc/integrations/redshift.ipynb @@ -0,0 +1,1472 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "d22b2168-17c7-4a77-89d1-b928f3b7d41c", + "metadata": {}, + "source": [ + "# Redshift\n", + "\n", + "```{important}\n", + "`sqlalchemy-redshift` requires SQLAlchemy 1.x (as of version 0.8.14)\n", + "```\n", + "\n", + "This tutorial will show you how to use JupySQL with [Redshift](https://aws.amazon.com/redshift/), a data warehouse service provided by AWS.\n", + "\n", + "## Pre-requisites\n", + "\n", + "First, let's install the required packages." + ] + }, + { + "cell_type": "code", + "execution_count": 1, + "id": "104f9ccb-1ce8-4850-a0f0-520cf445b292", + "metadata": { + "tags": [] + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Note: you may need to restart the kernel to use updated packages.\n" + ] + } + ], + "source": [ + "%pip install jupysql sqlalchemy-redshift redshift-connector 'sqlalchemy<2' --quiet" + ] + }, + { + "cell_type": "markdown", + "id": "06edf8cd-6c65-4018-a818-47037c3ae831", + "metadata": {}, + "source": [ + "Load JupySQL:" + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "id": "ba102937-c7bd-48a4-aec0-999794519b02", + "metadata": { + "tags": [] + }, + "outputs": [ + { + "data": { + "text/html": [ + "Found pyproject.toml from '/Users/eduardo/dev/jupysql'" + ], + "text/plain": [ + "Found pyproject.toml from '/Users/eduardo/dev/jupysql'" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "%load_ext sql" + ] + }, + { + "cell_type": "markdown", + "id": "759e70ec-7afd-4508-a49a-cd3f41ca3092", + "metadata": {}, + "source": [ + "## Connect to Redshift\n", + "\n", + "Here, we create a connection and pass it to JupySQL:" + ] + }, + { + "cell_type": "code", + "execution_count": 4, + "id": "759a61b2-4c50-4a76-b1b2-85dd3d082308", + "metadata": { + "tags": [] + }, + "outputs": [], + "source": [ + "from os import environ\n", + "from sqlalchemy import create_engine\n", + "from sqlalchemy.engine import URL\n", + "\n", + "user = environ[\"REDSHIFT_USERNAME\"]\n", + "password = environ[\"REDSHIFT_PASSWORD\"]\n", + "host = environ[\"REDSHIFT_HOST\"]\n", + "\n", + "url = URL.create(\n", + " drivername=\"redshift+redshift_connector\",\n", + " username=user,\n", + " password=password,\n", + " host=host,\n", + " port=5439,\n", + " database=\"dev\",\n", + ")\n", + "\n", + "engine = create_engine(url)" + ] + }, + { + "cell_type": "code", + "execution_count": 5, + "id": "07768b48-2c43-4fc3-a009-d87826d8b2be", + "metadata": { + "tags": [] + }, + "outputs": [], + "source": [ + "%sql engine --alias redshift-sqlalchemy" + ] + }, + { + "cell_type": "markdown", + "id": "e5c7085a-cc78-4217-9671-5027a90bf911", + "metadata": {}, + "source": [ + "## Load data\n", + "\n", + "We'll load some sample data. First, we create the table:" + ] + }, + { + "cell_type": "code", + "execution_count": 8, + "id": "2c0bf0c1-b40c-4415-8387-3df77af874ba", + "metadata": { + "tags": [] + }, + "outputs": [ + { + "data": { + "text/html": [ + "Running query in 'redshift-sqlalchemy'" + ], + "text/plain": [ + "Running query in 'redshift-sqlalchemy'" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "text/html": [ + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
\n", + "ResultSet : to convert to pandas, call .DataFrame() or to polars, call .PolarsDataFrame()
" + ], + "text/plain": [ + "++\n", + "||\n", + "++\n", + "++" + ] + }, + "execution_count": 8, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "%%sql\n", + "DROP TABLE taxi;\n", + "\n", + "CREATE TABLE taxi (\n", + " VendorID BIGINT,\n", + " tpep_pickup_datetime TIMESTAMP,\n", + " tpep_dropoff_datetime TIMESTAMP,\n", + " passenger_count DOUBLE PRECISION,\n", + " trip_distance DOUBLE PRECISION,\n", + " RatecodeID DOUBLE PRECISION,\n", + " store_and_fwd_flag VARCHAR(1),\n", + " PULocationID BIGINT,\n", + " DOLocationID BIGINT,\n", + " payment_type BIGINT,\n", + " fare_amount DOUBLE PRECISION,\n", + " extra DOUBLE PRECISION,\n", + " mta_tax DOUBLE PRECISION,\n", + " tip_amount DOUBLE PRECISION,\n", + " tolls_amount DOUBLE PRECISION,\n", + " improvement_surcharge DOUBLE PRECISION,\n", + " total_amount DOUBLE PRECISION,\n", + " congestion_surcharge DOUBLE PRECISION,\n", + " airport_fee DOUBLE PRECISION\n", + ");" + ] + }, + { + "cell_type": "markdown", + "id": "29eaffd7-49f9-4a78-8347-88001eefcf49", + "metadata": {}, + "source": [ + "Now, we use `COPY` to copy a `.parquet` file stored in an S3 bucket:\n", + "\n", + "```{admonition} Instructions to upload to S3\n", + ":class: tip, dropdown\n", + "\n", + "If you don't have existing data and a role configured, here are the commands to do it:\n", + "\n", + "Create bucket:\n", + "\n", + "~~~sh\n", + "aws s3api create-bucket --bucket {bucket-name} --region {aws-region}\n", + "~~~\n", + "\n", + "Download some sample data from [here](https://www.nyc.gov/site/tlc/about/tlc-trip-record-data.page).\n", + "\n", + "Upload to the S3 bucket:\n", + "\n", + "~~~sh\n", + "aws s3 cp path/to/data.parquet s3://{bucket-name}/data.parquet\n", + "~~~\n", + "\n", + "Create a role that allows Redshift to have S3 read access:\n", + "\n", + "~~~sh\n", + "aws iam create-role --role-name {role-name} \\\n", + " --assume-role-policy-document '{\"Version\":\"2012-10-17\",\"Statement\":[{\"Effect\":\"Allow\",\"Principal\":{\"Service\":\"redshift.amazonaws.com\"},\"Action\":\"sts:AssumeRole\"}]}'\n", + " \n", + "aws iam attach-role-policy --role-name {role-name} --policy-arn arn:aws:iam::aws:policy/AmazonS3ReadOnlyAccess\n", + "~~~\n", + "\n", + "Then, go to the Redshift console and attach the role you created to your Redshift cluster.\n", + "```\n" + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "id": "104cca47-2474-47aa-b7a5-ab358b1097d4", + "metadata": { + "tags": [] + }, + "outputs": [ + { + "name": "stderr", + "output_type": "stream", + "text": [] + } + ], + "source": [ + "%%sql\n", + "COPY taxi\n", + "FROM 's3:///some-bucket/yellow_tripdata_2023-01.parquet'\n", + "IAM_ROLE 'arn:aws:iam::XYZ:role/some-role'\n", + "FORMAT AS PARQUET;" + ] + }, + { + "cell_type": "markdown", + "id": "56790755-6ac3-45d6-b4e0-1d34150ab793", + "metadata": {}, + "source": [ + "## Query" + ] + }, + { + "cell_type": "code", + "execution_count": 6, + "id": "98e6ca1d-09d6-4676-8a01-f2e7d84111e7", + "metadata": { + "tags": [] + }, + "outputs": [ + { + "data": { + "text/html": [ + "Running query in 'redshift-sqlalchemy'" + ], + "text/plain": [ + "Running query in 'redshift-sqlalchemy'" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "text/html": [ + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
vendoridtpep_pickup_datetimetpep_dropoff_datetimepassenger_counttrip_distanceratecodeidstore_and_fwd_flagpulocationiddolocationidpayment_typefare_amountextramta_taxtip_amounttolls_amountimprovement_surchargetotal_amountcongestion_surchargeairport_fee
22023-01-01 01:11:312023-01-01 01:21:501.04.591.0N1321392-20.5-1.0-0.50.00.0-1.0-24.250.0-1.25
22023-01-01 01:11:312023-01-01 01:21:501.04.591.0N132139220.51.00.50.00.01.024.250.01.25
22023-01-01 01:06:462023-01-01 01:42:585.06.81.0N68179136.61.00.58.320.01.049.922.50.0
\n", + "ResultSet : to convert to pandas, call .DataFrame() or to polars, call .PolarsDataFrame()
" + ], + "text/plain": [ + "+----------+----------------------+-----------------------+-----------------+---------------+------------+--------------------+--------------+--------------+--------------+-------------+-------+---------+------------+--------------+-----------------------+--------------+----------------------+-------------+\n", + "| vendorid | tpep_pickup_datetime | tpep_dropoff_datetime | passenger_count | trip_distance | ratecodeid | store_and_fwd_flag | pulocationid | dolocationid | payment_type | fare_amount | extra | mta_tax | tip_amount | tolls_amount | improvement_surcharge | total_amount | congestion_surcharge | airport_fee |\n", + "+----------+----------------------+-----------------------+-----------------+---------------+------------+--------------------+--------------+--------------+--------------+-------------+-------+---------+------------+--------------+-----------------------+--------------+----------------------+-------------+\n", + "| 2 | 2023-01-01 01:11:31 | 2023-01-01 01:21:50 | 1.0 | 4.59 | 1.0 | N | 132 | 139 | 2 | -20.5 | -1.0 | -0.5 | 0.0 | 0.0 | -1.0 | -24.25 | 0.0 | -1.25 |\n", + "| 2 | 2023-01-01 01:11:31 | 2023-01-01 01:21:50 | 1.0 | 4.59 | 1.0 | N | 132 | 139 | 2 | 20.5 | 1.0 | 0.5 | 0.0 | 0.0 | 1.0 | 24.25 | 0.0 | 1.25 |\n", + "| 2 | 2023-01-01 01:06:46 | 2023-01-01 01:42:58 | 5.0 | 6.8 | 1.0 | N | 68 | 179 | 1 | 36.6 | 1.0 | 0.5 | 8.32 | 0.0 | 1.0 | 49.92 | 2.5 | 0.0 |\n", + "+----------+----------------------+-----------------------+-----------------+---------------+------------+--------------------+--------------+--------------+--------------+-------------+-------+---------+------------+--------------+-----------------------+--------------+----------------------+-------------+" + ] + }, + "execution_count": 6, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "%%sql\n", + "SELECT * FROM taxi LIMIT 3" + ] + }, + { + "cell_type": "markdown", + "id": "d41ee487-3a33-466b-a6b7-7de780b4f78f", + "metadata": {}, + "source": [ + "## Pandas/Polars integration\n", + "\n", + "```{tip}\n", + "Learn more about the [`pandas`](pandas.md) and [`polars`](polars.md) integrations.\n", + "```\n", + "\n", + "You can convert results to pandas and polars data frames" + ] + }, + { + "cell_type": "code", + "execution_count": 37, + "id": "1fd40003-ebe6-4dc2-bc4d-09c536f1a154", + "metadata": { + "tags": [] + }, + "outputs": [ + { + "data": { + "text/html": [ + "Running query in 'redshift-sqlalchemy'" + ], + "text/plain": [ + "Running query in 'redshift-sqlalchemy'" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "%%sql results <<\n", + "SELECT tpep_pickup_datetime, tpep_dropoff_datetime FROM taxi LIMIT 100" + ] + }, + { + "cell_type": "code", + "execution_count": 39, + "id": "3670b9ab-323e-44e3-8e62-02d796ced8c2", + "metadata": { + "tags": [] + }, + "outputs": [ + { + "data": { + "text/html": [ + "
\n", + "\n", + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
tpep_pickup_datetimetpep_dropoff_datetime
02023-01-01 01:30:532023-01-01 02:03:29
12023-01-01 01:28:542023-01-01 01:53:11
22023-01-01 01:54:522023-01-01 02:00:54
32023-01-01 01:25:542023-01-01 01:35:49
42023-01-01 01:54:102023-01-01 02:11:43
\n", + "
" + ], + "text/plain": [ + " tpep_pickup_datetime tpep_dropoff_datetime\n", + "0 2023-01-01 01:30:53 2023-01-01 02:03:29\n", + "1 2023-01-01 01:28:54 2023-01-01 01:53:11\n", + "2 2023-01-01 01:54:52 2023-01-01 02:00:54\n", + "3 2023-01-01 01:25:54 2023-01-01 01:35:49\n", + "4 2023-01-01 01:54:10 2023-01-01 02:11:43" + ] + }, + "execution_count": 39, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "results.DataFrame().head()" + ] + }, + { + "cell_type": "code", + "execution_count": 40, + "id": "f0ead1d2-3eae-45f8-9ddf-bec7b30c8821", + "metadata": { + "tags": [] + }, + "outputs": [ + { + "data": { + "text/html": [ + "
\n", + "shape: (5, 2)
tpep_pickup_datetimetpep_dropoff_datetime
datetime[μs]datetime[μs]
2023-01-01 01:30:532023-01-01 02:03:29
2023-01-01 01:28:542023-01-01 01:53:11
2023-01-01 01:54:522023-01-01 02:00:54
2023-01-01 01:25:542023-01-01 01:35:49
2023-01-01 01:54:102023-01-01 02:11:43
" + ], + "text/plain": [ + "shape: (5, 2)\n", + "┌──────────────────────┬───────────────────────┐\n", + "│ tpep_pickup_datetime ┆ tpep_dropoff_datetime │\n", + "│ --- ┆ --- │\n", + "│ datetime[μs] ┆ datetime[μs] │\n", + "╞══════════════════════╪═══════════════════════╡\n", + "│ 2023-01-01 01:30:53 ┆ 2023-01-01 02:03:29 │\n", + "│ 2023-01-01 01:28:54 ┆ 2023-01-01 01:53:11 │\n", + "│ 2023-01-01 01:54:52 ┆ 2023-01-01 02:00:54 │\n", + "│ 2023-01-01 01:25:54 ┆ 2023-01-01 01:35:49 │\n", + "│ 2023-01-01 01:54:10 ┆ 2023-01-01 02:11:43 │\n", + "└──────────────────────┴───────────────────────┘" + ] + }, + "execution_count": 40, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "results.PolarsDataFrame().head()" + ] + }, + { + "cell_type": "markdown", + "id": "7b071dc7-8474-409a-bdbe-f6218b6e3d78", + "metadata": {}, + "source": [ + "## List tables" + ] + }, + { + "cell_type": "code", + "execution_count": 27, + "id": "57119ec7-6507-4441-ac12-b61b9f8ea973", + "metadata": { + "tags": [] + }, + "outputs": [ + { + "data": { + "text/html": [ + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
Name
taxi
" + ], + "text/plain": [ + "+------+\n", + "| Name |\n", + "+------+\n", + "| taxi |\n", + "+------+" + ] + }, + "execution_count": 27, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "%sqlcmd tables" + ] + }, + { + "cell_type": "markdown", + "id": "9d43bb0c-6344-4460-987a-f620c0d41675", + "metadata": {}, + "source": [ + "## List columns" + ] + }, + { + "cell_type": "code", + "execution_count": 28, + "id": "51c8cd0c-ac52-4e18-81df-a1d344b97d4a", + "metadata": { + "tags": [] + }, + "outputs": [ + { + "data": { + "text/html": [ + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
nametypenullabledefaultautoincrementcommentinfo
vendoridBIGINTTrueNoneFalseNone{'encode': 'az64'}
tpep_pickup_datetimeTIMESTAMPTrueNoneFalseNone{'encode': 'az64'}
tpep_dropoff_datetimeTIMESTAMPTrueNoneFalseNone{'encode': 'az64'}
passenger_countDOUBLE_PRECISIONTrueNoneFalseNone{}
trip_distanceDOUBLE_PRECISIONTrueNoneFalseNone{}
ratecodeidDOUBLE_PRECISIONTrueNoneFalseNone{}
store_and_fwd_flagVARCHAR(1)TrueNoneFalseNone{'encode': 'lzo'}
pulocationidBIGINTTrueNoneFalseNone{'encode': 'az64'}
dolocationidBIGINTTrueNoneFalseNone{'encode': 'az64'}
payment_typeBIGINTTrueNoneFalseNone{'encode': 'az64'}
fare_amountDOUBLE_PRECISIONTrueNoneFalseNone{}
extraDOUBLE_PRECISIONTrueNoneFalseNone{}
mta_taxDOUBLE_PRECISIONTrueNoneFalseNone{}
tip_amountDOUBLE_PRECISIONTrueNoneFalseNone{}
tolls_amountDOUBLE_PRECISIONTrueNoneFalseNone{}
improvement_surchargeDOUBLE_PRECISIONTrueNoneFalseNone{}
total_amountDOUBLE_PRECISIONTrueNoneFalseNone{}
congestion_surchargeDOUBLE_PRECISIONTrueNoneFalseNone{}
airport_feeDOUBLE_PRECISIONTrueNoneFalseNone{}
" + ], + "text/plain": [ + "+-----------------------+------------------+----------+---------+---------------+---------+--------------------+\n", + "| name | type | nullable | default | autoincrement | comment | info |\n", + "+-----------------------+------------------+----------+---------+---------------+---------+--------------------+\n", + "| vendorid | BIGINT | True | None | False | None | {'encode': 'az64'} |\n", + "| tpep_pickup_datetime | TIMESTAMP | True | None | False | None | {'encode': 'az64'} |\n", + "| tpep_dropoff_datetime | TIMESTAMP | True | None | False | None | {'encode': 'az64'} |\n", + "| passenger_count | DOUBLE_PRECISION | True | None | False | None | {} |\n", + "| trip_distance | DOUBLE_PRECISION | True | None | False | None | {} |\n", + "| ratecodeid | DOUBLE_PRECISION | True | None | False | None | {} |\n", + "| store_and_fwd_flag | VARCHAR(1) | True | None | False | None | {'encode': 'lzo'} |\n", + "| pulocationid | BIGINT | True | None | False | None | {'encode': 'az64'} |\n", + "| dolocationid | BIGINT | True | None | False | None | {'encode': 'az64'} |\n", + "| payment_type | BIGINT | True | None | False | None | {'encode': 'az64'} |\n", + "| fare_amount | DOUBLE_PRECISION | True | None | False | None | {} |\n", + "| extra | DOUBLE_PRECISION | True | None | False | None | {} |\n", + "| mta_tax | DOUBLE_PRECISION | True | None | False | None | {} |\n", + "| tip_amount | DOUBLE_PRECISION | True | None | False | None | {} |\n", + "| tolls_amount | DOUBLE_PRECISION | True | None | False | None | {} |\n", + "| improvement_surcharge | DOUBLE_PRECISION | True | None | False | None | {} |\n", + "| total_amount | DOUBLE_PRECISION | True | None | False | None | {} |\n", + "| congestion_surcharge | DOUBLE_PRECISION | True | None | False | None | {} |\n", + "| airport_fee | DOUBLE_PRECISION | True | None | False | None | {} |\n", + "+-----------------------+------------------+----------+---------+---------------+---------+--------------------+" + ] + }, + "execution_count": 28, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "%sqlcmd columns --table taxi" + ] + }, + { + "cell_type": "markdown", + "id": "5a9b8f5c-f5c4-4e95-ab54-09d0f3122790", + "metadata": {}, + "source": [ + "## Profile a dataset" + ] + }, + { + "cell_type": "code", + "execution_count": 29, + "id": "b46f42ff-c973-48c2-a0c1-184cb8ca4c29", + "metadata": { + "tags": [] + }, + "outputs": [ + { + "data": { + "text/html": [ + "
Following statistics are not available in\n", + " redshift_connector: STD, 25%, 50%, 75%
\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
vendoridtpep_pickup_datetimetpep_dropoff_datetimepassenger_counttrip_distanceratecodeidstore_and_fwd_flagpulocationiddolocationidpayment_typefare_amountextramta_taxtip_amounttolls_amountimprovement_surchargetotal_amountcongestion_surchargeairport_fee
count3066766306676630667662995023306676629950232995023306676630667663066766306676630667663066766306676630667663066766306676629950232995023
unique21610975161131910438772257261568736810403677651587133
mean1.0000nannan1.36253.84731.4974nan166.0000164.00001.000018.36711.53780.48833.36790.51850.982127.02042.27420.1074
min1nannan0.00.01.0nan110-900.0-7.5-0.5-96.22-65.0-1.0-751.0-2.5-1.25
max2nannan9.0258928.1599.0nan26526541160.112.553.16380.8196.991.01169.42.51.25
" + ], + "text/plain": [ + "+--------+----------+----------------------+-----------------------+-----------------+---------------+------------+--------------------+--------------+--------------+--------------+-------------+---------+---------+------------+--------------+-----------------------+--------------+----------------------+-------------+\n", + "| | vendorid | tpep_pickup_datetime | tpep_dropoff_datetime | passenger_count | trip_distance | ratecodeid | store_and_fwd_flag | pulocationid | dolocationid | payment_type | fare_amount | extra | mta_tax | tip_amount | tolls_amount | improvement_surcharge | total_amount | congestion_surcharge | airport_fee |\n", + "+--------+----------+----------------------+-----------------------+-----------------+---------------+------------+--------------------+--------------+--------------+--------------+-------------+---------+---------+------------+--------------+-----------------------+--------------+----------------------+-------------+\n", + "| count | 3066766 | 3066766 | 3066766 | 2995023 | 3066766 | 2995023 | 2995023 | 3066766 | 3066766 | 3066766 | 3066766 | 3066766 | 3066766 | 3066766 | 3066766 | 3066766 | 3066766 | 2995023 | 2995023 |\n", + "| unique | 2 | 1610975 | 1611319 | 10 | 4387 | 7 | 2 | 257 | 261 | 5 | 6873 | 68 | 10 | 4036 | 776 | 5 | 15871 | 3 | 3 |\n", + "| mean | 1.0000 | nan | nan | 1.3625 | 3.8473 | 1.4974 | nan | 166.0000 | 164.0000 | 1.0000 | 18.3671 | 1.5378 | 0.4883 | 3.3679 | 0.5185 | 0.9821 | 27.0204 | 2.2742 | 0.1074 |\n", + "| min | 1 | nan | nan | 0.0 | 0.0 | 1.0 | nan | 1 | 1 | 0 | -900.0 | -7.5 | -0.5 | -96.22 | -65.0 | -1.0 | -751.0 | -2.5 | -1.25 |\n", + "| max | 2 | nan | nan | 9.0 | 258928.15 | 99.0 | nan | 265 | 265 | 4 | 1160.1 | 12.5 | 53.16 | 380.8 | 196.99 | 1.0 | 1169.4 | 2.5 | 1.25 |\n", + "+--------+----------+----------------------+-----------------------+-----------------+---------------+------------+--------------------+--------------+--------------+--------------+-------------+---------+---------+------------+--------------+-----------------------+--------------+----------------------+-------------+" + ] + }, + "execution_count": 29, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "%sqlcmd profile --table taxi" + ] + }, + { + "cell_type": "markdown", + "id": "5c82b2d3-5ebd-46f2-bc0b-2ad34737ebbf", + "metadata": {}, + "source": [ + "## Plotting\n", + "\n", + "Let's create a histogram for the `trip_distance`. Since there are outliers, we'll use the 99th percentile as a cutoff value." + ] + }, + { + "cell_type": "code", + "execution_count": 7, + "id": "d747789f-a627-408c-a122-0443f2fdb90d", + "metadata": { + "tags": [] + }, + "outputs": [ + { + "data": { + "text/html": [ + "Running query in 'redshift-sqlalchemy'" + ], + "text/plain": [ + "Running query in 'redshift-sqlalchemy'" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "text/html": [ + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
percentile_disc
20.0
\n", + "ResultSet : to convert to pandas, call .DataFrame() or to polars, call .PolarsDataFrame()
" + ], + "text/plain": [ + "+-----------------+\n", + "| percentile_disc |\n", + "+-----------------+\n", + "| 20.0 |\n", + "+-----------------+" + ] + }, + "execution_count": 7, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "%%sql\n", + "SELECT \n", + " APPROXIMATE PERCENTILE_DISC(0.99) WITHIN GROUP (ORDER BY trip_distance)\n", + "FROM \n", + " taxi;" + ] + }, + { + "cell_type": "markdown", + "id": "e27903cb", + "metadata": {}, + "source": [ + "Let's create a new snippet by filtering out the outliers using `--save`:" + ] + }, + { + "cell_type": "code", + "execution_count": 16, + "id": "3e55ecc0-ecaf-4d4d-8cf9-4bc7735f5495", + "metadata": { + "tags": [] + }, + "outputs": [ + { + "data": { + "text/html": [ + "Running query in 'redshift-sqlalchemy'" + ], + "text/plain": [ + "Running query in 'redshift-sqlalchemy'" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "text/html": [ + "Skipping execution..." + ], + "text/plain": [ + "Skipping execution..." + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "%%sql --save taxi_no_outliers --no-execute\n", + "select * from taxi where trip_distance < 20" + ] + }, + { + "cell_type": "markdown", + "id": "ed87d158-6dd2-44f8-a26c-65ad4eafb6fa", + "metadata": {}, + "source": [ + "### Histogram" + ] + }, + { + "cell_type": "code", + "execution_count": 17, + "id": "a7e0685d-4950-4c97-9f5d-ac3be039d1e0", + "metadata": { + "tags": [] + }, + "outputs": [ + { + "data": { + "text/html": [ + "Plotting using saved snippet : taxi_no_outliers" + ], + "text/plain": [ + "Plotting using saved snippet : taxi_no_outliers" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "text/plain": [ + "" + ] + }, + "execution_count": 17, + "metadata": {}, + "output_type": "execute_result" + }, + { + "data": { + "image/png": "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cpft1auX+vfvr2nTpqlp06a6++67tXDhwgpf1/Pzzz9r7NixCgkJka+vr4KCgqz9z83NLVHfokULl+dXXHGFJOn48eOSpO+++04eHh4lfpmca+/evZKkW2+9VUFBQS6PNWvWWBed26W08fz+++9LfX3bt29vzT/X+fsdEBAgSQoPDy8x3el0lvraVVVlxsjLy0tvvvmm9u3bpxMnTmjhwoW2fanBx8fHuvar2BVXXGGNvfTr6xYWFqZGjRq51JX1upaluK6sMTp27Fi5p+bttnfvXu3YsaPEe7b4837++7a0991jjz2mNm3aqE+fPmrevLmGDBliXWsF98Q1VUA5zj0yUNs5HA7961//0meffaYPP/xQq1ev1pAhQ/TSSy/ps88+k5+f3wWXf+CBB7Rp0yY9+eSTioqKkp+fn5xOp26//XY5nc4S9WUdwTOVuAi5eL3/+Mc/FBoaWmJ+/fr2/piyYzzL2m87Xo/yVHaMVq9eLenXo1p79+6t1H8SLuRir8mqLmWFxvMvoL9YTqdTnTp1sq5XO9/5Abu0911wcLAyMzO1evVqrVy5UitXrtTChQv10EMPadGiRbb2i0uDUIU6z87bEXz77bcyxris85tvvpGkCl9I27JlSzmdTn333Xcu/zPfs2dPhfu47rrrdN111+kPf/iD3n77bT344IN69913NWzYsDL39/jx40pLS9O0adNcLqwuPpJUFa1atZLT6dTOnTsVFRVVZo306y+YuLi4Km/rYrRs2bLU13f37t3W/EvJrjH6+uuvNX36dD388MPKzMzUsGHDtG3bNuvIWnVr2bKl1q5dqxMnTrgcrars61pcV9YYNW3aVA0bNpT069GynJycEnWlHRW7mM9+q1at9NVXX+m22267qPV4eXmpb9++6tu3r5xOpx577DG9/vrreu6559S6desqrxc1g9N/qPMaNmxY6g/hqjh06JCWLl1qPc/Ly9Pf//53RUVFlXoUpjR9+vSRJL3yyisu02fPnl3ussePHy9xVKQ4zBSfAmzQoIEkldjn4iMP5y9fke2WpV+/fvLw8ND06dNLHEUp3k58fLz8/f31xz/+sdTrwI4ePVrl7VfUHXfcoc8//1zp6enWtPz8fL3xxhuKiIi44OnL6mDHGJ05c0ZJSUkKCwvTnDlzlJKSouzsbI0fP75aei7NHXfcoaKiIs2dO9dl+qxZs+RwOKz3enmaNWumqKgoLVq0yOU12b59u9asWaM77rjDmtaqVSvl5ubq66+/tqYdPnzY5XNZ7GI++w888IB+/PFH/eUvfykx79SpUxU6HXn+rUo8PDysb3lW9lYcqB04UoU6Lzo6WvPnz9fzzz+v1q1bKzg42LrYtLLatGmjoUOHasuWLQoJCdGbb76p7OxsLVy4sMLriIqK0qBBg/Taa68pNzdXPXv2VFpamr799ttyl120aJFee+013XPPPWrVqpVOnDihv/zlL/L397d+8fj6+qpDhw5677331KZNGzVu3FgdO3ZUx44dddNNN+nFF1/UmTNndOWVV2rNmjXat29flV4L6ddrzJ555hn9/ve/14033qh7771X3t7e2rJli8LCwjRjxgz5+/tr/vz5Gjx4sLp27aqBAwcqKChIBw4c0IoVK3T99deX+KVst0mTJumdd95Rnz59NGbMGDVu3FiLFi3Svn379O9//7vERfbVzY4xev7555WZmam0tDQ1atRInTt31uTJk/Xss8/qvvvucwki1aVv37665ZZb9Mwzz2j//v3q0qWL1qxZow8++EDjxo2r1C0MZs6cqT59+ig2NlZDhw61bqkQEBDg8vcwBw4cqIkTJ+qee+7RmDFjrNtztGnTpsSF/NHR0Vq7dq1efvllhYWFKTIyUjExMRXqZ/DgwVq8eLFGjBih9evX6/rrr1dRUZF2796txYsXW/dCu5Bhw4bp559/1q233qrmzZvr+++/16uvvqqoqCjrujO4mRr73iFQS2RlZZmEhATTqFEjI8n62nTx16pLuz1BWbdUSEhIMKtXrzadO3c23t7epl27dmbJkiWV7unUqVNmzJgxpkmTJqZhw4amb9++5uDBg+XeUmHr1q1m0KBBpkWLFsbb29sEBwebO++803zxxRcu69+0aZOJjo42Xl5eLuv84YcfzD333GMCAwNNQECAuf/++82hQ4dKbLesWxaU9roYY8ybb75prr32WuPt7W2uuOIK06tXL5OamupSs379ehMfH28CAgKMj4+PadWqlUlKSirRe0Vc6JYKCQkJpS7z3Xffmfvuu88EBgYaHx8f06NHD7N8+fISPUoqMaZlvVfK6qMiLmaMMjIyTP369c3o0aNd1nn27FnTvXt3ExYWZo4fP+7SY2UkJiaahg0blphe2rpOnDhhxo8fb8LC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0Ro9erS6d++ua665Rv/973+rq1cAAIBaq1Khavbs2XrkkUfk7+9fYl5AQIAeffRRvfzyy7Y1BwAA4C4qFaq++uor3X777WXO7927tzIyMi66KQAAAHdTqVCVnZ1d6q0UitWvX19Hjx696KYAAADcTaVC1ZVXXqnt27eXOf/rr79Ws2bNLropAAAAd1OpUHXHHXfoueee0+nTp0vMO3XqlKZMmaI777zTtuYAAADcRaVuqfDss8/q/fffV5s2bTRq1Ci1bdtWkrR7927NmzdPRUVFeuaZZ6qlUQAAgNqsUqEqJCREmzZt0siRI/X000/LGCNJcjgcio+P17x58xQSElItjQIAANRmlb75Z8uWLfXRRx/p+PHj+vbbb2WM0dVXX60rrriiOvoDAABwC1W6o7okXXHFFerevbudvQAAALitKv3tPwAAALgiVAEAANiAUAUAAGADQhUAAIANCFUAAAA2IFQBAADYgFAFAABgA0IVAACADap880/gUomYtKLcmv0vJFyCTgAAKFuNHqmaMWOGunfvrkaNGik4OFj9+vXTnj17XGpOnz6t5ORkNWnSRH5+furfv7+ys7Ndag4cOKCEhAQ1aNBAwcHBevLJJ3X27FmXmg0bNqhr167y9vZW69atlZKSUqKfefPmKSIiQj4+PoqJidHnn39e6V4AAEDdVKOhauPGjUpOTtZnn32m1NRUnTlzRr1791Z+fr5VM378eH344YdasmSJNm7cqEOHDunee++15hcVFSkhIUGFhYXatGmTFi1apJSUFE2ePNmq2bdvnxISEnTLLbcoMzNT48aN07Bhw7R69Wqr5r333tOECRM0ZcoUbd26VV26dFF8fLyOHDlS4V4AAEDd5TDGmJpuotjRo0cVHBysjRs36qabblJubq6CgoL09ttv67777pMk7d69W+3bt1d6erquu+46rVy5UnfeeacOHTqkkJAQSdKCBQs0ceJEHT16VF5eXpo4caJWrFih7du3W9saOHCgcnJytGrVKklSTEyMunfvrrlz50qSnE6nwsPDNXr0aE2aNKlCvZQnLy9PAQEBys3Nlb+/v62vnV0qcqqtNuL0HwCgulT093etulA9NzdXktS4cWNJUkZGhs6cOaO4uDirpl27dmrRooXS09MlSenp6erUqZMVqCQpPj5eeXl52rFjh1Vz7jqKa4rXUVhYqIyM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4s4vbOPGjUpOTtZnn32m1NRUnTlzRr1791Z+fv4Fl/P399fhw4etx/fff3+JOq68a665xqXXTz75pMxadxk3SdqyZYvLfqWmpkqS7r///jKXqa3jlp+fry5dumjevHmlzn/xxRf1yiuvaMGCBdq8ebMaNmyo+Ph4nT59usx1VvYzW10utG8nT57U1q1b9dxzz2nr1q16//33tWfPHt11113lrrcy7+vqVN7YSdLtt9/u0us777xzwXW6w9hJctmnw4cP680335TD4VD//v0vuN7aMHYV+dk/fvx4ffjhh1qyZIk2btyoQ4cO6d57773geqvyWb0oBm6pR48eJjk52XpeVFRkwsLCzIwZM0qtf+CBB0xCQoLLtJiYGPPoo49Wa58X68iRI0aS2bhxY5k1CxcuNAEBAZeuqYswZcoU06VLlwrXu+u4GWPM2LFjTatWrYzT6Sx1vruMmySzdOlS67nT6TShoaFm5syZ1rScnBzj7e1t3nnnnTLXU9nP7KVw/r6V5vPPPzeSzPfff19mTWXf15dKafuXmJho7r777kqtx13H7u677za33nrrBWtq69id/7M/JyfHeHp6miVLllg1u3btMpJMenp6qeuo6mf1YnCkyg0VFhYqIyNDcXFx1jQPDw/FxcUpPT291GXS09Nd6iUpPj6+zPraIjc3V5LUuHHjC9b98ssvatmypcLDw3X33Xdrx44dl6K9Ktm7d6/CwsJ01VVX6cEHH9SBAwfKrHXXcSssLNRbb72lIUOGXPCPh7vTuBXbt2+fsrKyXMYlICBAMTExZY5LVT6ztUVubq4cDke5f7e0Mu/rmrZhwwYFBwerbdu2GjlypH766acya9117LKzs7VixQoNHTq03NraOHbn/+zPyMjQmTNnXMahXbt2atGiRZnjUJXP6sUiVLmhY8eOqaioqMSfxwkJCVFWVlapy2RlZVWqvjZwOp0aN26crr/+enXs2LHMurZt2+rNN9/UBx98oLfeektOp1M9e/bUDz/8cAm7rZiYmBilpKRo1apVmj9/vvbt26cbb7xRJ06cKLXeHcdNkpYtW6acnBwlJSWVWeNO43au4te+MuNSlc9sbXD69GlNnDhRgwYNuuAfrK3s+7om3X777fr73/+utLQ0/elPf9LGjRvVp08fFRUVlVrvrmO3aNEiNWrUqNzTY7Vx7Er72Z+VlSUv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", + "text/plain": [ + "
" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "%sqlplot histogram --table taxi_no_outliers --column trip_distance" + ] + }, + { + "cell_type": "markdown", + "id": "8b721b7c-3677-4c39-8f6a-7fa604d8b718", + "metadata": {}, + "source": [ + "### Boxplot" + ] + }, + { + "cell_type": "code", + "execution_count": 18, + "id": "d31e6f94-fe6f-4b09-b808-21e28224cbc7", + "metadata": { + "tags": [] + }, + "outputs": [ + { + "data": { + "text/html": [ + "Plotting using saved snippet : taxi_no_outliers" + ], + "text/plain": [ + "Plotting using saved snippet : taxi_no_outliers" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "text/plain": [ + "" + ] + }, + "execution_count": 18, + "metadata": {}, + "output_type": "execute_result" + }, + { + "data": { + "image/png": "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MECtWrVSZGSkVqxYofPnz2vRokVFbuPu7i4fHx+bBwAAuH2Vag7Q0qVLtXDhQrVr104Wi8Xa3rx5cx05csRuxUn/Cz/Hjh3T2rVrSxxO/Pz81LhxYx0+fNiudQEAgIqrVGeATp8+rZo1axZoz87OtglEtyo//CQnJ2vNmjW64447SjzGhQsXdOTIEQUGBtqtLgAAULGVKgDdfffd+uabb6zL+aHnb3/7myIiIoo9zoULF5SUlKSkpCRJUkpKipKSkpSamqqrV6/q4Ycf1vbt2/XPf/5Tubm5SktLU1pamq5cuWIdo1u3bpo1a5Z1efz48dqwYYOOHj2qzZs3q1+/fnJ1dVV0dHRpDhUAANyGSnUJ7I033lBUVJT27duna9euaebMmdq3b582b96sDRs2FHuc7du367777rMujxs3TpI0aNAgxcbG6t///rckKSwszGa7devWqWvXrpKkI0eO6MyZM9Z1J06cUHR0tM6ePSt/f3917NhRW7dulb+/f2kOFQAA3IYshmEYpdnwyJEjevPNN7Vr1y5duHBBbdq0sd65uaLLzMyUr6+vMjIymBANVHAluSxfyl+HAJxESd6/Sx2AbmcEIOD2QQACzKMk79+lmgO0YsUKrVq1qkD7qlWr9O2335ZmSAAAgHJTqgA0YcIE5ebmFmg3DEMTJky45aIAAADKUqkCUHJyspo1a1agPTQ0lPvtAAAAp1eqAOTr66uff/65QPvhw4dVtWrVWy4KAACgLJUqAPXp00djx461uevz4cOH9cILL6h37952Kw4AAKAslCoAvf3226patapCQ0NVr1491atXT02bNtUdd9yhv/71r/auEQAAwK5KdSNEX19fbd68WQkJCdq1a5c8PT3VqlUrde7c2d71AQAA2B33ASoE9wECbh/cBwgwj5K8f5fqDJAkJSYmKjExUb/++qvy8vJs1s2dO7e0wwIAAJS5UgWgKVOmaOrUqbr77rsVGBho12+ABwAAKGulCkBz5sxRfHy8nnzySXvXAwAAUOZK9SmwK1euqH379vauBQAAoFyUKgANGzZMn332mb1rAQAAKBelugR2+fJlffzxx1qzZo1atWqlypUr26x/99137VIcAABAWShVAPrpp58UFhYmSdqzZ4/NOiZEAwAAZ1eqALRu3Tp71wEAAFBuSjUHCAAAoCIr9Y0Qt2/frkWLFik1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eHi43cZ74okn7DbWjh071KZNG7uNB6DsOXUAOnPmjHJzc1WrVi2b9lq1ahX5l2BaWlqh/dPS0orcz8SJE21CU/4ZIADOIzQ0VDt27LjlcS5duqSjR48qJCREnp6edqjsem0AKhanDkDlxd3dXe7u7o4uA8ANVKlSxW5nWTp06GCXcQBUXE49CbpGjRpydXVVenq6TXt6eroCAgIK3SYgIKBE/QEAgPk4dQByc3NTeHi4EhMTrW15eXlKTExUREREodtERETY9JekhISEIvsDAADzcfpLYOPGjdOgQYN09913695779WMGTOUnZ2twYMHS5IGDhyoO++8U3FxcZKkMWPGqEuXLnrnnXfUs2dPffHFF9q+fbs+/vhjRx4GAABwIk4fgB599FGdPn1akyZNUlpamsLCwrRy5UrrROfU1FS5uPzvRFb79u312Wef6dVXX9X//d//qVGjRlq6dKlatGjhqEMAAABOxunvA+QIJbmPAAAAcA4lef926jlAAAAAZYEABAAATIcABAAATIcABAAATIcABAAATIcABAAATIcABAAATIcABAAATMfp7wTtCPn3hszMzHRwJQAAoLjy37eLc49nAlAhsrKyJEnBwcEOrgQAAJRUVlaWfH19b9iHr8IoRF5enk6ePClvb29ZLBZHlwPAjjIzMxUcHKzjx4/zVTfAbcYwDGVlZSkoKMjme0ILQwACYCp81x8AiUnQAADAhAhAAADAdAhAAEzF3d1dkydPlru7u6NLAeBAzAECAACmwxkgAABgOgQgAABgOgQgAABgOgQgAABgOgQgAOUqNjZWYWFhZbqPrl27auzYsdblkJAQzZgxo0z3CaBiIQABsIvfh46ijB8/XomJiWVf0G/88MMPGjFiRLH6EpYAc+DLUAGUC8MwlJubKy8vL3l5eZXrvv39/ct1fwCcH2eAANyymJgYbdiwQTNnzpTFYpHFYlF8fLwsFou+/fZbhYeHy93dXZs2bSpwCSwmJkZ9+/bVlClT5O/vLx8fHz399NO6cuVKsfadnZ2tgQMHysvLS4GBgXrnnXcK9PntWR3DMBQbG6s6derI3d1dQUFBGj16tKTrZ7GOHTum559/3nocknT27FlFR0frzjvvVJUqVdSyZUt9/vnnNvvo2rWrRo8erZdeeknVq1dXQECAYmNjbfqcP39eTz31lGrVqiUPDw+1aNFCy5cvt67ftGmTOnXqJE9PTwUHB2v06NHKzs4u1vMAoGQIQABu2cyZMxUREaHhw4fr1KlTOnXqlIKDgyVJEyZM0Jtvvqn9+/erVatWhW6fmJio/fv3a/369fr888+1ePFiTZkypVj7fvHFF7VhwwYtW7ZMq1ev1vr167Vz584i+3/11VeaPn26PvroIyUnJ2vp0qVq2bKlJGnx4sWqXbu2pk6daj0OSbp8+bLCw8P1zTffaM+ePRoxYoSefPJJff/99zZjz58/X1WrVtW2bdv09ttva+rUqUpISJAk5eXlKSoqSt99950WLFigffv26c0335Srq6sk6ciRI3rggQfUv39//fTTT1q4cKE2bdqkUaNGFet5AFBCBgDYQZcuXYwxY8ZYl9etW2dIMpYuXWrTb/LkyUbr1q2ty4MGDTKqV69uZGdnW9tmz55teHl5Gbm5uTfcZ1ZWluHm5mYsWrTI2nb27FnD09PTppa6desa06dPNwzDMN555x2jcePGxpUrVwod87d9b6Rnz57GCy+8YF3u0qWL0bFjR5s+99xzj/Hyyy8bhmEYq1atMlxcXIyDBw8WOt7QoUONESNG2LT95z//MVxcXIxLly7dtB4AJcMZIABl6u67775pn9atW6tKlSrW5YiICF24cEHHjx+/4XZHjhzRlStX1LZtW2tb9erV1aRJkyK3GTBggC5duqT69etr+PDhWrJkia5du3bD/eTm5uq1115Ty5YtVb16dXl5eWnVqlVKTU216ff7M1yBgYH69ddfJUlJSUmqXbu2GjduXOg+du3apfj4eOscKS8vL0VGRiovL08pKSk3rA9AyTEJGkCZqlq1qqNLsBEcHKyDBw9qzZo1SkhI0LPPPqtp06Zpw4YNqly5cqHbTJs2TTNnztSMGTPUsmVLVa1aVWPHji0wT+n321ssFuXl5UmSPD09b1jXhQsX9NRTT1nnI/1WnTp1SnKIAIqBAATALtzc3JSbm1uqbXft2qVLly5ZQ8LWrVvl5eVlnUdUlAYNGqhy5cratm2bNSScO3dOhw4dUpcuXYrcztPTU7169VKvXr00cuRIhYaGavfu3WrTpk2hx/Hdd9+pT58+euKJJyRdn89z6NAhNWvWrNjH2KpVK504cUKHDh0q9CxQmzZttG/fPjVs2LDYYwIoPS6BAbCLkJAQbdu2TUePHtWZM2esZz6K48qVKxo6dKj27dunFStWaPLkyRo1apRcXG78K8rLy0tDhw7Viy++qLVr12rPnj2KiYm54Xbx8fH6+9//rj179ujnn3/WggUL5Onpqbp161qPY+PGjfrll1905swZSVKjRo2UkJCgzZs3a//+/XrqqaeUnp5e7OOTpC5duqhz587q37+/EhISlJKSom+//VYrV66UJL388svavHmzRo0apaSkJCUnJ2vZsmVMggbKCAEIgF2MHz9erq6uatasmfz9/QvMj7mRbt26qVGjRurcubMeffRR9e7du8BHyIsybdo0derUSb169VL37t3VsWNHhYeHF9nfz89Pn3zyiTp06KBWrVppzZo1+vrrr3XHHXdIkqZOnaqjR4+qQYMG1vsHvfrqq2rTpo0iIyPVtWtXBQQEqG/fvsU+vnxfffWV7rnnHkVHR6tZs2Z66aWXrGebWrVqpQ0bNujQoUPq1KmT7rrrLk2aNElBQUEl3g+Am7MYhmE4uggA5hUTE6Pz589r6dKlji4FgIlwBggAAJgOAQiA00pNTbX5WPjvHyW5zAYAv8UlMABO69q1azp69GiR60NCQlSpEh9mBVByBCAAAGA6XAIDAACmQwACAACmQwACAACmQwACAACmQwACAACmQwACAACmQwACAACm8/8AvWkX9WPlRpcAAAAASUVORK5CYII=", + "text/plain": [ + "
" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "%sqlplot boxplot --table taxi_no_outliers --column trip_distance" + ] + }, + { + "cell_type": "markdown", + "id": "ffadf307-aae6-4ca1-922f-caa4fecbb036", + "metadata": {}, + "source": [ + "### Bar" + ] + }, + { + "cell_type": "code", + "execution_count": 19, + "id": "f12be2fa-7d12-4379-a501-67071db2db5c", + "metadata": { + "tags": [] + }, + "outputs": [ + { + "data": { + "text/html": [ + "Removing NULLs, if there exists any from passenger_count" + ], + "text/plain": [ + "Removing NULLs, if there exists any from passenger_count" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "text/plain": [ + "" + ] + }, + "execution_count": 19, + "metadata": {}, + "output_type": "execute_result" + }, + { + "data": { + "image/png": "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", + "text/plain": [ + "
" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "%sqlplot bar --table taxi --column passenger_count" + ] + }, + { + "cell_type": "markdown", + "id": "afc3a091-58b4-42be-89e0-ffefc9f22003", + "metadata": {}, + "source": [ + "## Plotting using the `ggplot` API\n", + "\n", + "You can also use the `ggplot` API to create visualizations:" + ] + }, + { + "cell_type": "code", + "execution_count": 33, + "id": "8732b80d-6dc4-42b1-b503-3fa039f66f4b", + "metadata": { + "tags": [] + }, + "outputs": [], + "source": [ + "from sql.ggplot import ggplot, aes, geom_histogram" + ] + }, + { + "cell_type": "code", + "execution_count": 34, + "id": "9a82b4f3-36de-4e77-ba3f-ecc88be9d4c1", + "metadata": { + "tags": [] + }, + "outputs": [ + { + "data": { + "text/plain": [ + "" + ] + }, + "execution_count": 34, + "metadata": {}, + "output_type": "execute_result" + }, + { + "data": { + "image/png": "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rl276rXXXrOsjyufA0lq2rSpy+P97rvvrvq8lo5XxtVeIx8fH7Vr167S27HKiRMnlJeXp6VLlyooKMjlNmLECEk/XSxxufLeJzfynkbdwzlVwE1077336uuvv9aHH36oTz75RG+//bbmzJmjJUuWuBw5udmu/Ne3JP3qV7/S9u3bNWHCBPXs2VONGzdWSUmJBg4cWO5VT15eXpVaJqnMifVW9Hu9bma/l8vLy9N9990nm82mGTNmqH379vLz89OePXs0ceLEcp/b0ikTcnJydOrUKYWGhlrSy814vFVx5fl6pYqLi6/a8/UqfZ6ffPJJxcfHl1tz5dHn8v7e1dT3NNyDUAXcZIGBgRoxYoRGjBihc+fO6d5779W0adPM/wFf7QOldevW2rhxo86ePetytKr066LWrVub/y0pKVF2drY6duxo1h0+fLjSPZ45c0bp6emaPn26pkyZYi6vqV9xlD72rKysMmOHDh1S8+bN1ahRo5va09Vexy1btujUqVP65z//6XLye3Z2drn1S5YsUVpamn73u99p5syZeu655/Thhx9WS8/lad269VWf19Lxym5H+uk1ateunbm8qKhI2dnZio6ONpc1bdq0zBWB0k9Huy5f92rPcWUEBQWpSZMmKi4udtl3VVT0nkb9wdd/wE105XQEjRs3VocOHVRYWGguK/3wv/JD5cEHH1RxcbEWLFjgsnzOnDny8PDQoEGDJEkxMTGSfrp0/XJvvfVWpfssPRpw5RGLuXPnVnobN1OLFi3Us2dPLVu2zOV5279/vz755BM9+OCDN72nq72O5T23RUVFZV4v6aegNWHCBMXFxek3v/mN/vjHP+qjjz4qd9qD6vLggw9q586dysjIMJcVFBRo6dKlatOmjSIiIiq1nejoaPn4+Gj+/Pkuj/2//uu/lJ+f73J1avv27fXZZ5+5zD+2Zs0aHT161GWbV3uOK8PLy0txcXF6//33tX///jLjJ06cqNR2KvOeRv3BkSrgJoqIiND999+vXr16KTAwULt379Y//vEPjRkzxqzp1auXJGns2LGKiYmRl5eXhg0bpocfflj9+/fXK6+8om+//VY9evTQJ598og8//FDjxo1T+/btzfXj4uI0d+5cnTp1ypxS4csvv5RUuX/d22w23XvvvZo1a5YuXryoW265RZ988slVj6bUBLNnz9agQYMUFRWl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", + "text/plain": [ + "
" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "(\n", + " ggplot(\"taxi_no_outliers\", aes(x=\"trip_distance\"), with_=\"taxi_no_outliers\")\n", + " + geom_histogram(bins=30, fill=\"vendorid\")\n", + ")" + ] + }, + { + "cell_type": "markdown", + "id": "e2317e18-30a2-4b04-b889-40c3d66c9077", + "metadata": {}, + "source": [ + "## Using a native connection\n", + "\n", + "Using a native connection is also supported." + ] + }, + { + "cell_type": "code", + "execution_count": 55, + "id": "34c3db55-3631-42a1-b43e-670e7e4e7b96", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Note: you may need to restart the kernel to use updated packages.\n" + ] + } + ], + "source": [ + "%pip install redshift-connector --quiet" + ] + }, + { + "cell_type": "code", + "execution_count": 56, + "id": "ca437a56-a06e-4669-ba47-26d74701ddd5", + "metadata": { + "tags": [] + }, + "outputs": [], + "source": [ + "import redshift_connector\n", + "\n", + "conn = redshift_connector.connect(\n", + " host=host,\n", + " database=\"dev\",\n", + " port=5439,\n", + " user=user,\n", + " password=password,\n", + " timeout=60,\n", + ")" + ] + }, + { + "cell_type": "code", + "execution_count": 57, + "id": "a36dc846-aa1d-485d-bd71-798960f309e7", + "metadata": { + "tags": [] + }, + "outputs": [], + "source": [ + "%sql conn --alias redshift-native" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3 (ipykernel)", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.10.12" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/doc/integrations/snowflake.ipynb b/doc/integrations/snowflake.ipynb new file mode 100644 index 000000000..f5d909e0b --- /dev/null +++ b/doc/integrations/snowflake.ipynb @@ -0,0 +1,874 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "8a26f191", + "metadata": {}, + "source": [ + "# Snowflake\n", + "\n", + "```{important}\n", + "`snowflake-sqlalchemy` requires SQLAlchemy 1.x (as of version 1.4.7 )\n", + "```\n", + "\n", + "`Snowflake` is a cloud-based data warehousing platform that provides organizations with a powerful and flexible solution for storing, managing, and analyzing large amounts of data. Unlike traditional data warehouses, Snowflake operates entirely in the cloud, utilizing a distributed architecture that allows it to process and store data across multiple computing resources. \n", + "\n", + "In this guide, we'll demonstrate how to integrate with Snowflake using JupySQL magics.\n", + "\n", + "```{tip}\n", + "If you encounter any issues, feel free to join our [community](https://ploomber.io/community) and we'll be happy to help!\n", + "```" + ] + }, + { + "cell_type": "markdown", + "id": "14dc32cc", + "metadata": {}, + "source": [ + "## Pre-requisites\n", + "\n", + "We will need the `snowflake-sqlalchemy` package for connecting to the warehouse." + ] + }, + { + "cell_type": "code", + "execution_count": 1, + "id": "ac2a4ee0", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Note: you may need to restart the kernel to use updated packages.\n" + ] + } + ], + "source": [ + "%pip install --upgrade snowflake-sqlalchemy 'sqlalchemy<2' --quiet" + ] + }, + { + "cell_type": "markdown", + "id": "4629c09b", + "metadata": {}, + "source": [ + "Now let's define the URL connection parameters and create an `Engine` object." + ] + }, + { + "cell_type": "code", + "execution_count": 17, + "id": "b824fb60", + "metadata": {}, + "outputs": [], + "source": [ + "from sqlalchemy import create_engine\n", + "from snowflake.sqlalchemy import URL\n", + "\n", + "\n", + "engine = create_engine(\n", + " URL(\n", + " drivername=\"driver\",\n", + " user=\"user\",\n", + " password=\"password\",\n", + " account=\"account\",\n", + " database=\"database\",\n", + " role=\"role\",\n", + " schema=\"schema\",\n", + " warehouse=\"warehouse\",\n", + " )\n", + ")" + ] + }, + { + "cell_type": "markdown", + "id": "7853cb8d", + "metadata": {}, + "source": [ + "## Load sample data\n", + "\n", + "Now, let's load the `penguins` dataset. We'll convert this `.csv` file to a dataframe and create a table in Snowflake database from the data." + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "id": "09b2ac9e", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "('penguins.csv', )" + ] + }, + "execution_count": 3, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "import urllib.request\n", + "\n", + "urllib.request.urlretrieve(\n", + " \"https://raw.githubusercontent.com/mwaskom/seaborn-data/master/penguins.csv\",\n", + " \"penguins.csv\",\n", + ")" + ] + }, + { + "cell_type": "code", + "execution_count": 4, + "id": "65ff0181", + "metadata": {}, + "outputs": [], + "source": [ + "%load_ext sql" + ] + }, + { + "cell_type": "code", + "execution_count": 5, + "id": "803c43e4", + "metadata": {}, + "outputs": [], + "source": [ + "%sql engine --alias connection" + ] + }, + { + "cell_type": "code", + "execution_count": 6, + "id": "3e364576", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "344" + ] + }, + "execution_count": 6, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "import pandas as pd\n", + "\n", + "df = pd.read_csv(\"penguins.csv\")\n", + "connection = engine.connect()\n", + "df.to_sql(name=\"penguins\", con=connection, index=False, if_exists=\"replace\")" + ] + }, + { + "cell_type": "markdown", + "id": "747f5239", + "metadata": {}, + "source": [ + "## Query" + ] + }, + { + "cell_type": "markdown", + "id": "494cbab2-a241-4e91-ae94-4ad6cb74c8ec", + "metadata": {}, + "source": [ + "List the tables in the database:" + ] + }, + { + "cell_type": "code", + "execution_count": 7, + "id": "23aa0941", + "metadata": {}, + "outputs": [ + { + "data": { + "text/html": [ + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
Name
penguins
" + ], + "text/plain": [ + "+----------+\n", + "| Name |\n", + "+----------+\n", + "| penguins |\n", + "+----------+" + ] + }, + "execution_count": 7, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "%sqlcmd tables" + ] + }, + { + "cell_type": "markdown", + "id": "a1936edd-342e-476d-ae83-ab00749daa9b", + "metadata": {}, + "source": [ + "List columns in the penguins table:" + ] + }, + { + "cell_type": "code", + "execution_count": 8, + "id": "1397fbb6", + "metadata": {}, + "outputs": [ + { + "data": { + "text/html": [ + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
nametypenullabledefaultautoincrementcommentprimary_key
speciesVARCHAR(16777216)TrueNoneFalseNoneFalse
islandVARCHAR(16777216)TrueNoneFalseNoneFalse
bill_length_mmFLOATTrueNoneFalseNoneFalse
bill_depth_mmFLOATTrueNoneFalseNoneFalse
flipper_length_mmFLOATTrueNoneFalseNoneFalse
body_mass_gFLOATTrueNoneFalseNoneFalse
sexVARCHAR(16777216)TrueNoneFalseNoneFalse
" + ], + "text/plain": [ + "+-------------------+-------------------+----------+---------+---------------+---------+-------------+\n", + "| name | type | nullable | default | autoincrement | comment | primary_key |\n", + "+-------------------+-------------------+----------+---------+---------------+---------+-------------+\n", + "| species | VARCHAR(16777216) | True | None | False | None | False |\n", + "| island | VARCHAR(16777216) | True | None | False | None | False |\n", + "| bill_length_mm | FLOAT | True | None | False | None | False |\n", + "| bill_depth_mm | FLOAT | True | None | False | None | False |\n", + "| flipper_length_mm | FLOAT | True | None | False | None | False |\n", + "| body_mass_g | FLOAT | True | None | False | None | False |\n", + "| sex | VARCHAR(16777216) | True | None | False | None | False |\n", + "+-------------------+-------------------+----------+---------+---------------+---------+-------------+" + ] + }, + "execution_count": 8, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "%sqlcmd columns --table penguins" + ] + }, + { + "cell_type": "markdown", + "id": "831ca098-a0f7-419b-ae96-b2c8b5026be6", + "metadata": {}, + "source": [ + "Query our data:" + ] + }, + { + "cell_type": "code", + "execution_count": 9, + "id": "8f92b0f7", + "metadata": {}, + "outputs": [ + { + "data": { + "text/html": [ + "Running query in 'connection'" + ], + "text/plain": [ + "Running query in 'connection'" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "text/html": [ + "1 rows affected." + ], + "text/plain": [ + "1 rows affected." + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "text/html": [ + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
COUNT(*)
344
" + ], + "text/plain": [ + "+----------+\n", + "| COUNT(*) |\n", + "+----------+\n", + "| 344 |\n", + "+----------+" + ] + }, + "execution_count": 9, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "%%sql\n", + "SELECT COUNT(*) FROM penguins " + ] + }, + { + "cell_type": "code", + "execution_count": 10, + "id": "082c9090", + "metadata": {}, + "outputs": [ + { + "data": { + "text/html": [ + "Running query in 'connection'" + ], + "text/plain": [ + "Running query in 'connection'" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "text/html": [ + "3 rows affected." + ], + "text/plain": [ + "3 rows affected." + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "text/html": [ + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
speciescount
Adelie152
Gentoo124
Chinstrap68
" + ], + "text/plain": [ + "+-----------+-------+\n", + "| species | count |\n", + "+-----------+-------+\n", + "| Adelie | 152 |\n", + "| Gentoo | 124 |\n", + "| Chinstrap | 68 |\n", + "+-----------+-------+" + ] + }, + "execution_count": 10, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "%%sql\n", + "SELECT species, COUNT(*) AS count\n", + "FROM penguins\n", + "GROUP BY species\n", + "ORDER BY count DESC" + ] + }, + { + "cell_type": "markdown", + "id": "972cf9e5", + "metadata": {}, + "source": [ + "## Parametrize queries\n", + "\n", + "JupySQL supports variable expansion in this format: `{{variable}}`." + ] + }, + { + "cell_type": "code", + "execution_count": 14, + "id": "f3bad484", + "metadata": {}, + "outputs": [], + "source": [ + "dynamic_limit = 5\n", + "dynamic_column = \"island, sex\"" + ] + }, + { + "cell_type": "code", + "execution_count": 15, + "id": "aa7319e8", + "metadata": {}, + "outputs": [ + { + "data": { + "text/html": [ + "Running query in 'connection'" + ], + "text/plain": [ + "Running query in 'connection'" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "text/html": [ + "5 rows affected." + ], + "text/plain": [ + "5 rows affected." + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "text/html": [ + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
islandsex
TorgersenMALE
TorgersenFEMALE
TorgersenFEMALE
TorgersenNone
TorgersenFEMALE
" + ], + "text/plain": [ + "+-----------+--------+\n", + "| island | sex |\n", + "+-----------+--------+\n", + "| Torgersen | MALE |\n", + "| Torgersen | FEMALE |\n", + "| Torgersen | FEMALE |\n", + "| Torgersen | None |\n", + "| Torgersen | FEMALE |\n", + "+-----------+--------+" + ] + }, + "execution_count": 15, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "%sql SELECT {{dynamic_column}} FROM penguins LIMIT {{dynamic_limit}}" + ] + }, + { + "cell_type": "markdown", + "id": "898f9f0c", + "metadata": {}, + "source": [ + "## CTEs\n", + "\n", + "Using JupySQL we can save query snippets, and use these saved snippets to form larger queries. Let's see CTEs in action:" + ] + }, + { + "cell_type": "code", + "execution_count": 11, + "id": "a108569c", + "metadata": {}, + "outputs": [ + { + "data": { + "text/html": [ + "Running query in 'connection'" + ], + "text/plain": [ + "Running query in 'connection'" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "text/html": [ + "Skipping execution..." + ], + "text/plain": [ + "Skipping execution..." + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "%%sql --save no_nulls --no-execute\n", + "SELECT *\n", + "FROM penguins\n", + "WHERE body_mass_g IS NOT NULL and\n", + "sex IS NOT NULL" + ] + }, + { + "cell_type": "code", + "execution_count": 12, + "id": "6768b87e", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Generating CTE with stored snippets : no_nulls\n" + ] + }, + { + "data": { + "text/html": [ + "Running query in 'connection'" + ], + "text/plain": [ + "Running query in 'connection'" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "text/html": [ + "3 rows affected." + ], + "text/plain": [ + "3 rows affected." + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "text/html": [ + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
islandavg_body_mass_g
Torgersen3708.5106382978724
Biscoe4719.171779141105
Dream3718.9024390243903
" + ], + "text/plain": [ + "+-----------+--------------------+\n", + "| island | avg_body_mass_g |\n", + "+-----------+--------------------+\n", + "| Torgersen | 3708.5106382978724 |\n", + "| Biscoe | 4719.171779141105 |\n", + "| Dream | 3718.9024390243903 |\n", + "+-----------+--------------------+" + ] + }, + "execution_count": 12, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "%%sql\n", + "SELECT island, avg(body_mass_g) as avg_body_mass_g\n", + "FROM no_nulls\n", + "GROUP BY island;" + ] + }, + { + "cell_type": "markdown", + "id": "4a11d4f4", + "metadata": {}, + "source": [ + "The query gets compiled like so:" + ] + }, + { + "cell_type": "code", + "execution_count": 13, + "id": "7bcf72de", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "WITH\n", + "SELECT *\n", + "FROM penguins\n", + "WHERE body_mass_g IS NOT NULL and\n", + "sex IS NOT NULL\n" + ] + } + ], + "source": [ + "final = %sqlcmd snippets no_nulls\n", + "print(final)" + ] + }, + { + "cell_type": "markdown", + "id": "8644b4a1-0f51-4d76-b348-29c8bff2c3be", + "metadata": {}, + "source": [ + "## Plotting\n" + ] + }, + { + "cell_type": "code", + "execution_count": 16, + "id": "c739d88e-6593-41b6-998d-a453c6355590", + "metadata": { + "tags": [] + }, + "outputs": [ + { + "data": { + "text/plain": [ + "" + ] + }, + "execution_count": 16, + "metadata": {}, + "output_type": "execute_result" + }, + { + "data": { + "image/png": "iVBORw0KGgoAAAANSUhEUgAAAkAAAAHFCAYAAAAaD0bAAAAAOXRFWHRTb2Z0d2FyZQBNYXRwbG90bGliIHZlcnNpb24zLjcuMSwgaHR0cHM6Ly9tYXRwbG90bGliLm9yZy/bCgiHAAAACXBIWXMAAA9hAAAPYQGoP6dpAAA8x0lEQVR4nO3deVxV1f7/8fdR8OAAKCqTCqg5oDldRywVMzUtGy3LnG6DDQ6Z+UgpTeheQ20i0/RrpWaD2v06ZDmkpuDtiuVEmd/yYmFQSZQloCkOrN8f/Th5ZEbgHNyv5+NxHg/32mvt/dnbjbxde59zbMYYIwAAAAup5uoCAAAAKhsBCAAAWA4BCAAAWA4BCAAAWA4BCAAAWA4BCAAAWA4BCAAAWA4BCAAAWA4BCAAAWA4BCFe0ZcuWyWazObWFhYXppptuKnbs0aNHZbPZtGzZsnzbO3r0qKNtzJgxCgsLK1Vd0dHR+epypY0bNyo6OrrAdTabTePHj6/cgipIdHR0if+upk+frpCQEHl4eKhu3boVWtflGjNmjCIjI11dRoUqy89Zabd/pZ9DOCMAAYUICgpSYmKibrzxRleXUuE2btyomJgYV5fhNj744APNmjVLo0aNUkJCgrZt2+bqkixvxowZWrt2ravLwBXEw9UFAO7KbrerR48eri4DLvDVV19JkiZOnCh/f/8i+54+fVo1a9asjLIsrXnz5q4uAVcYZoBgWWvXrlX79u3l5eWlZs2aad68eU7rC7oFVtFWrVqliIgI1a5dW3Xq1NHAgQN14MABpz5jxoxRnTp1dOTIEQ0ePFh16tRRkyZN9MQTTygnJ8ep7w8//KChQ4fK29tbdevW1b333qs9e/Y4HdeYMWO0YMECSX/e7sp7XXybT5LefvtthYeHq1atWurQoYM++uijUh1b3vl8/vnnNWfOHIWFhalmzZqKjIzUf//7X507d07Tpk1TcHCwfH19ddtttykjI8NpG3m3Lz/66CN16tRJNWvWVHh4uKOWZcuWKTw8XLVr11a3bt20d+/eUtWYt4/p06dLkgICAmSz2Ry3B/P2v2bNGnXq1EleXl6OmbOvvvpKt9xyi+rVqycvLy917NhRb731ltO24+PjZbPZ9N5772nq1KkKCgpSnTp1NGTIEP3888/Kzs7W2LFj1aBBAzVo0EB///vfdfLkyVIfQ965njt3rmbNmqWQkBB5eXmpS5cu+uSTT/L1T05O1vDhw+Xv7y+73a7w8HDHNXFp7StWrNDTTz+t4OBg+fj46Prrr9fhw4ed+hpj9Nxzzyk0NNSx361btyoyMtLpNlNBt5Qv3ld8fLyjraBbYHm3Z4u7Nn/55ReNHTtWTZo0kd1uV8OGDXXNNdcws2d1BrCY0NBQ06hRIxMSEmKWLFliNm7caO69914jyTz//POOfikpKUaSWbp0qaNt6dKlRpJJSUlxtI0ePdqEhoaWqoaZM2eaS3/8Zs2aZWw2m7nvvvvMRx99ZNasWWMiIiJM7dq1zaFDh5z2V6NGDRMeHm5eeOEFs23bNvPMM88Ym81mYmJiHP1OnjxprrrqKuPn52cWLFhgPv74Y/P444+bpk2bOh3XkSNHzNChQ40kk5iY6HidOXPGGGOMJBMWFma6detm3n//fbNx40YTGRlpPDw8zLffflviY847n6GhoWbIkCHmo48+Mu+8844JCAgwLVu2NCNHjjT33Xef2bRpk1m0aJGpU6eOGTJkiNM2QkNDTePGjc3VV19tVqxYYTZu3Gi6d+9uPD09zTPPPGOuueYas2bNGrN27VrTsmVLExAQYP74449S/d3s37/f3H///UaS2bx5s0lMTDRpaWmO/QcFBZlmzZqZJUuWmB07dpjPP//cfPPNN8bb29s0b97cLF++3GzYsMHcc889RpKZM2eOY9s7duxwnIMxY8aYzZs3O461b9++pn///mbKlClmy5YtZs6cOaZ69epmwoQJpar/4nPdpEkTc+2115rVq1ebf/3rX6Zr167G09PT7Nq1y9H30KFDxtfX17Rr184sX77cbNmyxTzxxBOmWrVqJjo6Ol/tYWFh5t577zUbNmwwK1asMCEhIaZFixbm/Pnzjr5RUVFGkhk7dqzZvHmzef31101ISIgJCgoyffr0cfQr6Ofp4n3t2LHD0VbQz1lJr82BAweahg0bmsWLF5v4+Hizbt0688wzz5iVK1eW+tziykEAguWEhoYam81mkpKSnNr79+9vfHx8zKlTp4wxlRuAUlNTjYeHR75fdtnZ2SYwMNDcddddTvuTZN5//32nvoMHDzatWrVyLC9YsMBIMps2bXLq99BDD+U7rnHjxuULZHkkmYCAAJOVleVoS09PN9WqVTOxsbElPua889mhQwdz4cIFR3tcXJyRZG6++Wan/pMmTTKSTGZmpqMtNDTU1KxZ0/zwww+OtqSkJCPJBAUFOf7ujDFm3bp1RpJZv359iWvMk/f388svvzi1h4aGmurVq5vDhw87td99993Gbreb1NRUp/ZBgwaZWrVqmRMnThhj/vrFfmmwyzvWiRMnOrXfeuutxs/Pr9T1553r4OBgc/r0aUd7VlaW8fPzM9dff72jbeDAgaZx48ZO59kYY8aPH2+8vLzMb7/95lT74MGDnfq9//77jvBsjDG//fabsdvtZtiwYU79EhMTjaRyD0AluTbr1KljJk2aVMjZglVxCwyW1LZtW3Xo0MGpbfjw4crKytL+/fsrvZ6PP/5Y58+f16hRo3T+/HnHy8vLS3369HG6FSD9OfU/ZMgQp7b27dvr+++/dywnJCTI29tbN9xwg1O/e+65p9T19e3bV97e3o7lgIAA+fv7O+2vpAYPHqxq1f76pyc8PFyS8j1snteemprq1N6xY0c1atQoX7/IyEjVqlUrX3tZaixK+/bt1bJlS6e27du3q1+/fmrSpIlT+5gxY/THH38oMTHRqf3SdyEWdQ5+++23Mt0Gk6Tbb79dXl5ejmVvb28NGTJEO3fu1IULF3TmzBl98sknuu2221SrVi2na2/w4ME6c+aMdu/e7bTNm2++2Wm5ffv2kv46z7t371ZOTo7uuusup349evSokHdxleTa7Natm5YtW6Z//vOf2r17t86dO1fudaDqIQDBkgIDAwttO378eGWXo59//lmS1LVrV3l6ejq9Vq1apV9//dWpf61atZx+sUl/PrR95swZx/Lx48cVEBCQb18FtRWnfv36+drsdrtOnz5d6m35+fk5LdeoUaPI9ouPqTzGX66goKB8bcePHy+wPTg42LH+YpV1DIVd52fPntXJkyd1/PhxnT9/Xq+++mq+627w4MGSlO/au/RasNvtkuS4FvKOtbyuveKU5NpctWqVRo8erTfeeEMRERHy8/PTqFGjlJ6eXu71oOrgXWCwpIL+4ctrK+gf1IrWoEEDSdL//u//KjQ0tFy2Wb9+fX3++ef52vlH//IU9PlN9evX17Fjx/K1//TTT5L++vutbIVd5zVq1FCdOnXk6emp6tWra+TIkRo3blyB22jatGmp9pn385MX6i/d98WzQHkh/tKH9y8NXZerQYMGiouLU1xcnFJTU7V+/XpNmzZNGRkZ2rx5c7nuC1UHM0CwpEOHDumLL75wanvvvffk7e2tv/3tb5Vez8CBA+Xh4aFvv/1WXbp0KfBVWn369FF2drY2bdrk1L5y5cp8fS/9XzxKp1+/ftq+fbsj8ORZvny5atWq5bKPU1izZo3T7FF2drY+/PBD9erVS9WrV1etWrXUt29fHThwQO3bty/wuivtfwi6d+8uu92uVatWObXv3r073+3IvDD05ZdfOrWvX7++VPssjZCQEI0fP179+/d3ye1uuA9mgGBJwcHBuvnmmxUdHa2goCC988472rp1q+bMmeP0HEllCQsL07PPPqunn35a3333nW644QbVq1dPP//8sz7//HPVrl271B9UOHr0aL388ssaMWKE/vnPf+qqq67Spk2b9PHHH0uS03M47dq1kyTNmTNHgwYNUvXq1dW+fXvHLRgUbebMmfroo4/Ut29fPfPMM/Lz89O7776rDRs2aO7cufL19XVJXdWrV1f//v01efJk5ebmas6cOcrKynK6ll555RVde+216tWrlx555BGFhYUpOztbR44c0Ycffqjt27eXap9+fn6aPHmyYmNjVa9ePd1222364YcfFBMTo6CgIKfrrmvXrmrVqpWmTJmi8+fPq169elq7dq0+/fTTcjsHmZmZ6tu3r4YPH67WrVvL29tbe/bs0ebNm3X77beX235Q9RCAYEkdO3bU3//+d82cOVPJyckKDg7WSy+9pMcff9xlNUVFRalNmzZ65ZVXtGLFCuXk5CgwMFBdu3bVww8/XOrt1a5dW9u3b9ekSZP05JNPymazacCAAXrttdc0ePBgp693GD58uP7zn//otdde07PPPitjjFJSUir0qweuJK1atdKuXbv01FNPady4cTp9+rTCw8O1dOlSjRkzxmV1jR8/XmfOnNHEiROVkZGhtm3basOGDbrmmmscfdq0aaP9+/frH//4h6ZPn66MjAzVrVtXLVq0cDwHVFqzZs1S7dq1tWjRIi1dulStW7fWwoUL9fTTTztdd9WrV9eHH36o8ePH6+GHH5bdbtfdd9+t+fPnl9snsHt5eal79+56++23dfToUZ07d04hISGaOnWqnnzyyXLZB6ommzHGuLoIAJXnueee0/Tp05WamqrGjRu7uhxUgKNHj6pp06Z6/vnnNWXKFFeXI0lKSUlR69atNXPmTD311FOuLgdgBgi4ks2f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MwAAqPou6xmgffv26Z133tG7776rAwcOlHr8qVOn1KFDB82fP7/A9XPnztVLL72k+fPna8+ePQoMDFT//v2VnZ1d6DYTExM1bNgwjRw5Ul988YVGjhypu+66S5999lmp6wMAAFemMs0AZWRk6O6771Z8fLzq1q0rY4wyMzPVt29frVy5Ug0bNizRdgYNGqRBgwYVuM4Yo7i4OD399NOOZ47eeustBQQE6L333tNDDz1U4Li4uDj1799fUVFRkqSoqCglJCQoLi5OK1asKMPRAgCAK02ZZoAmTJigrKwsHTp0SL/99pt+//13ffXVV8rKytLEiRPLpbCUlBSlp6c7PVNkt9vVp08f7dq1q9BxiYmJ+Z5DGjhwYJFjAACAtZRpBmjz5s3atm2bwsPDHW1t2rTRggULyu0h6PT0dElSQECAU3tAQIC+//77IscVNCZvewXJyclRTk6OYzkrK6ssJQMAgCqiTAEoNzdXnp6e+do9PT2Vm5t72UVd7NLvHDPGFPk9ZGUZExsbq5iYmLIXCQCXIWzaBleXAFhOmW6BXXfddXrsscf0008/Odp+/PFHPf744+rXr1+5FBYYGChJ+WZuMjIy8s3wXDqutGOioqKUmZnpeKWlpV1G5QAAwN2VKQDNnz9f2dnZCgsLU/PmzXXVVVepadOmys7O1quvvlouhTVt2lSBgYHaunWro+3s2bNKSEhQz549Cx0XERHhNEaStmzZUuQYu90uHx8fpxcAALhylekWWJMmTbR//35t3bpV33zzjYwxatOmja6//vpSbefkyZM6cuSIYzklJUVJSUny8/NTSEiIJk2apOeee04tWrRQixYt9Nxzz6lWrVoaPny4Y8yoUaPUqFEjxcbGSpIee+wx9e7dW3PmzNEtt9yiDz74QNu2bdOnn35alkMFAABXoFIFoO3bt2v8+PHavXu3fHx81L9/f/Xv31+SlJmZqbZt22rRokXq1atXiba3d+9e9e3b17E8efJkSdLo0aO1bNkyPfnkkzp9+rQeffRRxwchbtmyRd7e3o4xqampqlbtr4msnj17auXKlZo+fbpmzJih5s2ba9WqVerevXtpDhUAAFzBShWA4uLi9OCDDxZ4i8jX11cPPfSQXnrppRIHoMjISBljCl1vs9kUHR2t6OjoQvvEx8fnaxs6dKiGDh1aohoAAID1lOoZoC+++EI3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vgUGAABQEQhAAADAcghAAADAcghAAADAcghAAADAcghAAADAcghAAADAcghAAADAcghAAADAcghAAADAcghAAADActz6y1BROfgOLwCA1TADBAAALIcABAAALIcABAAALIcABAAALIcABAAALIcABAAALIcABAAALIcABAAALIcABAAALIcABAAALIcABAAALIcABAAALIcABAAALIcABAAALIcABAAALIcABAAALIcABAAALMfD1QUAAFCUsGkbStTv6OwbK7gSXEmYAQIAAJZDAAIAAJZDAAIAAJbj1gEoNjZWXbt2lbe3t/z9/XXrrbfq8OHDRY6Jj4+XzWbL9/rmm28qqWoAAODu3DoAJSQkaNy4cdq9e7e2bt2q8+fPa8CAATp16lSxYw8fPqxjx445Xi1atKiEigEAQFXg1u8C27x5s9Py0qVL5e/vr3379ql3795FjvX391fdunUrsDoAAFBVufUM0KUyMzMlSX5+fsX27dSpk4KCgtSvXz/t2LGjoksDAABViFvPAF3MGKPJkyfr2muv1dVXX11ov6CgIC1evFidO3dWTk6O3n77bfXr10/x8fGFzhrl5OQoJyfHsZyVlVXu9QMAAPdRZQLQ+PHj9eWXX+rTTz8tsl+rVq3UqlUrx3JERITS0tL0wgsvFBqAYmNjFRMTU671AgAA91UlboFNmDBB69ev144dO9S4ceNSj+/Ro4eSk5MLXR8VFaXMzEzHKy0t7XLKBQAAbs6tZ4CMMZowYYLWrl2r+Ph4NW3atEzbOXDggIKCggpdb7fbZbfby1omAACoYtw6AI0bN07vvfeePvjgA3l7eys9PV2S5Ovrq5o1a0r6c/bmxx9/1PLlyyVJcXFxCgsLU9u2bXX27Fm98847Wr16tVavXu2y4wAAAO7FrQPQwoULJUmRkZFO7UuXLtWYMWMkSceOHVNqaqpj3dmzZzVlyhT9+OOPqlmzptq2basNGzZo8ODBlVU2AABwczZjjHF1Ee4mKytLvr6+yszMlI+Pj6vLqXAl/aZlAHBnfBs8SvP7u0o8BA0AAFCeCEAAAMBy3PoZIAAASqokt/NLcpusvLYD98YMEAAAsBwCEAAAsBwCEAAAsBwCEAAAsBwCEAAAsBwCEAAAsBwCEAAAsBwCEAAAsBwCEAAAsBwCEAAAsBwCEAAAsBwCEAAAsBwCEAAAsBwCEAAAsBwCEAAAsBwCEAAAsBwCEAAAsBwPVxdgRWHTNhTb5+jsG8tlOwCA8leZ//6W5PdBSZTX7x5321dZMQMEAAAshwAEAAAshwAEAAAshwAEAAAshwAEAAAshwAEAAAshwAEAAAshwAEAAAshwAEAAAshwAEAAAshwAEAAAshwAEAAAsp0oEoNdee01NmzaVl5eXOnfurH//+99F9k9ISFDnzp3l5eWlZs2aadGiRZVUKQAAqArcPgCtWrVKkyZN0tNPP60DBw6oV69eGjRokFJTUwvsn5KSosGDB6tXr146cOCAnnrqKU2cOFGrV6+u5MoBAIC7cvsA9NJLL+n+++/XAw88oPDwcMXFxalJkyZauHBhgf0XLVqkkJAQxcXFKTw8XA888IDuu+8+vfDCC5VcOQAAcFduHYDOnj2rffv2acCAAU7tAwYM0K5duwock5iYmK//wIEDtXfvXp07d67CagUAAFWHh6sLKMqvv/6qCxcuKCAgwKk9ICBA6enpBY5JT08vsP/58+f166+/KigoKN+YnJwc5eTkOJYzMzMlSVlZWZd7CAXKzfmj2D4l2XdJtgMA+EtV/Le1vH4XldfvHnfbV0HbNMYU29etA1Aem83mtGyMyddWXP+C2vPExsYqJiYmX3uTJk1KW2q58Y1z2a4B4IpVFf9trcyar5R9ZWdny9fXt8g+bh2AGjRooOrVq+eb7cnIyMg3y5MnMDCwwP4eHh6qX79+gWOioqI0efJkx3Jubq5+++031a9fv8ighT9lZWWpSZMmSktLk4+Pj6vLqXI4f5eH83f5OIeXh/N3ecrz/BljlJ2dreDg4GL7unUAqlGjhjp37qytW7fqtttuc7Rv3bpVt9xyS4FjIiIi9OGHHzq1bdmyRV26dJGnp2eBY+x2u+x2u1Nb3bp1L694C/Lx8eGH/zJw/i4P5+/ycQ4vD+fv8pTX+Stu5iePWz8ELUmTJ0/WG2+8oSVLlujrr7/W448/rtTUVD388MOS/py9GTVqlKP/ww8/rO+//16TJ0/W119/rSVLlujNN9/UlClTXHUIAADAzbj1DJAkDRs2TMePH9ezzz6rY8eO6eqrr9bGjRsVGhoqSTp27JjTZwI1bdpUGzdu1OOPP64FCxYoODhY8+bN0x133OGqQwAAAG7G7QOQJD366KN69NFHC1y3bNmyfG19+vTR/v37K7gq5LHb7Zo5c2a+24goGc7f5eH8XT7O4eXh/F0eV50/mynJe8UAAACuIG7/DBAAAEB5IwABAADLIQABAADLIQABAADLIQChRBYuXKj27ds7PqgqIiJCmzZtcqwfM2aMbDab06tHjx4urNi9xcbGymazadKkSY42Y4yio6MVHBysmjVrKjIyUocOHXJdkW6soPPHNVi06OjofOcnMDDQsZ7rr2jFnT+uv5L58ccfNWLECNWvX1+1atVSx44dtW/fPsf6yrwOCUAokcaNG2v27Nnau3ev9u7dq+uuu0633HKL04V5ww036NixY47Xxo0bXVix+9qzZ48WL16s9u3bO7XPnTtXL730kubPn689e/YoMDBQ/fv3V3Z2tosqdU+FnT+Ja7A4bdu2dTo/Bw8edKzj+iteUedP4vorzu+//65rrrlGnp6e2rRpk/7v//5PL774otM3L1TqdWiAMqpXr5554403jDHGjB492txyyy2uLagKyM7ONi1atDBbt241ffr0MY899pgxxpjc3FwTGBhoZs+e7eh75swZ4+vraxYtWuSiat1PYefPGK7B4sycOdN06NChwHVcf8Ur6vwZw/VXElOnTjXXXnttoesr+zpkBgilduHCBa1cuVKnTp1SRESEoz0+Pl7+/v5q2bKlHnzwQWVkZLiwSvc0btw43Xjjjbr++uud2lNSUpSenq4BAwY42ux2u/r06aNdu3ZVdpluq7Dzl4drsGjJyckKDg5W06ZNdffdd+u7776TxPVXUoWdvzxcf0Vbv369unTpojvvvFP+/v7q1KmTXn/9dcf6yr4Oq8QnQcM9HDx4UBERETpz5ozq1KmjtWvXqk2bNpKkQYMG6c4771RoaKhSUlI0Y8YMXXfdddq3bx+fjvr/rVy5Uvv379eePXvyrUtPT5ckBQQEOLUHBATo+++/r5T63F1R50/iGixO9+7dtXz5crVs2VI///yz/vnPf6pnz546dOgQ118JFHX+6tevz/VXAt99950WLlyoyZMn66mnntLnn3+uiRMnym63a9SoUZV/HZb7nBKuWDk5OSY5Odns2bPHTJs2zTRo0MAcOnSowL4//fST8fT0NKtXr67kKt1Tamqq8ff3N0lJSY62i2/h/Oc//zGSzE8//eQ07oEHHjADBw6szFLdUnHnryBcg0U7efKkCQgIMC+++CLXXxlcfP4K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", + "text/plain": [ + "
" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "%sqlplot histogram --table penguins --column bill_length_mm" + ] + }, + { + "cell_type": "markdown", + "id": "38d6711c", + "metadata": {}, + "source": [ + "## Clean up\n", + "\n", + "To ensure that the Python connector closes the session properly, execute `connection.close()` before `engine.dispose()`. This prevents the garbage collector from removing the resources required to communicate with Snowflake." + ] + }, + { + "cell_type": "code", + "execution_count": 16, + "id": "20db062a", + "metadata": {}, + "outputs": [], + "source": [ + "connection.close()\n", + "engine.dispose()" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3 (ipykernel)", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.10.11" + }, + "varInspector": { + "cols": { + "lenName": 16, + "lenType": 16, + "lenVar": 40 + }, + "kernels_config": { + "python": { + "delete_cmd_postfix": "", + "delete_cmd_prefix": "del ", + "library": "var_list.py", + "varRefreshCmd": "print(var_dic_list())" + }, + "r": { + "delete_cmd_postfix": ") ", + "delete_cmd_prefix": "rm(", + "library": "var_list.r", + "varRefreshCmd": "cat(var_dic_list()) " + } + }, + "types_to_exclude": [ + "module", + "function", + "builtin_function_or_method", + "instance", + "_Feature" + ], + "window_display": false + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} \ No newline at end of file diff --git a/doc/integrations/spark.ipynb b/doc/integrations/spark.ipynb new file mode 100644 index 000000000..e199f89c2 --- /dev/null +++ b/doc/integrations/spark.ipynb @@ -0,0 +1,1386 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Spark\n", + "\n", + "```{versionadded} 0.10.7\n", + "```\n", + "\n", + "This tutorial will show you how to get a Spark instance up and running locally to integrate with JupySQL. You can run this in a Jupyter notebook. We'll use [Spark Connect](https://spark.apache.org/docs/latest/api/python/getting_started/quickstart_connect.html) which is the new thin client for Spark" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Pre-requisites\n", + "\n", + "To run this tutorial, you need to install following Python packages:" + ] + }, + { + "cell_type": "code", + "execution_count": 14, + "metadata": { + "tags": [] + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Note: you may need to restart the kernel to use updated packages.\n" + ] + } + ], + "source": [ + "%pip install jupysql pyspark==3.4.1 arrow pyarrow==12.0.1 pandas grpcio-status --quiet" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Start Spark instance\n", + "\n", + "We'll use a the [sparglim](https://github.com/Wh1isper/sparglim) Docker image to ease setup:" + ] + }, + { + "cell_type": "code", + "execution_count": 15, + "metadata": { + "tags": [] + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "12f699ee8e8e35ab10186f3c39024a7e443691bb4213e56ca3c2e90cd80daf1b\n" + ] + } + ], + "source": [ + "%%bash\n", + "docker run -p 15002:15002 -p 4040:4040 -d --name spark wh1isper/sparglim-server" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Our database is running, let's load some data!" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Load sample data\n", + "\n", + "Now, let's fetch some sample data. We'll be using the [NYC taxi dataset](https://www.nyc.gov/site/tlc/about/tlc-trip-record-data.page):" + ] + }, + { + "cell_type": "code", + "execution_count": 16, + "metadata": { + "tags": [] + }, + "outputs": [], + "source": [ + "import pandas as pd\n", + "from pyspark.sql.connect.session import SparkSession\n", + "\n", + "spark = SparkSession.builder.remote(\"sc://localhost\").getOrCreate()\n", + "\n", + "df = pd.read_parquet(\n", + " \"https://d37ci6vzurychx.cloudfront.net/trip-data/yellow_tripdata_2021-01.parquet\"\n", + ")\n", + "sparkDf = spark.createDataFrame(df.head(10000))" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Set [eagerEval](https://spark.apache.org/docs/latest/api/python/getting_started/quickstart_df.html#Viewing-Data) on to print dataframes, This makes Spark print dataframes eagerly in notebook environments, rather than it's default lazy execution which requires .show() to see the data. In Spark 3.4.1 we need to override, as below, but in 3.5.0 it will print in html. " + ] + }, + { + "cell_type": "code", + "execution_count": 17, + "metadata": {}, + "outputs": [], + "source": [ + "def __pretty_(self, p, cycle):\n", + " self.show(truncate=False)\n", + "\n", + "\n", + "from pyspark.sql.connect.dataframe import DataFrame\n", + "\n", + "DataFrame._repr_pretty_ = __pretty_\n", + "spark.conf.set(\"spark.sql.repl.eagerEval.enabled\", True)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Add dataset to temporary view to allow querying:" + ] + }, + { + "cell_type": "code", + "execution_count": 18, + "metadata": { + "tags": [] + }, + "outputs": [], + "source": [ + "sparkDf.createOrReplaceTempView(\"taxi\")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Query\n", + "\n", + "Now, let's start JupySQL, authenticate, and query the data!" + ] + }, + { + "cell_type": "code", + "execution_count": 19, + "metadata": { + "tags": [] + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "The sql extension is already loaded. To reload it, use:\n", + " %reload_ext sql\n" + ] + } + ], + "source": [ + "%load_ext sql" + ] + }, + { + "cell_type": "code", + "execution_count": 20, + "metadata": { + "tags": [] + }, + "outputs": [], + "source": [ + "%sql spark" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "```{important}\n", + "If the cell above fails, you might have some missing packages. Message us on [Slack](https://ploomber.io/community) and we'll help you!\n", + "```" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "List the tables in the database:" + ] + }, + { + "cell_type": "code", + "execution_count": 22, + "metadata": { + "tags": [] + }, + "outputs": [ + { + "data": { + "text/html": [ + "Running query in 'SparkSession'" + ], + "text/plain": [ + "Running query in 'SparkSession'" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "text/html": [ + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
namespaceviewNameisTemporary
taxiTrue
" + ], + "text/plain": [ + "+-----------+----------+-------------+\n", + "| namespace | viewName | isTemporary |\n", + "+-----------+----------+-------------+\n", + "| | taxi | True |\n", + "+-----------+----------+-------------+" + ] + }, + "execution_count": 22, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "%sql show views in default" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "We can turn on `lazy_spark` to avoid executing spark plan and return a Spark Dataframe" + ] + }, + { + "cell_type": "code", + "execution_count": 23, + "metadata": {}, + "outputs": [], + "source": [ + "%config SqlMagic.lazy_execution = True" + ] + }, + { + "cell_type": "code", + "execution_count": 8, + "metadata": {}, + "outputs": [ + { + "data": { + "text/html": [ + "Running query in 'SparkSession'" + ], + "text/plain": [ + "Running query in 'SparkSession'" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "+---------+--------+-----------+\n", + "|namespace|viewName|isTemporary|\n", + "+---------+--------+-----------+\n", + "| |taxi |true |\n", + "+---------+--------+-----------+\n", + "\n" + ] + }, + { + "data": { + "text/plain": [] + }, + "execution_count": 8, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "%sql show views in default" + ] + }, + { + "cell_type": "code", + "execution_count": 24, + "metadata": {}, + "outputs": [], + "source": [ + "%config SqlMagic.lazy_execution = False" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "List columns in the taxi table:" + ] + }, + { + "cell_type": "code", + "execution_count": 25, + "metadata": { + "tags": [] + }, + "outputs": [ + { + "data": { + "text/html": [ + "Running query in 'SparkSession'" + ], + "text/plain": [ + "Running query in 'SparkSession'" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "root\n", + " |-- VendorID: long (nullable = true)\n", + " |-- tpep_pickup_datetime: timestamp (nullable = true)\n", + " |-- tpep_dropoff_datetime: timestamp (nullable = true)\n", + " |-- passenger_count: double (nullable = true)\n", + " |-- trip_distance: double (nullable = true)\n", + " |-- RatecodeID: double (nullable = true)\n", + " |-- store_and_fwd_flag: string (nullable = true)\n", + " |-- PULocationID: long (nullable = true)\n", + " |-- DOLocationID: long (nullable = true)\n", + " |-- payment_type: long (nullable = true)\n", + " |-- fare_amount: double (nullable = true)\n", + " |-- extra: double (nullable = true)\n", + " |-- mta_tax: double (nullable = true)\n", + " |-- tip_amount: double (nullable = true)\n", + " |-- tolls_amount: double (nullable = true)\n", + " |-- improvement_surcharge: double (nullable = true)\n", + " |-- total_amount: double (nullable = true)\n", + " |-- congestion_surcharge: double (nullable = true)\n", + " |-- airport_fee: double (nullable = true)\n", + "\n" + ] + } + ], + "source": [ + "df = %sql select * from taxi\n", + "df.sqlaproxy.dataframe.printSchema()" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Query our data:" + ] + }, + { + "cell_type": "code", + "execution_count": 26, + "metadata": { + "tags": [] + }, + "outputs": [ + { + "data": { + "text/html": [ + "Running query in 'SparkSession'" + ], + "text/plain": [ + "Running query in 'SparkSession'" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "text/html": [ + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
count(1)
10000
" + ], + "text/plain": [ + "+----------+\n", + "| count(1) |\n", + "+----------+\n", + "| 10000 |\n", + "+----------+" + ] + }, + "execution_count": 26, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "%%sql\n", + "SELECT COUNT(*) FROM taxi" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Parameterize queries" + ] + }, + { + "cell_type": "code", + "execution_count": 27, + "metadata": { + "tags": [] + }, + "outputs": [], + "source": [ + "threshold = 10" + ] + }, + { + "cell_type": "code", + "execution_count": 28, + "metadata": { + "tags": [] + }, + "outputs": [ + { + "data": { + "text/html": [ + "Running query in 'SparkSession'" + ], + "text/plain": [ + "Running query in 'SparkSession'" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "text/html": [ + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
count(1)
9476
" + ], + "text/plain": [ + "+----------+\n", + "| count(1) |\n", + "+----------+\n", + "| 9476 |\n", + "+----------+" + ] + }, + "execution_count": 28, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "%%sql\n", + "SELECT COUNT(*) FROM taxi\n", + "WHERE trip_distance < {{threshold}}" + ] + }, + { + "cell_type": "code", + "execution_count": 29, + "metadata": { + "tags": [] + }, + "outputs": [], + "source": [ + "threshold = 0.5" + ] + }, + { + "cell_type": "code", + "execution_count": 30, + "metadata": { + "tags": [] + }, + "outputs": [ + { + "data": { + "text/html": [ + "Running query in 'SparkSession'" + ], + "text/plain": [ + "Running query in 'SparkSession'" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "text/html": [ + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
count(1)
642
" + ], + "text/plain": [ + "+----------+\n", + "| count(1) |\n", + "+----------+\n", + "| 642 |\n", + "+----------+" + ] + }, + "execution_count": 30, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "%%sql\n", + "SELECT COUNT(*) FROM taxi\n", + "WHERE trip_distance < {{threshold}}" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## CTEs" + ] + }, + { + "cell_type": "code", + "execution_count": 31, + "metadata": { + "tags": [] + }, + "outputs": [ + { + "data": { + "text/html": [ + "Running query in 'SparkSession'" + ], + "text/plain": [ + "Running query in 'SparkSession'" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "text/html": [ + "Skipping execution..." + ], + "text/plain": [ + "Skipping execution..." + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "%%sql --save many_passengers --no-execute\n", + "SELECT *\n", + "FROM taxi\n", + "WHERE passenger_count > 3\n", + "-- remove top 1% outliers for better visualization\n", + "AND trip_distance < 18.93" + ] + }, + { + "cell_type": "code", + "execution_count": 32, + "metadata": { + "tags": [] + }, + "outputs": [ + { + "data": { + "text/html": [ + "Running query in 'SparkSession'" + ], + "text/plain": [ + "Running query in 'SparkSession'" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "text/html": [ + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
min(trip_distance)avg(trip_distance)max(trip_distance)
0.03.109138187221396318.46
" + ], + "text/plain": [ + "+--------------------+--------------------+--------------------+\n", + "| min(trip_distance) | avg(trip_distance) | max(trip_distance) |\n", + "+--------------------+--------------------+--------------------+\n", + "| 0.0 | 3.1091381872213963 | 18.46 |\n", + "+--------------------+--------------------+--------------------+" + ] + }, + "execution_count": 32, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "%%sql --save trip_stats --with many_passengers\n", + "SELECT MIN(trip_distance), AVG(trip_distance), MAX(trip_distance)\n", + "FROM many_passengers" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "This is what JupySQL executes:" + ] + }, + { + "cell_type": "code", + "execution_count": 33, + "metadata": { + "scrolled": true, + "tags": [] + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "WITH `many_passengers` AS (\n", + "SELECT *\n", + "FROM taxi\n", + "WHERE passenger_count > 3\n", + "\n", + "AND trip_distance < 18.93)\n", + "SELECT MIN(trip_distance), AVG(trip_distance), MAX(trip_distance)\n", + "FROM many_passengers\n" + ] + } + ], + "source": [ + "query = %sqlcmd snippets trip_stats\n", + "print(query)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Profiling" + ] + }, + { + "cell_type": "code", + "execution_count": 34, + "metadata": {}, + "outputs": [ + { + "data": { + "text/html": [ + "
Following statistics are not available in\n", + " SparkSession: STD, 25%, 50%, 75%
\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
VendorIDtpep_pickup_datetimetpep_dropoff_datetimepassenger_counttrip_distanceRatecodeIDstore_and_fwd_flagPULocationIDDOLocationIDpayment_typefare_amountextramta_taxtip_amounttolls_amountimprovement_surchargetotal_amountcongestion_surchargeairport_fee
count1000010000100001000010000100001000010000100001000010000100001000010000100001000010000100000
unique287668745712436217323042288350418395930
topnan2021-01-01 00:41:192021-01-02 00:00:00nannannanNnannannannannannannannannannannanNone
freqnan47nannannan9808nannannannannannannannannannannan0
mean1.6901nannan1.50803.10021.0712nan158.5551154.72961.381911.88220.82590.48641.78460.22460.294516.96962.1063nan
std0.4625nannan1.13543.59701.0755nan70.928875.25040.555210.84201.11670.10412.43511.27300.057012.50230.9562nan
min1nannan0.00.01.0nan111-100.0-0.5-0.5-1.07-6.12-0.3-100.3-2.5nan
25%1.0000nannan1.00001.04001.0000nan100.000083.00001.00006.00000.00000.50000.00000.00000.300010.30002.5000nan
50%2.0000nannan1.00001.93001.0000nan152.0000151.00001.00008.50000.50000.50001.54000.00000.300013.55002.5000nan
75%2.0000nannan2.00003.60001.0000nan234.0000234.00002.000013.50002.50000.50002.65000.00000.300019.30002.5000nan
max2nannan6.045.9299.0nan2652654121.03.50.580.025.50.3137.762.5nan
" + ], + "text/plain": [ + "+--------+----------+----------------------+-----------------------+-----------------+---------------+------------+--------------------+--------------+--------------+--------------+-------------+--------+---------+------------+--------------+-----------------------+--------------+----------------------+-------------+\n", + "| | VendorID | tpep_pickup_datetime | tpep_dropoff_datetime | passenger_count | trip_distance | RatecodeID | store_and_fwd_flag | PULocationID | DOLocationID | payment_type | fare_amount | extra | mta_tax | tip_amount | tolls_amount | improvement_surcharge | total_amount | congestion_surcharge | airport_fee |\n", + "+--------+----------+----------------------+-----------------------+-----------------+---------------+------------+--------------------+--------------+--------------+--------------+-------------+--------+---------+------------+--------------+-----------------------+--------------+----------------------+-------------+\n", + "| count | 10000 | 10000 | 10000 | 10000 | 10000 | 10000 | 10000 | 10000 | 10000 | 10000 | 10000 | 10000 | 10000 | 10000 | 10000 | 10000 | 10000 | 10000 | 0 |\n", + "| unique | 2 | 8766 | 8745 | 7 | 1243 | 6 | 2 | 173 | 230 | 4 | 228 | 8 | 3 | 504 | 18 | 3 | 959 | 3 | 0 |\n", + "| top | nan | 2021-01-01 00:41:19 | 2021-01-02 00:00:00 | nan | nan | nan | N | nan | nan | nan | nan | nan | nan | nan | nan | nan | nan | nan | None |\n", + "| freq | nan | 4 | 7 | nan | nan | nan | 9808 | nan | nan | nan | nan | nan | nan | nan | nan | nan | nan | nan | 0 |\n", + "| mean | 1.6901 | nan | nan | 1.5080 | 3.1002 | 1.0712 | nan | 158.5551 | 154.7296 | 1.3819 | 11.8822 | 0.8259 | 0.4864 | 1.7846 | 0.2246 | 0.2945 | 16.9696 | 2.1063 | nan |\n", + "| std | 0.4625 | nan | nan | 1.1354 | 3.5970 | 1.0755 | nan | 70.9288 | 75.2504 | 0.5552 | 10.8420 | 1.1167 | 0.1041 | 2.4351 | 1.2730 | 0.0570 | 12.5023 | 0.9562 | nan |\n", + "| min | 1 | nan | nan | 0.0 | 0.0 | 1.0 | nan | 1 | 1 | 1 | -100.0 | -0.5 | -0.5 | -1.07 | -6.12 | -0.3 | -100.3 | -2.5 | nan |\n", + "| 25% | 1.0000 | nan | nan | 1.0000 | 1.0400 | 1.0000 | nan | 100.0000 | 83.0000 | 1.0000 | 6.0000 | 0.0000 | 0.5000 | 0.0000 | 0.0000 | 0.3000 | 10.3000 | 2.5000 | nan |\n", + "| 50% | 2.0000 | nan | nan | 1.0000 | 1.9300 | 1.0000 | nan | 152.0000 | 151.0000 | 1.0000 | 8.5000 | 0.5000 | 0.5000 | 1.5400 | 0.0000 | 0.3000 | 13.5500 | 2.5000 | nan |\n", + "| 75% | 2.0000 | nan | nan | 2.0000 | 3.6000 | 1.0000 | nan | 234.0000 | 234.0000 | 2.0000 | 13.5000 | 2.5000 | 0.5000 | 2.6500 | 0.0000 | 0.3000 | 19.3000 | 2.5000 | nan |\n", + "| max | 2 | nan | nan | 6.0 | 45.92 | 99.0 | nan | 265 | 265 | 4 | 121.0 | 3.5 | 0.5 | 80.0 | 25.5 | 0.3 | 137.76 | 2.5 | nan |\n", + "+--------+----------+----------------------+-----------------------+-----------------+---------------+------------+--------------------+--------------+--------------+--------------+-------------+--------+---------+------------+--------------+-----------------------+--------------+----------------------+-------------+" + ] + }, + "execution_count": 34, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "%sqlcmd profile -t taxi" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Plotting" + ] + }, + { + "cell_type": "code", + "execution_count": 35, + "metadata": { + "tags": [] + }, + "outputs": [ + { + "data": { + "text/plain": [ + "" + ] + }, + "execution_count": 35, + "metadata": {}, + "output_type": "execute_result" + }, + { + "data": { + "image/png": "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VtOmTTVkyBBt27ZNISEheuedd5Sdna2FCxeWehtt27ZVv3799PrrrysvL0+33367kpOTdfDgwSu+d9GiRXr99dfVu3dvNW7cWCdPntRbb70lu92ue+65R9Lvl4wiIyP1/vvvq2nTpqpdu7Zatmypli1bqkuXLpo5c6bOnTunG264QevWrVNGRsZVzYX0+z1VL7zwgl588UV17txZDz74oHx8fLRt2zaFhYVp+vTpstvtmj9/vgYMGKB27dqpb9++CgoKUmZmplatWqU77rhDr7766lX3UBoTJkzQe++9p549e2rMmDGqXbu2Fi1apIyMDH3wwQclbgivaOXxGb300ktKT09XcnKyatasqdatW2vy5MmaNGmSHnroIfPnAagy3PeAGwDD+P3x7tjYWKNmzZqGJPMR/OJH2i/2CPulHruPjY011q5da7Ru3drw8fExmjdvbixbtqzMPZ05c8YYM2aMUadOHaNGjRrGfffdZxw+fPiKj93v2LHD6Nevn9GwYUPDx8fHCA4ONu69915j+/btLtvfvHmzERUVZXh7e7ts86effjJ69+5tBAYGGgEBAcbDDz9sHDlypMR+L/VY+8XmxTAM45133jFuueUWw8fHx6hVq5bRtWtXIykpyaVmw4YNRkxMjBEQEGD4+voajRs3NgYNGlSi99K43GP3sbGxF33P999/bzz00ENGYGCg4evra9x2223GypUrS/QoqcRneqmflUv1URp/5jNKS0szPD09jdGjR7ts8/z588att95qhIWFGSdOnHDpEXA3m2GU4912ANwmPDxcLVu21MqVK93dCgBcc7iHCAAAWB73EAEWcqUvZ/Tz8+MJIACWRCACLKRevXqXHR84cKASExMrpxkAqEIIRMB1ojTfRJ2UlHTZ8bCwsHLqBgCuLdxUDQAALI+bqgEAgOVxyawUioqKdOTIEdWsWZM/TAgAwDXCMAydPHlSYWFhV/yCUwJRKRw5cqTEX5EGAADXhsOHD6t+/fqXrSEQlULNmjUl/T6hdrvdzd0AAIDScDqdatCggfl7/HIIRKVQfJnMbrcTiAAAuMaU5nYXbqoGAACWRyACAACWRyACAACWRyACAACWRyACAACWRyACAACWRyACAACWRyACAACWRyACAACWRyACAACWRyACAACWRyACAACWRyACAACWRyACAACWRyACAACW5+nu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FAzD0KhRo7R8+XKtX79eERERLuNRUVHy8vJymd/9+/crMzOT+f0TioqKlJ+fz/yWg+7du2vXrl1KT083l/bt26t///7mv5nj8nXq1Cl9//33qlevXvn+DP+JG79RRS1ZssTw8fExEhMTjb179xrDhw83AgMDjaysLHe3dk06efKk8fXXXxtff/21Icl45ZVXjK+//tr48ccfDcMwjBkzZhiBgYHGRx99ZOzcudN44IEHjIiICOPMmTNu7rzqGzlypBEQEGBs3LjROHr0qLn89ttvZs2IESOMhg0bGuvXrze2b99uOBwOw+FwuLHra8uECROMlJQUIyMjw9i5c6cxYcIEw2azGevWrTMMg/mtCBc+ZWYYzPGf9cwzzxgbN240MjIyjC+//NKIjo426tata+Tk5BiGUX7zSyC6Tv3zn/80GjZsaHh7exu33XabsWXLFne3dM3asGGDIanEMnDgQMMwfn/0/m9/+5sREhJi+Pj4GN27dzf279/v3qavERebV0nGwoULzZozZ84YTz31lFGrVi2jevXqRu/evY2jR4+6r+lrzODBg41GjRoZ3t7eRlBQkNG9e3czDBkG81sR/hiImOM/55FHHjHq1atneHt7GzfccIPxyCOPGAcPHjTHy2t+bYZhGOVwBgsAAOCaxT1EAADA8ghEAADA8ghEAADA8ghEAADA8ghEAADA8ghEAADA8ghEAADA8ghEANwqISFBbdu2rdB93HnnnYqPjzdfh4eHa86cORW6TwDXFgIRgArxxxByKc8++6zL3yGqDNu2bdPw4cNLVUt4AqzB090NALAmwzBUWFgof39/+fv7V+q+g4KCKnV/AKo+zhABKHeDBg1SSkqK5s6dK5vNJpvNpsTERNlsNq1evVpRUVHy8fHRF198UeKS2aBBg9SrVy9NnTpVQUFBstvtGjFihAoKCkq179OnT+vxxx+Xv7+/6tWrp1mzZpWoufCsj2EYSkhIUMOGDeXj46OwsDCNGTNG0u9nuX788UeNHTvWPA5J+vXXX9WvXz/dcMMNql69ulq1aqX33nvPZR933nmnxowZo/Hjx6t27doKDQ1VQkKCS01ubq6efPJJhYSEyNfXVy1bttTKlSvN8S+++EKdO3eWn5+fGjRooDFjxuj06dOlmgcAZUMgAlDu5s6dK4fDoWHDhuno0aM6evSoGjRoIEmaMGGCZsyYoW+//VatW7e+6PuTk5P17bffauPGjXrvvff04YcfaurUqaXa93PPPaeUlBR99NFHWrdunTZu3KgdO3Zcsv6DDz7Q7Nmz9cYbb+jAgQNasWKFWrVqJUn68MMPVb9+fU2bNs08Dkk6e/asoqKitGrVKu3evVvDhw/XgAED9NVXX7lse9GiRapRo4a2bt2qmTNnatq0aUpKSpIkFRUVqWfPnvryyy/17rvvau/evZoxY4aqVasmSfr+++919913q0+fPtq5c6fef/99ffHFFxo1alSp5gFAGZXXX6MFgAv98S9+b9iwwZBkrFixwqVuypQpRps2bczXAwcONGrXrm2cPn3aXDd//nzD39/fKCwsvOw+T548aXh7extLly411/3666+Gn5+fSy+NGjUyZs+ebRiGYcyaNcto2rSpUVBQcNFtXlh7ObGxscYzzzxjvu7atavRqVMnl5pbb73VeP755w3DMIy1a9caHh4exv79+y+6vSFDhhjDhw93Wff5558bHh4expkzZ67YD4Cy4QwRgErVvn37K9a0adNG1atXN187HA6dOnVKhw8fvuz7vv/+exUUFKhDhw7mutq1a6tZs2aXfM/DDz+sM2fO6MYbb9SwYcO0fPlynT9//rL7KSws1IsvvqhWrVqpdu3a8vf319q1a5WZmelS98czYPXq1VNOTo4kKT09XfXr11fTpk0vuo9vvvlGiYmJ5j1W/v7+iomJUVFRkTIyMi7bH4Cy46ZqAJWqRo0a7m7BRYMGDbR//3599tlnSkpK0lNPPaWXX35ZKSkp8vLyuuh7Xn75Zc2dO1dz5sxRq1atVKNGDcXHx5e4z+mP77fZbCoqKpIk+fn5XbavU6dO6cknnzTvZ7pQw4YNy3KIAEqBQASgQnh7e6uwsPCq3vvNN9/ozJkzZmjYsmWL/P39zfuQLqVx48by8vLS1q1bzdBw4sQJfffdd+ratesl3+fn56f77rtP9913n+Li4tS8eXPt2rVL7dq1u+hxfPnll3rggQf02GOPSfr9fqDvvvtOkZGRpT7G1q1b66efftJ333130bNE7dq10969e9WkSZNSbxPA1eOSGYAKER4erq1bt+rQoUP65ZdfzDMjpVFQUKAhQ4Zo7969+vTTTzVlyhSNGjVKHh6X/0+Wv7+/hgwZoueee07r16/X7t27NWjQoMu+LzExUf/617+0e/du/fDDD3r33Xfl5+enRo0amcexadMm/fzzz/rll18kSTfddJOSkpK0efNmffvtt3ryySeVnZ1d6uOTpK5du6pLly7q06ePkpKSlJGRodWrV2vNmjWSpOeff16bN2/WqFGjlJ6ergMHDuijjz7ipmqgghCIAFSIZ599VtWqVVNkZKSCgoJK3F9zOd27d9dNN92kLl266JFHHtH9999f4pH1S3n55ZfVuXNn3XfffYqOjlanTp0UFRV1yfrAwEC99dZbuuOOO9S6dWt99tln+uSTT1SnTh1J0rRp03To0CE1btzY/P6iSZMmqV27doqJidGdd96p0NBQ9erVq9THV+yDDz7Qrbfeqn79+ikyMlLjx483z0a1bt1aKSkp+u6779S5c2fdcsstmjx5ssLCwsq8HwBXZjMMw3B3EwBQbNCgQcrNzdWKFSvc3QoAC+EMEQAAsDwCEYBrRmZmpstj6H9cynJZDgAuxCUzANeM8+fP69ChQ5ccDw8Pl6cnD88CKDsCEQAAsDwumQEAAMsjEAEAAMsjEAEAAMsjEAEAAMsjEAEAAMsjEAEAAMsjEAEAAMsjEAEAAMv7f+dZjQYV06HwAAAAAElFTkSuQmCC", + "text/plain": [ + "
" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "%sqlplot histogram --table taxi --column trip_distance --bins 10" + ] + }, + { + "cell_type": "code", + "execution_count": 36, + "metadata": { + "tags": [] + }, + "outputs": [ + { + "data": { + "text/plain": [ + "" + ] + }, + "execution_count": 36, + "metadata": {}, + "output_type": "execute_result" + }, + { + "data": { + "image/png": "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p379+mnEiBHq16+f4uLilJmZ6enSAHgAYQaAqWRmZmro0KFKTExUbm6uSktLlZubq8TERA0dOpRAAzRDFsMwDE8X0ZBKSkoUEhKi4uJi7s0EmJzValVcXJwSExO1dOlSeXn97/uYzWZTamqq8vLytGfPHk45ASZXn89vRmYAmEZOTo4KCgr01FNPOQQZSfLy8lJaWpry8/OVk5PjoQoBeAJhBoBpFBYWSpISEhLq3F7TXtMPQPNAmAFgGpGRkZKkvLy8OrfXtNf0A9A8EGYAmEZKSopiYmL04osvymazOWyz2WxKT09XbGysUlJSPFQhAE8gzAAwDW9vb82aNUvLly9Xamqqw9VMqampWr58uV577TUm/wLNDIvmATCVwYMHa/HixXrkkUfUq1cve3tsbKwWL16swYMHe7A6AJ7ApdkATIkVgIGmrT6f34zMADAlb29v9e3b19NlAGgEmDMDAABMjTADAABMjTADAABMjTADAABMjTADAABMjTADAABMjTADAABMjTADAABMjTADAABMjTADAABMjTADAABMjTADAABMjTADAABMjTADAABMjTADAABMjTADAABMjTADAABMjTADAABMjTADAABMjTADAABMjTADAABMjTADAABMjTADAABMjTADAABMjTADAABMjTADAABMjTADAABMjTADAABMjTADAABMjTADAABMjTADAABMjTADAABMjTADAABMrVGFmZdeekkWi0VTpkyxt508eVITJkxQaGioAgMDNWTIEB06dMhzRQIAgEal0YSZDRs26E9/+pOSkpIc2qdOnaply5bp448/1po1a3Tw4EENHjzYQ1UCAIDGplGEmRMnTuiuu+7SggUL1KZNG3t7cXGx3n77bc2ePVvXX3+9kpOTtXDhQq1du1br1q3zYMUAAKCxaBRhZsKECRo0aJD69+/v0L5p0yadOnXKoT0+Pl4dOnRQbm5unfuqrKxUSUmJww8AAGi6Wni6gA8//FCbN2/Whg0bam0rKiqSj4+PWrdu7dAeHh6uoqKiOveXnp6uGTNmNESpAACgEfLoyMz+/fv18MMP629/+5v8/Pzcss+0tDQVFxfbf/bv3++W/QIAgMbJo2Fm06ZNOnz4sLp3764WLVqoRYsWWrNmjV5//XW1aNFC4eHhqqqq0vHjxx2ed+jQIUVERNS5T19fXwUHBzv8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pvP4449rzpw5DmtctWjRQlOnTtUrr7ziwcoAuEt9Pr9dGpn5/PPPtXLlylrtK1eu1BdffOHKLgHAKY8//rheffVVhYaGasGCBSosLNSCBQsUGhqqV199VY8//rinSwRwgbk0MpOUlKSXXnpJN998s0P7ihUr9MQTT+j77793W4Hni5EZoOmoqqpSq1atFBoaqgMHDqhFi/9N+6uurlb79u119OhRlZWVycfHx4OVAjhfDT4ys2fPHnXt2rVWe3x8vH744QdXdgkA55SRkaHq6mo9//zzslgsys7O1gcffKDs7GxZLBbNnDlT1dXVysjI8HSpAC4gl65mCgkJ0Y8//qiYmBiH9h9++EGtWrVyR10AUEvNopwWi0VxcXEqKCiwb4uJidHTTz/t0A9A8+BSmLnttts0ZcoULVmyRJ06dZJ0Osg88sgjuvXWW91aIADUqPl7M3bsWA0aNEiPPfaY/P39VVFRoS+++EIPPPCAQz8AzYNLc2aKi4t10003aePGjWrfvr0k6cCBA0pJSVFmZqZat27t7jpdxpwZoOmoqKhQQECAWrRooaioKPtNbiWpQ4cOOnjwoKqrq1VeXi5/f38PVgrgfNXn89vl00xr167V6tWr9f3338vf319JSUnq3bu3SwUDgDPWr18v6fRk36KiIj3xxBMaM2aM3n77bYdLtdevX6++fft6sFIAF5JLYUY6fc76xhtv1I033ujOegDgjP79739LkmJjY7Vv3z69/PLLevnllyWdXmcmNjZW+fn59n4AmgeXw0xWVpaysrJ0+PBh2Ww2h23vvPPOeRcGAL925MgRSdJTTz2le++9VxkZGdq7d686deqk8ePHa9GiRRo3bpy9H4DmwaUwM2PGDM2cOVNXXnmlIiMjuVM2gAsiLCxMkpSZman77rtPU6ZMsW+z2WxaunSpQz8AzYNLYebNN9/UokWLdM8997i7HgA4o4svvljS6QU6U1NTlZaWpoSEBOXl5Sk9PV0rVqxw6AegeXApzFRVValXr17urgUAziolJUUxMTG66KKLtHXrVoe/Q7GxsUpOTtbRo0eVkpLiwSoBXGgurQB8//336/3333d3LQBwVt7e3po1a5Y2btyowsJCh20HDx7Uxo0b9dprr8nb29tDFQLwBJdGZk6ePKm33npL//jHP5SUlKSWLVs6bJ89e7ZbigOAM6msrDzr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", + "text/plain": [ + "
" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "%sqlplot boxplot --table taxi --column trip_distance" + ] + }, + { + "cell_type": "code", + "execution_count": 37, + "metadata": {}, + "outputs": [ + { + "data": { + "text/html": [ + "Removing NULLs, if there exists any from payment_type" + ], + "text/plain": [ + "Removing NULLs, if there exists any from payment_type" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "text/plain": [ + "" + ] + }, + "execution_count": 37, + "metadata": {}, + "output_type": "execute_result" + }, + { + "data": { + "image/png": "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t6/VqFEja9KkSdahQ4esv//971aDBg2sV155xbIsy3r99det9957zzp69KiVkpJiORwOa/Dgwa7z/+d//seKjIx0m/Pxxx+37rjjDtdjSdZPfvITa/Xq1VZGRoY1dOhQKywszBowYIC1adMm68CBA1afPn2sO++803XOjh07LLvdbiUmJlpHjx61tmzZYoWFhVlz5sxxm7d169bWypUrrcOHD1uPP/641ahRI+u7776zLl68aP3jH/+wJFkZGRnWyZMnrby8vGv+LPLy8iyHw2E9+uij1smTJ62TJ09aFy9eLHOPzZo1s1599VUrIyPDmjlzpuXt7W0dOHDAsizLKioqsiIiIqxHHnnE+vLLL60DBw5YDzzwgNW+fXursLCwrMsF4CoIRABuWN++fa2IiAirpKTEte/JJ5+0IiIirjp+z549liTrzJkzlmVZ1jfffGN5e3tbu3fvtizrP3/4mzdvbiUmJrrOkWTNnDnT9TglJcWSZL3++uuufW+99Zbl7+/vejxw4EDrz3/+s9tz/+1vf7NatWr1o/OePXvWkmS9//77lmVZ1ocffmhJsr7//vty/Twu/Xbu8vQ4fvx4t/N69+5tTZgwwVV7+/bt3X7OhYWFVv369a3NmzeXuT4AV+IlMwCVok+fPrLZbK7HDodDhw8fVnFxsVJTU/WrX/1Kbdq0UePGjdW3b19JUlZWliQpJCREMTExeuONNyRJ69evV2FhoX7961+7PUfnzp1d/w4KCpIkderUyW1fQUGBnE6nJOmLL77QvHnz1KhRI9f26KOP6uTJk/rhhx+uOm/Dhg1lt9tdL+lVlrL26HA4rnh88OBBVz9HjhxR48aNXf00bdpUBQUFV7xUCKB86nm6AAB1W0FBgaKjoxUdHa0333xTLVq0UFZWlqKjo1VUVOQaN3bsWI0aNUoJCQlavny57rvvPjVo0MBtLh8fH9e/S8PX1faVlJRIks6ePau5c+dq2LBhV9Tl7+9/1XlL5ymdozKVpcdrOXv2rLp3764333zzimMtWrSozFIB4xCIAFSK3bt3uz3etWuXbr75Zh06dEjfffedFixYoNDQUEnSZ599dsX5Q4YMUcOGDbV06VJt2rRJO3bsuOGaunXrpoyMDLVr167Cc/j6+kqSiouLy3XO1caXpcddu3bpwQcfdHt86623SvpPP6tXr1bLli1lt9vL2wqAa+AlMwCVIisrS1OmTFFGRobeeustvfDCC5o0aZLatGkjX19fvfDCC/rqq6+0bt06PfXUU1ec7+3trYceekgzZszQzTfffMVLRxUxa9Ys/fWvf9XcuXO1f/9+HTx4UKtWrdLMmTPLPEfbtm1ls9m0YcMGnTp1SmfPnr3uOWFhYdq9e7eOHTumb7/91nW1qSw9rlmzRm+88Yb+9a9/afbs2fr0008VFxcnSRo5cqSaN2+uu+++Wx999JEyMzO1fft2Pf744/r666/L3BOAKxGIAFSKBx98UOfPn1evXr0UGxurSZMmady4cWrRooUSExO1Zs0aRUZGasGCBVe8/bzUmDFjVFRUpIcffrhSaoqOjtaGDRu0ZcsW9ezZU3369FFCQoLatm1b5jl+8pOfaO7cuZo+fbqCgoJc4eRannjiCXl7eysyMtL1EmGp6/U4d+5crVq1Sp07d9Zf//pXvfXWW4qMjJQkNWjQQDt27FCbNm00bNgwRUREaMyYMSooKOCKEXCDbJb1/z4oBAAqqF+/furatauee+65G5rno48+0sCBA3X8+HHXTdN1zbV6tNlsWrt2rYYOHeqZ4gCDcQ8RAI8rLCzUqVOnNGfOHP3617+uk2HIhB6B2oyXzAB43FtvvaW2bdsqLy9PCxcu9HQ515SVleX2Nv7Lt0tfHrtUbeoRMBEvmQFAOVy8ePGaX5URFhamevW4+A7UNgQiAABgPF4yAwAAxiMQAQAA4xGIAACA8QhEAADAeAQiAABgPAIRAAAwHoEIAAAYj0AEAACM939faPJBMq+8vAAAAABJRU5ErkJggg==", + "text/plain": [ + "
" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "%sqlplot bar --table taxi --column payment_type" + ] + }, + { + "cell_type": "code", + "execution_count": 38, + "metadata": {}, + "outputs": [ + { + "data": { + "text/html": [ + "Removing NULLs, if there exists any from payment_type" + ], + "text/plain": [ + "Removing NULLs, if there exists any from payment_type" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "text/plain": [ + "" + ] + }, + "execution_count": 38, + "metadata": {}, + "output_type": "execute_result" + }, + { + "data": { + "image/png": "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", + "text/plain": [ + "
" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "%sqlplot pie --table taxi --column payment_type" + ] + }, + { + "cell_type": "code", + "execution_count": 39, + "metadata": {}, + "outputs": [], + "source": [ + "from sql.ggplot import ggplot, aes, geom_histogram" + ] + }, + { + "cell_type": "code", + "execution_count": 40, + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "" + ] + }, + "execution_count": 40, + "metadata": {}, + "output_type": "execute_result" + }, + { + "data": { + "image/png": "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VtOmTTVkyBBt27ZNISEheuedd5Sdna2FCxeWehtt27ZVv3799PrrrysvL0+33367kpOTdfDgwSu+d9GiRXr99dfVu3dvNW7cWCdPntRbb70lu92ue+65R9Lvl4wiIyP1/vvvq2nTpqpdu7Zatmypli1bqkuXLpo5c6bOnTunG264QevWrVNGRsZVzYX0+z1VL7zwgl588UV17txZDz74oHx8fLRt2zaFhYVp+vTpstvtmj9/vgYMGKB27dqpb9++CgoKUmZmplatWqU77rhDr7766lX3UBoTJkzQe++9p549e2rMmDGqXbu2Fi1apIyMDH3wwQclbgivaOXxGb300ktKT09XcnKyatasqdatW2vy5MmaNGmSHnroIfPnAagy3PeAGwDD+P3x7tjYWKNmzZqGJPMR/OJH2i/2CPulHruPjY011q5da7Ru3drw8fExmjdvbixbtqzMPZ05c8YYM2aMUadOHaNGjRrGfffdZxw+fPiKj93v2LHD6Nevn9GwYUPDx8fHCA4ONu69915j+/btLtvfvHmzERUVZXh7e7ts86effjJ69+5tBAYGGgEBAcbDDz9sHDlypMR+L/VY+8XmxTAM45133jFuueUWw8fHx6hVq5bRtWtXIykpyaVmw4YNRkxMjBEQEGD4+voajRs3NgYNGlSi99K43GP3sbGxF33P999/bzz00ENGYGCg4evra9x2223GypUrS/QoqcRneqmflUv1URp/5jNKS0szPD09jdGjR7ts8/z588att95qhIWFGSdOnHDpEXA3m2GU4912ANwmPDxcLVu21MqVK93dCgBcc7iHCAAAWB73EAEWcqUvZ/Tz8+MJIACWRCACLKRevXqXHR84cKASExMrpxkAqEIIRMB1ojTfRJ2UlHTZ8bCwsHLqBgCuLdxUDQAALI+bqgEAgOVxyawUioqKdOTIEdWsWZM/TAgAwDXCMAydPHlSYWFhV/yCUwJRKRw5cqTEX5EGAADXhsOHD6t+/fqXrSEQlULNmjUl/T6hdrvdzd0AAIDScDqdatCggfl7/HIIRKVQfJnMbrcTiAAAuMaU5nYXbqoGAACWRyACAACWRyACAACWRyACAACWRyACAACWRyACAACWRyACAACWRyACAACWRyACAACWRyACAACWRyACAACWRyACAACWRyACAACWRyACAACWRyACAACW5+nu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FAzD0KhRo7R8+XKtX79eERERLuNRUVHy8vJymd/9+/crMzOT+f0TioqKlJ+fz/yWg+7du2vXrl1KT083l/bt26t///7mv5nj8nXq1Cl9//33qlevXvn+DP+JG79RRS1ZssTw8fExEhMTjb179xrDhw83AgMDjaysLHe3dk06efKk8fXXXxtff/21Icl45ZVXjK+//tr48ccfDcMwjBkzZhiBgYHGRx99ZOzcudN44IEHjIiICOPMmTNu7rzqGzlypBEQEGBs3LjROHr0qLn89ttvZs2IESOMhg0bGuvXrze2b99uOBwOw+FwuLHra8uECROMlJQUIyMjw9i5c6cxYcIEw2azGevWrTMMg/mtCBc+ZWYYzPGf9cwzzxgbN240MjIyjC+//NKIjo426tata+Tk5BiGUX7zSyC6Tv3zn/80GjZsaHh7exu33XabsWXLFne3dM3asGGDIanEMnDgQMMwfn/0/m9/+5sREhJi+Pj4GN27dzf279/v3qavERebV0nGwoULzZozZ84YTz31lFGrVi2jevXqRu/evY2jR4+6r+lrzODBg41GjRoZ3t7eRlBQkNG9e3czDBkG81sR/hiImOM/55FHHjHq1atneHt7GzfccIPxyCOPGAcPHjTHy2t+bYZhGOVwBgsAAOCaxT1EAADA8ghEAADA8ghEAADA8ghEAADA8ghEAADA8ghEAADA8ghEAADA8ghEANwqISFBbdu2rdB93HnnnYqPjzdfh4eHa86cORW6TwDXFgIRgArxxxByKc8++6zL3yGqDNu2bdPw4cNLVUt4AqzB090NALAmwzBUWFgof39/+fv7V+q+g4KCKnV/AKo+zhABKHeDBg1SSkqK5s6dK5vNJpvNpsTERNlsNq1evVpRUVHy8fHRF198UeKS2aBBg9SrVy9NnTpVQUFBstvtGjFihAoKCkq179OnT+vxxx+Xv7+/6tWrp1mzZpWoufCsj2EYSkhIUMOGDeXj46OwsDCNGTNG0u9nuX788UeNHTvWPA5J+vXXX9WvXz/dcMMNql69ulq1aqX33nvPZR933nmnxowZo/Hjx6t27doKDQ1VQkKCS01ubq6efPJJhYSEyNfXVy1bttTKlSvN8S+++EKdO3eWn5+fGjRooDFjxuj06dOlmgcAZUMgAlDu5s6dK4fDoWHDhuno0aM6evSoGjRoIEmaMGGCZsyYoW+//VatW7e+6PuTk5P17bffauPGjXrvvff04YcfaurUqaXa93PPPaeUlBR99NFHWrdunTZu3KgdO3Zcsv6DDz7Q7Nmz9cYbb+jAgQNasWKFWrVqJUn68MMPVb9+fU2bNs08Dkk6e/asoqKitGrVKu3evVvDhw/XgAED9NVXX7lse9GiRapRo4a2bt2qmTNnatq0aUpKSpIkFRUVqWfPnvryyy/17rvvau/evZoxY4aqVasmSfr+++919913q0+fPtq5c6fef/99ffHFFxo1alSp5gFAGZXXX6MFgAv98S9+b9iwwZBkrFixwqVuypQpRps2bczXAwcONGrXrm2cPn3aXDd//nzD39/fKCwsvOw+T548aXh7extLly411/3666+Gn5+fSy+NGjUyZs+ebRiGYcyaNcto2rSpUVBQcNFtXlh7ObGxscYzzzxjvu7atavRqVMnl5pbb73VeP755w3DMIy1a9caHh4exv79+y+6vSFDhhjDhw93Wff5558bHh4expkzZ67YD4Cy4QwRgErVvn37K9a0adNG1atXN187HA6dOnVKhw8fvuz7vv/+exUUFKhDhw7mutq1a6tZs2aXfM/DDz+sM2fO6MYbb9SwYcO0fPlynT9//rL7KSws1IsvvqhWrVqpdu3a8vf319q1a5WZmelS98czYPXq1VNOTo4kKT09XfXr11fTpk0vuo9vvvlGiYmJ5j1W/v7+iomJUVFRkTIyMi7bH4Cy46ZqAJWqRo0a7m7BRYMGDbR//3599tlnSkpK0lNPPaWXX35ZKSkp8vLyuuh7Xn75Zc2dO1dz5sxRq1atVKNGDcXHx5e4z+mP77fZbCoqKpIk+fn5XbavU6dO6cknnzTvZ7pQw4YNy3KIAEqBQASgQnh7e6uwsPCq3vvNN9/ozJkzZmjYsmWL/P39zfuQLqVx48by8vLS1q1bzdBw4sQJfffdd+ratesl3+fn56f77rtP9913n+Li4tS8eXPt2rVL7dq1u+hxfPnll3rggQf02GOPSfr9fqDvvvtOkZGRpT7G1q1b66efftJ333130bNE7dq10969e9WkSZNSbxPA1eOSGYAKER4erq1bt+rQoUP65ZdfzDMjpVFQUKAhQ4Zo7969+vTTTzVlyhSNGjVKHh6X/0+Wv7+/hgwZoueee07r16/X7t27NWjQoMu+LzExUf/617+0e/du/fDDD3r33Xfl5+enRo0amcexadMm/fzzz/rll18kSTfddJOSkpK0efNmffvtt3ryySeVnZ1d6uOTpK5du6pLly7q06ePkpKSlJGRodWrV2vNmjWSpOeff16bN2/WqFGjlJ6ergMHDuijjz7ipmqgghCIAFSIZ599VtWqVVNkZKSCgoJK3F9zOd27d9dNN92kLl266JFHHtH9999f4pH1S3n55ZfVuXNn3XfffYqOjlanTp0UFRV1yfrAwEC99dZbuuOOO9S6dWt99tln+uSTT1SnTh1J0rRp03To0CE1btzY/P6iSZMmqV27doqJidGdd96p0NBQ9erVq9THV+yDDz7Qrbfeqn79+ikyMlLjx483z0a1bt1aKSkp+u6779S5c2fdcsstmjx5ssLCwsq8HwBXZjMMw3B3EwBQbNCgQcrNzdWKFSvc3QoAC+EMEQAAsDwCEYBrRmZmpstj6H9cynJZDgAuxCUzANeM8+fP69ChQ5ccDw8Pl6cnD88CKDsCEQAAsDwumQEAAMsjEAEAAMsjEAEAAMsjEAEAAMsjEAEAAMsjEAEAAMsjEAEAAMsjEAEAAMv7f+dZjQYV06HwAAAAAElFTkSuQmCC", + "text/plain": [ + "
" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "(ggplot(table=\"taxi\", mapping=aes(x=\"trip_distance\")) + geom_histogram(bins=10))" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Clean up\n", + "\n", + "To stop and remove the container:" + ] + }, + { + "cell_type": "code", + "execution_count": 41, + "metadata": { + "tags": [] + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES\n", + "12f699ee8e8e wh1isper/sparglim-server \"tini -- sparglim-se…\" About a minute ago Up About a minute 0.0.0.0:4040->4040/tcp, 0.0.0.0:15002->15002/tcp spark\n", + "f019407c6426 docker.dev.slicelife.com/onelogin-aws-assume-role:stable \"onelogin-aws-assume…\" 2 weeks ago Up 2 weeks heuristic_tu\n" + ] + } + ], + "source": [ + "! docker container ls" + ] + }, + { + "cell_type": "code", + "execution_count": 42, + "metadata": { + "tags": [] + }, + "outputs": [], + "source": [ + "%%capture out\n", + "! docker container ls --filter ancestor=wh1isper/sparglim-server --quiet" + ] + }, + { + "cell_type": "code", + "execution_count": 43, + "metadata": { + "tags": [] + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Container id: 12f699ee8e8e\n" + ] + } + ], + "source": [ + "container_id = out.stdout.strip()\n", + "print(f\"Container id: {container_id}\")" + ] + }, + { + "cell_type": "code", + "execution_count": 44, + "metadata": { + "tags": [] + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "12f699ee8e8e\n" + ] + } + ], + "source": [ + "! docker container stop {container_id}" + ] + }, + { + "cell_type": "code", + "execution_count": 45, + "metadata": { + "tags": [] + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "12f699ee8e8e\n" + ] + } + ], + "source": [ + "! docker container rm {container_id}" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3 (ipykernel)", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.10.12" + }, + "myst": { + "html_meta": { + "description lang=en": "Query using Spark SQL from Jupyter via JupySQL", + "keywords": "jupyter, sql, jupysql, spark", + "property=og:locale": "en_US" + } + }, + "vscode": { + "interpreter": { + "hash": "8de7291ac4f217ed756f77e1d71d41823fff9c4ffb13df0a183e9309929ad9aa" + } + }, + "widgets": { + "application/vnd.jupyter.widget-state+json": { + "state": {}, + "version_major": 2, + "version_minor": 0 + } + } + }, + "nbformat": 4, + "nbformat_minor": 4 +} diff --git a/doc/integrations/trinodb.ipynb b/doc/integrations/trinodb.ipynb new file mode 100644 index 000000000..464e50f33 --- /dev/null +++ b/doc/integrations/trinodb.ipynb @@ -0,0 +1,1348 @@ +{ + "cells": [ + { + "attachments": {}, + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Trino \n", + "\n", + "This tutorial will show you how to get a Trino (f.k.a PrestoSQL) instance up and running locally to test JupySQL. You can run this in a Jupyter notebook." + ] + }, + { + "attachments": {}, + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Pre-requisites\n", + "\n", + "To run this tutorial, you need to install following Python packages:" + ] + }, + { + "cell_type": "code", + "execution_count": 1, + "metadata": { + "tags": [] + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Note: you may need to restart the kernel to use updated packages.\n" + ] + } + ], + "source": [ + "%pip install jupysql trino pandas pyarrow --quiet" + ] + }, + { + "attachments": {}, + "cell_type": "markdown", + "metadata": {}, + "source": [ + "You also need a Trino connector. Here is the [supported connector](https://pypi.org/project/sqlalchemy-trino/). You can install it with:" + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "metadata": { + "tags": [] + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Note: you may need to restart the kernel to use updated packages.\n" + ] + } + ], + "source": [ + "%pip install sqlalchemy-trino --quiet" + ] + }, + { + "attachments": {}, + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Start Trino instance\n", + "\n", + "We fetch the official image, create a new database, and user (this will take a few seconds)." + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "metadata": { + "tags": [] + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "cba8365556d3f35dd56cfd06747276ef1c7b7661eb4268b74e665d8d4d44a7e7\n" + ] + } + ], + "source": [ + "%%bash\n", + "docker run -p 8080:8080 --name trino -d trinodb/trino" + ] + }, + { + "attachments": {}, + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Our database is running, let's load some data!" + ] + }, + { + "attachments": {}, + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Load sample data\n", + "\n", + "Now, let's fetch some sample data. We'll be using the [NYC taxi dataset](https://www.nyc.gov/site/tlc/about/tlc-trip-record-data.page):" + ] + }, + { + "cell_type": "code", + "execution_count": 4, + "metadata": { + "tags": [] + }, + "outputs": [ + { + "data": { + "text/plain": [ + "(1369769, 19)" + ] + }, + "execution_count": 4, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "import pandas as pd\n", + "\n", + "df = pd.read_parquet(\n", + " \"https://d37ci6vzurychx.cloudfront.net/trip-data/yellow_tripdata_2021-01.parquet\"\n", + ")\n", + "df.shape" + ] + }, + { + "attachments": {}, + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Trino has maximum query text length of 1000000. Therefore, writing the whole NYC taxi dataset (~1.4M rows) will throw errors. A workaround is to increase the [`http-server.max-request-size`](https://trino.io/docs/current/admin/properties-query-management.html#query-max-length) configuration parameter to Trino's maximum allowed characters of 1,000,000,000 in the Trino server configuration file (config.properties). We'll write a subset of the data instead:" + ] + }, + { + "cell_type": "code", + "execution_count": 5, + "metadata": {}, + "outputs": [], + "source": [ + "df = df.head(1000)" + ] + }, + { + "attachments": {}, + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Trino uses a schema named \"default\" to store tables. Therefore, `schema='default'` is required in the connection string." + ] + }, + { + "cell_type": "code", + "execution_count": 7, + "metadata": { + "tags": [] + }, + "outputs": [], + "source": [ + "from sqlalchemy import create_engine\n", + "\n", + "engine = create_engine(\n", + " \"trino://user@localhost:8080/memory\", connect_args={\"user\": \"user\"}\n", + ")\n", + "\n", + "df.to_sql(\n", + " con=engine,\n", + " name=\"taxi\",\n", + " schema=\"default\",\n", + " method=\"multi\",\n", + " index=False,\n", + ")\n", + "\n", + "engine.dispose()" + ] + }, + { + "attachments": {}, + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Query\n", + "\n", + "Now, let's start JupySQL, authenticate, and query the data!" + ] + }, + { + "cell_type": "code", + "execution_count": 8, + "metadata": { + "tags": [] + }, + "outputs": [ + { + "data": { + "text/html": [ + "Tip: You may define configurations in /Users/neelashasen/Dev/jupysql_master/jupysql/pyproject.toml. Please review our configuration guideline." + ], + "text/plain": [ + "" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "text/html": [ + "Connecting to 'default'" + ], + "text/plain": [ + "Connecting to 'default'" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "%load_ext sql" + ] + }, + { + "cell_type": "code", + "execution_count": 9, + "metadata": { + "tags": [] + }, + "outputs": [ + { + "data": { + "text/html": [ + "Connecting and switching to connection 'trino://user@localhost:8080/memory'" + ], + "text/plain": [ + "Connecting and switching to connection 'trino://user@localhost:8080/memory'" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "%sql trino://user@localhost:8080/memory" + ] + }, + { + "attachments": {}, + "cell_type": "markdown", + "metadata": {}, + "source": [ + "```{important}\n", + "If the cell above fails, you might have some missing packages. Message us on [Slack](https://ploomber.io/community) and we'll help you!\n", + "```" + ] + }, + { + "attachments": {}, + "cell_type": "markdown", + "metadata": {}, + "source": [ + "List the tables in the database:" + ] + }, + { + "cell_type": "code", + "execution_count": 10, + "metadata": { + "tags": [] + }, + "outputs": [ + { + "data": { + "text/html": [ + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
Name
taxi
" + ], + "text/plain": [ + "+------+\n", + "| Name |\n", + "+------+\n", + "| taxi |\n", + "+------+" + ] + }, + "execution_count": 10, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "%sqlcmd tables --schema default" + ] + }, + { + "attachments": {}, + "cell_type": "markdown", + "metadata": {}, + "source": [ + "List columns in the taxi table:" + ] + }, + { + "cell_type": "code", + "execution_count": 11, + "metadata": { + "tags": [] + }, + "outputs": [ + { + "data": { + "text/html": [ + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
nametypenullabledefault
vendoridBIGINTTrueNone
tpep_pickup_datetimeTIMESTAMPTrueNone
tpep_dropoff_datetimeTIMESTAMPTrueNone
passenger_countDOUBLETrueNone
trip_distanceDOUBLETrueNone
ratecodeidDOUBLETrueNone
store_and_fwd_flagVARCHARTrueNone
pulocationidBIGINTTrueNone
dolocationidBIGINTTrueNone
payment_typeBIGINTTrueNone
fare_amountDOUBLETrueNone
extraDOUBLETrueNone
mta_taxDOUBLETrueNone
tip_amountDOUBLETrueNone
tolls_amountDOUBLETrueNone
improvement_surchargeDOUBLETrueNone
total_amountDOUBLETrueNone
congestion_surchargeDOUBLETrueNone
airport_feeDOUBLETrueNone
" + ], + "text/plain": [ + "+-----------------------+-----------+----------+---------+\n", + "| name | type | nullable | default |\n", + "+-----------------------+-----------+----------+---------+\n", + "| vendorid | BIGINT | True | None |\n", + "| tpep_pickup_datetime | TIMESTAMP | True | None |\n", + "| tpep_dropoff_datetime | TIMESTAMP | True | None |\n", + "| passenger_count | DOUBLE | True | None |\n", + "| trip_distance | DOUBLE | True | None |\n", + "| ratecodeid | DOUBLE | True | None |\n", + "| store_and_fwd_flag | VARCHAR | True | None |\n", + "| pulocationid | BIGINT | True | None |\n", + "| dolocationid | BIGINT | True | None |\n", + "| payment_type | BIGINT | True | None |\n", + "| fare_amount | DOUBLE | True | None |\n", + "| extra | DOUBLE | True | None |\n", + "| mta_tax | DOUBLE | True | None |\n", + "| tip_amount | DOUBLE | True | None |\n", + "| tolls_amount | DOUBLE | True | None |\n", + "| improvement_surcharge | DOUBLE | True | None |\n", + "| total_amount | DOUBLE | True | None |\n", + "| congestion_surcharge | DOUBLE | True | None |\n", + "| airport_fee | DOUBLE | True | None |\n", + "+-----------------------+-----------+----------+---------+" + ] + }, + "execution_count": 11, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "%sqlcmd columns --table taxi --schema default" + ] + }, + { + "attachments": {}, + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Query our data:" + ] + }, + { + "cell_type": "code", + "execution_count": 12, + "metadata": { + "tags": [] + }, + "outputs": [ + { + "data": { + "text/html": [ + "Running query in 'trino://user@localhost:8080/memory'" + ], + "text/plain": [ + "Running query in 'trino://user@localhost:8080/memory'" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "text/html": [ + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
_col0
1000
" + ], + "text/plain": [ + "+-------+\n", + "| _col0 |\n", + "+-------+\n", + "| 1000 |\n", + "+-------+" + ] + }, + "execution_count": 12, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "%%sql\n", + "SELECT COUNT(*) FROM default.taxi" + ] + }, + { + "attachments": {}, + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Parameterize queries" + ] + }, + { + "cell_type": "code", + "execution_count": 13, + "metadata": { + "tags": [] + }, + "outputs": [], + "source": [ + "threshold = 10" + ] + }, + { + "cell_type": "code", + "execution_count": 14, + "metadata": { + "tags": [] + }, + "outputs": [ + { + "data": { + "text/html": [ + "Running query in 'trino://user@localhost:8080/memory'" + ], + "text/plain": [ + "Running query in 'trino://user@localhost:8080/memory'" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "text/html": [ + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
_col0
949
" + ], + "text/plain": [ + "+-------+\n", + "| _col0 |\n", + "+-------+\n", + "| 949 |\n", + "+-------+" + ] + }, + "execution_count": 14, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "%%sql\n", + "SELECT COUNT(*) FROM default.taxi\n", + "WHERE trip_distance < {{threshold}}" + ] + }, + { + "cell_type": "code", + "execution_count": 15, + "metadata": { + "tags": [] + }, + "outputs": [], + "source": [ + "threshold = 0.5" + ] + }, + { + "cell_type": "code", + "execution_count": 16, + "metadata": { + "tags": [] + }, + "outputs": [ + { + "data": { + "text/html": [ + "Running query in 'trino://user@localhost:8080/memory'" + ], + "text/plain": [ + "Running query in 'trino://user@localhost:8080/memory'" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "text/html": [ + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
_col0
64
" + ], + "text/plain": [ + "+-------+\n", + "| _col0 |\n", + "+-------+\n", + "| 64 |\n", + "+-------+" + ] + }, + "execution_count": 16, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "%%sql\n", + "SELECT COUNT(*) FROM default.taxi\n", + "WHERE trip_distance < {{threshold}}" + ] + }, + { + "attachments": {}, + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## CTEs" + ] + }, + { + "cell_type": "code", + "execution_count": 17, + "metadata": { + "tags": [] + }, + "outputs": [ + { + "data": { + "text/html": [ + "Running query in 'trino://user@localhost:8080/memory'" + ], + "text/plain": [ + "Running query in 'trino://user@localhost:8080/memory'" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "text/html": [ + "Skipping execution..." + ], + "text/plain": [ + "Skipping execution..." + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "%%sql --save many_passengers --no-execute\n", + "SELECT *\n", + "FROM default.taxi\n", + "WHERE passenger_count > 3\n", + "-- remove top 1% outliers for better visualization\n", + "AND trip_distance < 18.93" + ] + }, + { + "cell_type": "code", + "execution_count": 18, + "metadata": { + "tags": [] + }, + "outputs": [ + { + "data": { + "text/html": [ + "Running query in 'trino://user@localhost:8080/memory'" + ], + "text/plain": [ + "Running query in 'trino://user@localhost:8080/memory'" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "text/html": [ + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
_col0_col1_col2
0.253.1647058823529411.15
" + ], + "text/plain": [ + "+-------+------------------+-------+\n", + "| _col0 | _col1 | _col2 |\n", + "+-------+------------------+-------+\n", + "| 0.25 | 3.16470588235294 | 11.15 |\n", + "+-------+------------------+-------+" + ] + }, + "execution_count": 18, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "%%sql --save trip_stats --with many_passengers\n", + "SELECT MIN(trip_distance), AVG(trip_distance), MAX(trip_distance)\n", + "FROM many_passengers" + ] + }, + { + "attachments": {}, + "cell_type": "markdown", + "metadata": {}, + "source": [ + "This is what JupySQL executes:" + ] + }, + { + "cell_type": "code", + "execution_count": 19, + "metadata": { + "tags": [] + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "WITH many_passengers AS (\n", + "SELECT *\n", + "FROM default.taxi\n", + "WHERE passenger_count > 3\n", + "\n", + "AND trip_distance < 18.93)\n", + "SELECT MIN(trip_distance), AVG(trip_distance), MAX(trip_distance)\n", + "FROM many_passengers\n" + ] + } + ], + "source": [ + "query = %sqlcmd snippets trip_stats\n", + "print(query)" + ] + }, + { + "attachments": {}, + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Plotting" + ] + }, + { + "attachments": {}, + "cell_type": "markdown", + "metadata": {}, + "source": [ + "The %sqlplot magic command currently does not directly support the `--schema` option for specifying the schema name. To work around this, you can specify the schema in the SQL query itself." + ] + }, + { + "cell_type": "code", + "execution_count": 20, + "metadata": { + "tags": [] + }, + "outputs": [ + { + "data": { + "text/html": [ + "Running query in 'trino://user@localhost:8080/memory'" + ], + "text/plain": [ + "Running query in 'trino://user@localhost:8080/memory'" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "image/png": "iVBORw0KGgoAAAANSUhEUgAAAjsAAAHHCAYAAABZbpmkAAAAOXRFWHRTb2Z0d2FyZQBNYXRwbG90bGliIHZlcnNpb24zLjcuMiwgaHR0cHM6Ly9tYXRwbG90bGliLm9yZy8pXeV/AAAACXBIWXMAAA9hAAAPYQGoP6dpAABCz0lEQVR4nO3deVyVdf7//+dB4bjAAVEBzQV3xdQmnPSMZgskKlOaWFqOoTk5GbS4ZXwqtxbIJjVbdGpKbbVsWi1NxK0UNU2zXHDJQmPTDBCSRbh+f/jjfDuCikfg4NXjfrud23De7/e5rtd1cU08va73dR2LYRiGAAAATMrD3QUAAABUJ8IOAAAwNcIOAAAwNcIOAAAwNcIOAAAwNcIOAAAwNcIOAAAwNcIOAAAwNcIOAAAwNcIOUM2Cg4M1evRod5dhes8++6zatm2rOnXq6KqrrqqWdVgsFs2YMaNall1Z119/va6//nq31gBcbgg7wEVYvHixLBaLtm3bVmH/9ddfryuvvPKS1/PFF1+4/Y/q5WTVqlV6+OGH1adPHy1atEhPP/10uTHr1q2TxWKp1KumjB492mm93t7eatu2rYYNG6b//e9/Ki0trZL1bNq0STNmzFB2dnaVLA+43NR1dwGA2aWkpMjD4+L+XfHFF1/opZdeIvBU0po1a+Th4aHXXntNXl5eFY7p0qWL3nzzTae2uLg4eXt769FHH63Uek6dOqW6dav2P5tWq1X//e9/Hcv/+eef9dlnn2nYsGG6/vrr9cknn8hmsznGr1q16qLXsWnTJs2cOVOjR4+Wn59fVZUOXDYIO0A1s1qt7i7houXn56thw4buLqPSsrKyVL9+/XMGHUkKDAzUP/7xD6e2hIQENWnSpFz7H5WWlqqoqEj16tVTvXr1qqzmMnXr1i23/ieffFIJCQmKi4vTPffco/fee8/Rd75tBFAxLmMB1ezsOTvFxcWaOXOmOnTooHr16qlx48bq27evEhMTJZ25tPHSSy9JUoWXVvLz8zVp0iS1bNlSVqtVnTp10r///W8ZhuG03lOnTumBBx5QkyZN5OPjo1tuuUW//PJLuXknM2bMkMVi0Z49e3TnnXeqUaNG6tu3ryRp165dGj16tNq2bat69eopKChId999t3799VendZUtY//+/frHP/4hX19fNW3aVI8//rgMw9CRI0c0ePBg2Ww2BQUF6bnnnqvUvjt9+rSeeOIJtWvXTlarVcHBwfq///s/FRYWOsZYLBYtWrRI+fn5jn21ePHiSi2/IhaLRbGxsXr77bfVtWtXWa1WrVy50tFX0b7bt2+fbr/9dtlsNjVu3FgPPvigCgoKXK5Bkh555BH1799fy5Yt0/79+x3tFc3ZeeGFF9S1a1c1aNBAjRo1Us+ePfXOO+84apwyZYokqU2bNo599NNPP0mSFi1apBtvvFEBAQGyWq0KCQnRggULytUTHBysv//97/r66691zTXXqF69emrbtq3eeOONcmOzs7M1YcIEBQcHy2q1qkWLFrrrrrt0/Phxx5jCwkJNnz5d7du3l9VqVcuWLfXwww87/W6BqsKZHcAFOTk5Tv/hLlNcXHzBz86YMUPx8fH65z//qWuuuUa5ubnatm2bvv32W910003617/+pbS0NCUmJpa77GIYhm655RatXbtWY8eO1VVXXaUvv/xSU6ZM0S+//KK5c+c6xo4ePVrvv/++Ro0apd69e2v9+vWKjIw8Z1233XabOnTooKefftoRnBITE/Xjjz9qzJgxCgoK0u7du/XKK69o9+7d2rx5c7n5LcOHD1eXLl2UkJCgzz//XE8++aT8/f31n//8RzfeeKOeeeYZvf3225o8ebL++te/ql+/fufdV//85z+1ZMkSDRs2TJMmTdKWLVsUHx+vvXv36qOPPpIkvfnmm3rllVe0detWx+Wgv/3tbxf8PZzPmjVr9P777ys2NlZNmjRRcHDwecfffvvtCg4OVnx8vDZv3qz58+frt99+qzAIXIxRo0Zp1apVSkxMVMeOHSsc8+qrr+qBBx7QsGHDHCFr165d2rJli+68804NHTpU+/fv17vvvqu5c+eqSZMmkqSmTZtKkhYsWKCuXbvqlltuUd26dfXZZ5/pvvvuU2lpqWJiYpzWdfDgQQ0bNkxjx45VdHS0Xn/9dY0ePVqhoaHq2rWrJCkvL0/XXnut9u7dq7vvvltXX321jh8/rk8//VRHjx5VkyZNVFpaqltuuUVff/21xo0bpy5duuj777/X3LlztX//fn388ceXtN+AcgwAlbZo0SJD0nlfXbt2dfpM69atjejoaMf7Hj16GJGRkeddT0xMjFHR/z0//vhjQ5Lx5JNPOrUPGzbMsFgsxsGDBw3DMIzt27cbkoyHHnrIadzo0aMNScb06dMdbdOnTzckGXfccUe59f3+++/l2t59911DkrFhw4Zyyxg3bpyj7fTp00aLFi0Mi8ViJCQkONp/++03o379+k77pCI7d+40JBn//Oc/ndonT55sSDLWrFnjaIuOjjYaNmx43uVVpGvXrsZ1113n1CbJ8PDwMHbv3l1u/Ln23S233OI07r777jMkGd99991513+hunfs2GFIMiZMmOBou+6665xqHjx4cLlj7mzPPvusIck4fPhwub6KfscRERFG27Ztndpat25d7veelZVlWK1WY9KkSY62adOmGZKMDz/8sNxyS0tLDcMwjDfffNPw8PAwvvrqK6f+hQsXGpKMjRs3nnd7gIvFZSzABS+99JISExPLvbp3737Bz/r5+Wn37t06cODARa/3iy++UJ06dfTAAw84tU+aNEmGYWjFihWS5Ljsct999zmNu//++8+57HvvvbdcW/369R0/FxQU6Pjx4+rdu7ck6dtvvy03/p///Kfj5zp16qhnz54yDENjx451tPv5+alTp0768ccfz1mLdGZbJWnixIlO7ZMmTZIkff755+f9/KW47rrrFBISUunxZ58BKdvPZdvgKm9vb0nSyZMnzznGz89PR48e1TfffOPSOv74Oy47Y3ndddfpxx9/VE5OjtPYkJAQXXvttY73TZs2Lfe7/N///qcePXro1ltvLbeusjOBy5YtU5cuXdS5c2cdP37c8brxxhslSWvXrnVpW4Bz4TIW4IJrrrlGPXv2LNfeqFGjCi9v/dGsWbM0ePBgdezYUVdeeaUGDBigUaNGVSoo/fzzz2revLl8fHyc2rt06eLoL/tfDw8PtWnTxmlc+/btz7nss8dK0okTJzRz5kwtXbpUWVlZTn1n/yGUpFatWjm99/X1Vb169RyXTv7Yfva8n7OVbcPZNQcFBcnPz8+xrdWhon1xPh06dHB6365dO3l4eDjmxbgqLy9Pksr9vv9o6tSpWr16ta655hq1b99e/fv315133qk+ffpUah0bN27U9OnTlZycrN9//92pLycnR76+vo73Z/9+pTPH/G+//eZ4f+jQIUVFRZ13nQcOHNDevXsdl9LOdvaxBlwqwg5Qw/r166dDhw7pk08+0apVq/Tf//5Xc+fO1cKFC53OjNS0P/4Lv8ztt9+uTZs2acqUKbrqqqvk7e2t0tJSDRgwoMJnwNSpU6dSbZLKTag+l5p87k2ZivbFxaiqmn/44QdJ5w+pXbp0UUpKipYvX66VK1fqf//7n15++WVNmzZNM2fOPO/yDx06pLCwMHXu3Flz5sxRy5Yt5eXlpS+++EJz584t9zu+1N9lmdLSUnXr1k1z5sypsL9ly5YXtTzgQgg7gBv4+/trzJgxGjNmjPLy8tSvXz/NmDHDEXbO9ceydevWWr16tU6ePOn0r/19+/Y5+sv+t7S0VIcPH3Y663Dw4MFK1/jbb78pKSlJM2fO1LRp0xztrlx+c0XZNhw4cMBx5kqSMjMzlZ2d7djW2uDAgQNOZ4MOHjyo0tLSC05svpA333xTFotFN91003nHNWzYUMOHD9fw4cNVVFSkoUOH6qmnnlJcXJzq1at3zuPps88+U2FhoT799FOnszaXchmpXbt2jpB2vjHfffedwsLC3BJm8efDnB2ghp19+cbb21vt27d3uuW27Bk3Zz/xdtCgQSopKdGLL77o1D537lxZLBYNHDhQkhQRESFJevnll53GvfDCC5Wus+xf8Wf/q33evHmVXsalGDRoUIXrKzsbcL47y2pa2aMCypTt57LfhysSEhK0atUqDR8+vNxlsj86+3jy8vJSSEiIDMNw3B14ruOpot9xTk6OFi1a5HLdUVFR+u677xx3y/1R2Xpuv/12/fLLL3r11VfLjTl16pTy8/NdXj9QEc7sADUsJCRE119/vUJDQ+Xv769t27bpgw8+UGxsrGNMaGioJOmBBx5QRESE6tSpoxEjRujmm2/WDTfcoEcffVQ//fSTevTooVWrVumTTz7RQw89pHbt2jk+HxUVpXnz5unXX3913Hpe9ryWyvxr2mazqV+/fpo9e7aKi4t1xRVXaNWqVTp8+HA17JXyevTooejoaL3yyivKzs7Wddddp61bt2rJkiUaMmSIbrjhhhqpozIOHz6sW265RQMGDFBycrLeeust3XnnnerRo8cFP3v69Gm99dZbks5MAv/555/16aefateuXbrhhhv0yiuvnPfz/fv3V1BQkPr06aPAwEDt3btXL774oiIjIx1n/8qOp0cffVQjRoyQp6enbr75ZvXv319eXl66+eab9a9//Ut5eXl69dVXFRAQoPT0dJf2xZQpU/TBBx/otttu0913363Q0FCdOHFCn376qRYuXKgePXpo1KhRev/993Xvvfdq7dq16tOnj0pKSrRv3z69//77+vLLLyucEwe4zH03ggGXn7Jbz7/55psK+6+77roL3nr+5JNPGtdcc43h5+dn1K9f3+jcubPx1FNPGUVFRY4xp0+fNu6//36jadOmhsVicboN/eTJk8aECROM5s2bG56enkaHDh2MZ5991nFbb5n8/HwjJibG8Pf3N7y9vY0hQ4YYKSkphiSnW8HLbp8+duxYue05evSoceuttxp+fn6Gr6+vcdtttxlpaWnnvAX77GWc69bqivZTRYqLi42ZM2cabdq0MTw9PY2WLVsacXFxRkFBQaXWcyHnuvU8JiamwvHn2u49e/YYw4YNM3x8fIxGjRoZsbGxxqlTpy64/ujoaKfHFjRo0MAIDg42oqKijA8++MAoKSkp95mzbz3/z3/+Y/Tr189o3LixYbVajXbt2hlTpkwxcnJynD73xBNPGFdccYXh4eHhdBv6p59+anTv3t2oV6+eERwcbDzzzDPG66+/Xu5W9datW1f4yISz6zEMw/j111+N2NhY44orrjC8vLyMFi1aGNHR0cbx48cdY4qKioxnnnnG6Nq1q2G1Wo1GjRoZoaGhxsyZM8vVDlwqi2Fc5MwyAJetnTt36i9/+YveeustjRw50t3lXPZmzJihmTNn6tixY+XuOANQezBnBzCpU6dOlWubN2+ePDw8LvjkYgAwE+bsACY1e/Zsbd++XTfccIPq1q2rFStWaMWKFRo3bhy39gL4UyHsACb1t7/9TYmJiXriiSeUl5enVq1aacaMGXr00UfdXRoA1Cjm7AAAAFNjzg4AADA1wg4AADA15uzozPe0pKWlycfHh0eXAwBwmTAMQydPnlTz5s3l4XHu8zeEHUlpaWncnQIAwGXqyJEjatGixTn7CTuS45HqR44ckc1mc3M1AACgMnJzc9WyZUunL0auCGFH/+97gmw2G2EHAIDLzIWmoDBBGQAAmBphBwAAmJpbw05wcLAsFku5V0xMjCSpoKBAMTExaty4sby9vRUVFaXMzEynZaSmpioyMlINGjRQQECApkyZotOnT7tjcwAAQC3k1rDzzTffKD093fFKTEyUJN12222SpAkTJuizzz7TsmXLtH79eqWlpWno0KGOz5eUlCgyMlJFRUXatGmTlixZosWLF2vatGlu2R4AAFD71Kqvi3jooYe0fPlyHThwQLm5uWratKneeecdDRs2TJK0b98+denSRcnJyerdu7dWrFihv//970pLS1NgYKAkaeHChZo6daqOHTsmLy+vSq03NzdXvr6+ysnJYYIyAACXicr+/a41c3aKior01ltv6e6775bFYtH27dtVXFys8PBwx5jOnTurVatWSk5OliQlJyerW7dujqAjSREREcrNzdXu3bvPua7CwkLl5uY6vQAAgDnVmrDz8ccfKzs7W6NHj5YkZWRkyMvLS35+fk7jAgMDlZGR4Rjzx6BT1l/Wdy7x8fHy9fV1vHigIAAA5lVrws5rr72mgQMHqnnz5tW+rri4OOXk5DheR44cqfZ1AgAA96gVDxX8+eeftXr1an344YeOtqCgIBUVFSk7O9vp7E5mZqaCgoIcY7Zu3eq0rLK7tcrGVMRqtcpqtVbhFgAAgNqqVpzZWbRokQICAhQZGeloCw0Nlaenp5KSkhxtKSkpSk1Nld1ulyTZ7XZ9//33ysrKcoxJTEyUzWZTSEhIzW0AAACotdx+Zqe0tFSLFi1SdHS06tb9f+X4+vpq7Nixmjhxovz9/WWz2XT//ffLbrerd+/ekqT+/fsrJCREo0aN0uzZs5WRkaHHHntMMTExnLkBAACSakHYWb16tVJTU3X33XeX65s7d648PDwUFRWlwsJCRURE6OWXX3b016lTR8uXL9f48eNlt9vVsGFDRUdHa9asWTW5CQAAoBarVc/ZcReeswMAwOXnsnvODgAAQHUg7AAAAFNz+5wdswt+5HN3l3DRfkqIvPAgAAAuE5zZAQAApkbYAQAApkbYAQAApkbYAQAApkbYAQAApkbYAQAApkbYAQAApkbYAQAApkbYAQAApkbYAQAApkbYAQAApkbYAQAApkbYAQAApkbYAQAApkbYAQAApkbYAQAApkbYAQAApkbYAQAApkbYAQAApkbYAQAApkbYAQAApkbYAQAApkbYAQAApkbYAQAApkbYAQAApkbYAQAApkbYAQAApkbYAQAApkbYAQAApkbYAQAApkbYAQAApkbYAQAApkbYAQAApkbYAQAApkbYAQAApkbYAQAApkbYAQAApkbYAQAApub2sPPLL7/oH//4hxo3bqz69eurW7du2rZtm6PfMAxNmzZNzZo1U/369RUeHq4DBw44LePEiRMaOXKkbDab/Pz8NHbsWOXl5dX0pgAAgFrIrWHnt99+U58+feTp6akVK1Zoz549eu6559SoUSPHmNmzZ2v+/PlauHChtmzZooYNGyoiIkIFBQWOMSNHjtTu3buVmJio5cuXa8OGDRo3bpw7NgkAANQyFsMwDHet/JFHHtHGjRv11VdfVdhvGIaaN2+uSZMmafLkyZKknJwcBQYGavHixRoxYoT27t2rkJAQffPNN+rZs6ckaeXKlRo0aJCOHj2q5s2bX7CO3Nxc+fr6KicnRzabreo2UFLwI59X6fJqwk8Jke4uAQCAC6rs32+3ntn59NNP1bNnT912220KCAjQX/7yF7366quO/sOHDysjI0Ph4eGONl9fX/Xq1UvJycmSpOTkZPn5+TmCjiSFh4fLw8NDW7ZsqXC9hYWFys3NdXoBAABzcmvY+fHHH7VgwQJ16NBBX375pcaPH68HHnhAS5YskSRlZGRIkgIDA50+FxgY6OjLyMhQQECAU3/dunXl7+/vGHO2+Ph4+fr6Ol4tW7as6k0DAAC1hFvDTmlpqa6++mo9/fTT+stf/qJx48bpnnvu0cKFC6t1vXFxccrJyXG8jhw5Uq3rAwAA7uPWsNOsWTOFhIQ4tXXp0kWpqamSpKCgIElSZmam05jMzExHX1BQkLKyspz6T58+rRMnTjjGnM1qtcpmszm9AACAObk17PTp00cpKSlObfv371fr1q0lSW3atFFQUJCSkpIc/bm5udqyZYvsdrskyW63Kzs7W9u3b3eMWbNmjUpLS9WrV68a2AoAAFCb1XXnyidMmKC//e1vevrpp3X77bdr69ateuWVV/TKK69IkiwWix566CE9+eST6tChg9q0aaPHH39czZs315AhQySdORM0YMAAx+Wv4uJixcbGasSIEZW6EwsAAJibW8POX//6V3300UeKi4vTrFmz1KZNG82bN08jR450jHn44YeVn5+vcePGKTs7W3379tXKlStVr149x5i3335bsbGxCgsLk4eHh6KiojR//nx3bBIAAKhl3PqcndqC5+w44zk7AIDLwWXxnB0AAIDqRtgBAACmRtgBAACmRtgBAACmRtgBAACmRtgBAACmRtgBAACmRtgBAACmRtgBAACmRtgBAACmRtgBAACmRtgBAACmRtgBAACmRtgBAACmRtgBAACmRtgBAACmRtgBAACmRtgBAACmRtgBAACmRtgBAACmRtgBAACmRtgBAACmRtgBAACmRtgBAACmRtgB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okUuF+QOPGfHGc/ZAQBcDqrlOTuXW4gBAAC4pAnKAAAAtR1hBwAAmBphBwAAmBphBwAAmBphBwAAmBphBwAAmBphBwAAmBphBwAAmBphBwAAmBphBwAAmBphBwAAmBphBwAAmBphBwAAmBphBwAAmBphBwAAmBphBwAAmBphBwAAmBphBwAAmBphBwAAmBphBwAAmBphBwAAmBphBwAAmBphBwAAmBphBwAAmBphBwAAmBphBwAAmBphBwAAmBphBwAAmBphBwAAmBphBwAAmBphBwAAmBphBwAAmBphBwAAmBphBwAAmBphBwAAmBphBwAAmBphBwAAmBphBwAAmBphBwAAmBphBwAAmJpbw058fLz++te/ysfHRwEBARoyZIhSUlKcxhQUFCgmJkaNGzeWt7e3oqKilJmZ6TQmNTVVkZGRatCggQICAjRlyhSdPn26JjcFAADUUm4NO+vXr1dMTIw2b96sxMREFRcXq3///srPz3eMmTBhgj777DMtW7ZM69evV1pamoYOHeroLykpUWRkpIqKirRp0yYtWbJEixcv1rRp09yxSQAAoJaxGIZhuLuIMseOHVNAQIDWr1+vfv36KScnR02bNtU777yjYcOGSZL27dunLl26KDk5Wb1799aKFSv097//XWlpaQoMDJQkLVy4UFOnTtWxY8fk5eV1wfXm5ubK19dXOTk5stlsVbpNwY98XqXLqwk/JUS6uwQAAC6osn+/a9WcnZycHEmSv7+/JGn79u0qLi5WeHi4Y0znzp3VqlUrJScnS5KSk5PVrVs3R9CRpIiICOXm5mr37t0VrqewsFC5ublOLwAAYE513V1AmdLSUj300EPq06ePrrzySklSRkaGvLy85Ofn5zQ2MDBQGRkZjjF/DDpl/WV9FYmPj9fMmTOreAvMg7NRAAAzqTVndmJiYvTDDz9o6dKl1b6uuLg45eTkOF5Hjhyp9nUCAAD3qBVndmJjY7V8+XJt2LBBLVq0cLQHBQWpqKhI2dnZTmd3MjMzFRQU5BizdetWp+WV3a1VNuZsVqtVVqu1ircCAADURm49s2MYhmJjY/XRRx9pzZo1atOmjVN/aGioPD09lZSU5GhLSUlRamqq7Ha7JMlut+v7779XVlaWY0xiYqJsNptCQkJqZkMAAECt5dYzOzExMXrnnXf0ySefyMfHxzHHxtfXV/Xr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Y/jw4cbBgwfdXVaNWbt2rSGp3Cs6OtowjDO3nz/++ONGYGCgYbVajbCwMCMlJcW9RVej8+2P33//3ejfv7/RtGlTw9PT02jdurVxzz33mPYfCRXtB0nGokWLHGNOnTpl3HfffUajRo2MBg0aGLfeequRnp7uvqKr0YX2R2pqqtGvXz/D39/fsFqtRvv27Y0pU6YYOTk57i28mtx9991G69atDS8vL6Np06ZGWFiYI+gYxp/r2LhUFsMwjJo7jwQAAFCzmLMDAABMjbADAABMjbADAABMjbADAABMjbADAABMjbADAABMjbADAABMjbADoNrNmDFDV111VY2tb/To0RoyZEiNrQ9A7cZDBQFcFIvFct7+6dOna8aMGU5teXl5KiwsVOPGjV1e77p163TDDTc4avDx8VHbtm110003acKECU7fjZSTkyPDMOTn53fB5Y4ePVrZ2dn6+OOPXa4NQO1W190FALi8pKenO35+7733NG3aNKWkpDjavL29HT8bhqGSkhJ5e3s7tV+KlJQU2Ww25ebm6ttvv9Xs2bP12muvad26derWrZskydfXt0rWBcAcuIwF4KIEBQU5Xr6+vrJYLI73+/btk4+Pj1asWKHQ0FBZrVZ9/fXX5S5jlV1mmjlzppo2bSqbzaZ7771XRUVFF1x/QECAgoKC1LFjR40YMUIbN25U06ZNNX78+HLLL/PBBx+oW7duql+/vho3bqzw8HDl5+drxowZWrJkiT755BNZLBZZLBatW7dOkjR16lR17NhRDRo0UNu2bfX444+ruLjYscyybXrzzTcVHBwsX19fjRgxQidPnnSMKS0t1ezZs9W+fXtZrVa1atVKTz31lKP/yJEjuv322+Xn5yd/f38NHjxYP/3008X/UgCcF2EHQJV75JFHlJCQoL1796p79+4VjklKStLevXu1bt06vfvuu/rwww81c+bMi15X/fr1de+992rjxo3Kysoq15+enq477rhDd999t2N9Q4cOlWEYmjx5sm6//XYNGDBA6enpSk9P19/+9jdJko+PjxYvXqw9e/bo+eef16uvvqq5c+c6LfvQoUP6+OOPtXz5ci1fvlzr169XQkKCoz8uLk4JCQl6/PHHtWfPHr3zzjsKDAyUJBUXFysiIkI+Pj766quvtHHjRnl7e2vAgAGVCn0AKo/LWACq3KxZs3TTTTedd4yXl5def/11NWjQQF27dtWsWbM0ZcoUPfHEE/LwuLh/h3Xu3FmS9NNPPykgIMCpLz09XadPn9bQoUPVunVrSXJc7pLOhKXCwkIFBQU5fe6xxx5z/BwcHKzJkydr6dKlevjhhx3tpaWlWrx4sXx8fCRJo0aNUlJSkp566imdPHlSzz//vF588UVFR0dLktq1a6e+fftKOnMJsLS0VP/9738d86AWLVokPz8/rVu3Tv3797+ofQDg3Ag7AKpcz549LzimR48eatCggeO93W5XXl6ejhw54ggllVV2n0VFk6d79OihsLAwdevWTREREerfv7+GDRumRo0anXeZ7733nubPn69Dhw4pLy9Pp0+fls1mcxoTHBzsCDqS1KxZM8fZpb1796qwsFBhYWEVLv+7777TwYMHnT4vSQUFBTp06NCFNxpApXEZC0CVa9iwYY2ub+/evZLOhI+z1alTR4mJiVqxYoVCQkL0wgsvqFOnTjp8+PA5l5ecnKyRI0dq0KBBWr58uXbs2KFHH3203OUlT09Pp/cWi0WlpaWSzpwxOp+8vDyFhoZq586dTq/9+/frzjvvrMxmA6gkwg4At/juu+906tQpx/vNmzfL29tbLVu2vKjlnDp1Sq+88or69eunpk2bVjjGYrGoT58+mjlzpnbs2CEvLy999NFHks5cTispKXEav2nTJrVu3VqPPvqoevbsqQ4dOujnn3++qLo6dOig+vXrKykpqcL+q6++WgcOHFBAQIDat2/v9OJuMqBqEXYAuEVRUZHGjh2rPXv26IsvvtD06dMVGxt7wfk6WVlZysjI0IEDB7R06VL16dNHx48f14IFCyocv2XLFj399NPatm2bUlNT9eGHH+rYsWPq0qWLpDNng3bt2qWUlBQdP35cxcXF6tChg1JTU7V06VIdOnRI8+fPd4SjyqpXr56mTp2qhx9+WG+88YYOHTqkzZs367XXXpMkjRw5Uk2aNNHgwYP11Vdf6fDhw1q3bp0eeOABHT169KLWBeD8mLMDwC3CwsLUoUMH9evXT4WFhbrjjjvKPYywIp06dZLFYpG3t7fatm2r/v37a+LEieUmGJex2WzasGGD5s2bp9zcXLVu3VrPPfecBg4cKEm65557tG7dOvXs2VN5eXlau3atbrnlFk2YMEGxsbEqLCxUZGSkHn/88UrV90ePP/646tatq2nTpiktLU3NmjXTvffeK0lq0KCBNmzYoKlTp2ro0KE6efKkrrjiCoWFhZWbGwTg0vAEZQA1jqcWA6hJXMYCAACmRtgBAACmxmUsAABgapzZAQAApkbYAQAApkbYAQAApkbYAQAApkbYAQAApkbYAQAApkbYAQAApkbYAQAApkbYAQAApvb/AQi3lQkmdslEAAAAAElFTkSuQmCC", + "text/plain": [ + "
" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "result = %sql SELECT trip_distance FROM default.taxi\n", + "\n", + "import matplotlib.pyplot as plt\n", + "\n", + "data = result.DataFrame()\n", + "\n", + "plt.hist(data[\"trip_distance\"])\n", + "plt.xlabel(\"Trip Distance\")\n", + "plt.ylabel(\"Frequency\")\n", + "plt.title(\"Histogram of Trip Distance\")\n", + "plt.show()" + ] + }, + { + "cell_type": "code", + "execution_count": 21, + "metadata": { + "tags": [] + }, + "outputs": [ + { + "data": { + "text/html": [ + "Running query in 'trino://user@localhost:8080/memory'" + ], + "text/plain": [ + "Running query in 'trino://user@localhost:8080/memory'" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "image/png": "iVBORw0KGgoAAAANSUhEUgAAAjIAAAHHCAYAAACle7JuAAAAOXRFWHRTb2Z0d2FyZQBNYXRwbG90bGliIHZlcnNpb24zLjcuMiwgaHR0cHM6Ly9tYXRwbG90bGliLm9yZy8pXeV/AAAACXBIWXMAAA9hAAAPYQGoP6dpAABD/UlEQVR4nO3de3yMZ/7/8fckkUgkGaecKBJCaQXf0iJEBYuUbiNUW+yi2q1+2W7Rg+gB3ZZWq7Tdrh52V3r4oltV22pRZ1HRXVQ1dYpUHCpxauUsInP//vDLvUZCg8jknryej8c8du77vuaeT5Ktec91X9d92QzDMAQAAGBBHq4uAAAA4GoRZAAAgGURZAAAgGURZAAAgGURZAAAgGURZAAAgGURZAAAgGURZAAAgGURZAAAgGURZAA3Z7PZNG3aNFeX4eQ///mPoqOjVadOHdlsNu3YsaPS32PUqFEKDw+v9PNeiaSkJNlsNmVkZLi0DsCdEWSAq1T6IXXhIzg4WLGxsVq+fLmry7tmu3bt0rRp0yr9Q7i4uFh33323fv75Z82ZM0cffPCBmjVrVqZdeHh4md9veY+kpKRKre9S1q9f7/S+Pj4+CgkJUc+ePTVjxgydOHGiUt6noKBA06ZN0/r16yvlfIC783J1AYDVPffcc4qIiJBhGDp27JiSkpJ0xx136PPPP9fAgQNdXd5V27Vrl6ZPn66ePXtWas9Genq6Dh48qHfffVcPPPDAJdvNnTtXeXl55vaXX36phQsXas6cOWrYsKG5Pzo6utzXv/vuu3I4HJVWd6lHHnlEt956q0pKSnTixAlt3rxZU6dO1auvvqp//vOf6tWrl9n2d7/7ne699175+PhU+PwFBQWaPn26JKlnz56VXT7gdggywDWKi4tTp06dzO0xY8YoJCRECxcutHSQuV6OHz8uSapbt+5l28XHxzttZ2VlaeHChYqPj79ssMrPz1edOnVUq1ata6y0fDExMRoyZIjTvu+++059+/bV4MGDtWvXLoWFhUmSPD095enpeV3qAHAel5aASla3bl35+vrKy8v5e0J+fr4mTZqkJk2ayMfHRzfeeKNeeeUVlS5AX1hYqNatW6t169YqLCw0X/fzzz8rLCxM0dHRKikpkXR+/Ie/v79+/PFH9evXT3Xq1FGjRo303HPPqSIL2n/77beKi4tTYGCg/P391bt3b23ZssU8npSUpLvvvluSFBsba15O+bXLHWvXrlVMTIzq1KmjunXr6q677tLu3bvN46NGjdLtt98uSbr77rtls9muqdeh9PeQnp6uO+64QwEBARo+fLh57MLAk5GRIZvNpldeeUVz5sxRs2bN5Ovrq9tvv12pqalXXYMktW/fXnPnztXp06f1l7/8xdxf3hiZrVu3ql+/fmrYsKF8fX0VERGh+++/36wxKChIkjR9+nTz9146xmnnzp0aNWqUmjdvrtq1ays0NFT333+/Tp065VTPtGnTZLPZtH//fo0aNUp169aV3W7X6NGjVVBQUKb+Dz/8ULfddpv8/PxUr1499ejRQ1999ZVTm+XLl5t/24CAAA0YMEA//PDDNf3egMpAjwxwjbKzs3Xy5EkZhqHjx4/rjTfeUF5enkaMGGG2MQxDv/3tb7Vu3TqNGTNGHTp00MqVK/X444/rp59+0pw5c+Tr66v33ntP3bp101NPPaVXX31VkjRu3DhlZ2crKSnJ6dt9SUmJ+vfvry5dumjWrFlasWKFpk6dqnPnzum55567ZL0//PCDYmJiFBgYqCeeeEK1atXS22+/rZ49e2rDhg3q3LmzevTooUceeUSvv/66pkyZojZt2kiS+b/lWb16teLi4tS8eXNNmzZNhYWFeuONN9StWzdt375d4eHheuihh9S4cWPNmDHDvEQTEhJyTb//c+fOqV+/furevbteeeUV+fn5Xbb9+++/r9zcXI0bN05nzpzRa6+9pl69eun777+/plqGDBmiMWPG6KuvvtILL7xQbpvjx4+rb9++CgoK0uTJk1W3bl1lZGRoyZIlkqSgoCDNmzdPDz/8sAYNGqSEhARJUrt27SRJq1at0o8//qjRo0crNDRUP/zwg9555x398MMP2rJli2w2m9P7DR06VBEREZo5c6a2b9+uv/3tbwoODtZLL71ktpk+fbqmTZum6OhoPffcc/L29tY333yjtWvXqm/fvpKkDz74QCNHjlS/fv300ksvqaCgQPPmzVP37t317bffunxQNWo4A8BVmT9/viGpzMPHx8dISkpyart06VJDkvH888877R8yZIhhs9mM/fv3m/sSExMNDw8PY+PGjcbHH39sSDLmzp3r9LqRI0cakow//vGP5j6Hw2EMGDDA8Pb2Nk6cOGHul2RMnTrV3I6Pjze8vb2N9PR0c9/Ro0eNgIAAo0ePHua+0vdet25dhX4fHTp0MIKDg41Tp06Z+7777jvDw8PD+P3vf2/uW7dunSHJ+Pjjjyt03lIvv/yyIck4cOCAua/09zB58uQy7UeOHGk0a9bM3D5w4IAhyfD19TWOHDli7v/mm28MScaECRMu+/4Vqbt9+/ZGvXr1zO3S/4+U1vzpp58akoz//Oc/lzzHiRMnyvzNShUUFJTZt3DhQkOSsXHjRnPf1KlTDUnG/fff79R20KBBRoMGDczttLQ0w8PDwxg0aJBRUlLi1NbhcBiGYRi5ublG3bp1jQcffNDpeFZWlmG328vsB6oal5aAa/Tmm29q1apVWrVqlT788EPFxsbqgQceML9lS+cHqnp6euqRRx5xeu2kSZNkGIbTLKdp06bp5ptv1siRI/W///u/uv3228u8rtT48ePN5zabTePHj9fZs2e1evXqctuXlJToq6++Unx8vJo3b27uDwsL07Bhw7Rp0ybl5ORc8e8gMzNTO3bs0KhRo1S/fn1zf7t27fSb3/xGX3755RWf80o8/PDDFW4bHx+vxo0bm9u33XabOnfuXCk1+vv7Kzc395LHS8cFLVu2TMXFxVd8fl9fX/P5mTNndPLkSXXp0kWStH379jLtx44d67QdExOjU6dOmX/jpUuXyuFw6Nlnn5WHh/PHQWnvzqpVq3T69Gndd999OnnypPnw9PRU586dtW7duiv+OYDKRJABrtFtt92mPn36qE+fPho+fLi++OIL3XTTTWaokKSDBw+qUaNGCggIcHpt6aWagwcPmvu8vb31j3/8QwcOHFBubq7mz59f5pKBJHl4eDiFEUlq1aqVJF1yyvSJEydUUFCgG2+8scyxNm3ayOFw6PDhwxX/4f+/0vovdd6TJ08qPz//is9bEV5eXrrhhhsq3L5ly5Zl9rVq1apSppnn5eWV+Rtf6Pbbb9fgwYM1ffp0NWzYUHfddZfmz5+voqKiCp3/559/1p/+9CeFhITI19dXQUFBioiIkHT+EufFmjZt6rRdr149SdIvv/wi6fwMMg8PD910002XfM+0tDRJUq9evRQUFOT0+Oqrr8zB24CrMEYGqGQeHh6KjY3Va6+9prS0NN18881XfI6VK1dKOv+tOy0tzfywQlk+Pj5lehNcobi4WPv27VPbtm0v2cZms2nx4sXasmWLPv/8c61cuVL333+/Zs+erS1btsjf3/+y7zF06FBt3rxZjz/+uDp06CB/f385HA7179+/3Knml5oxZVRgQHip0vN+8MEHCg0NLXP84kHtQFXj/4HAdXDu3DlJMu+D0qxZM61evVq5ublO39j37NljHi+1c+dOPffccxo9erR27NihBx54QN9//73sdrvTezgcDv34449mL4wk7du3T5IuOfgyKChIfn5+2rt3b5lje/bskYeHh5o0aSJJ5fYCXUpp/Zc6b8OGDVWnTp0Kn+96Ku1huNC+ffuuecDq4sWLVVhYqH79+v1q2y5duqhLly564YUXtGDBAg0fPlyLFi3SAw88cMnf+y+//KI1a9Zo+vTpevbZZ8395f08FdWiRQs5HA7t2rVLHTp0uGQbSQoODlafPn2u+r2A68X1X2MAN1NcXKyvvvpK3t7e5qWjO+64QyUlJU5TcyVpzpw5stlsiouLM187atQoNWrUSK+99pqSkpJ07NgxTZgwodz3uvB8hmHoL3/5i2rVqqXevXuX297T01N9+/bVv/71L6dLKceOHdOCBQvUvXt3BQYGSpIZPE6fPv2rP3NYWJg6dOig9957z6l9amqqvvrqK91xxx2/eo6qsnTpUv3000/m9r///W9988035t/ganz33Xd69NFHVa9ePY0bN+6S7X755ZcyvSGlAaL08lLprKuLf++lvSsXv37u3LlXXXd8fLw8PDz03HPPlenRKX2ffv36KTAwUDNmzCh3XE9l3dEYuFr0yADXaPny5WbPyvHjx7VgwQKlpaVp8uTJZii48847FRsbq6eeekoZGRlq3769vvrqK/3rX//So48+an7rff7557Vjxw6tWbNGAQEBateunZ599lk9/fTTGjJkiFMgqF27tlasWKGRI0eqc+fOWr58ub744gtNmTLFvBdJeZ5//nmtWrVK3bt31//+7//Ky8tLb7/9toqKijRr1iyzXYcOHeTp6amXXnpJ2dnZ8vHxUa9evRQcHFzueV9++WXFxcWpa9euGjNmjDn92m63V6u1niIjI9W9e3c9/PDDKioq0ty5c9WgQQM98cQTFXp9cnKyzpw5o5KSEp06dUpff/21PvvsM9ntdn366aflXn4p9d577+mvf/2rBg0apBYtWig3N1fvvvuuAgMDzb+tr6+vbrrpJn300Udq1aqV6tevr7Zt26pt27bq0aOHZs2apeLi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kqS4uDgNGDBAvr6+Kiws1BdffKHly5eXaQfAvRFkAFSpgoIC7dmz56pe++OPP5rPV65caQYX6fwikhe22759+1W9R+vWreXn53dVrwVQ9QgyAKrUnj171LFjx2s+z8W9Lhdu7969+6rfY9u2bbrllluuqTYAVYcgA6BKtW7dWtu2bbuq13744YeaM2eOJKlevXpq2bKl/v3vf+u2225TWlqafvnlF0nShAkTNGLEiKuuD4B12Aw3HxWXk5Mju92u7OxsBQYGurocANfg7Nmz8vX1lcPhkM1mcxrUW7rt4eGhwsJCeXt7u7BSANeqop/fzFoCYBne3t6aNGnSZdtMmjSJEAPUIFxaAmAps2bNkiTNmTNH586dM/d7enpqwoQJ5nEANQM9MgAsZ9asWcrPz9fEiRMlSRMnTlR+fj4hBqiBCDIALMnb21vDhw+XJA0fPpzLSUANRZABAACWRZABAACWRZABAACWRZABAACWRZABAACWRZABAACWRZABAACWRZABAACWRZABAACWRZABAACWRZABAACWRZABAACWRZABAACWRZABAACWRZABAACWRZABAACWRZABAACWRZABAACWRZABAACWVa2DzLRp02Sz2ZwerVu3dnVZAACgmvBydQG/5uabb9bq1avNbS+val8yAACoItU+FXh5eSk0NNTVZQAAgGqoWl9akqS0tDQ1atRIzZs31/Dhw3Xo0KHLti8qKlJOTo7TAwAAuKdqHWQ6d+6spKQkrVixQvPmzdOBAwcUExOj3NzcS75m5syZstvt5qNJkyZVWDEAAKhKNsMwDFcXUVGnT59Ws2bN9Oqrr2rMmDHltikqKlJRUZG5nZOToyZNmig7O1uBgYFVVSqAKrB9+3Z17NhR27Zt0y233OLqcgBUopycHNnt9l/9/K72Y2QuVLduXbVq1Ur79++/ZBsfHx/5+PhUYVUAAMBVqvWlpYvl5eUpPT1dYWFhri4FAABUA9U6yDz22GPasGGDMjIytHnzZg0aNEienp667777XF0aAACoBqr1paUjR47ovvvu06lTpxQUFKTu3btry5YtCgoKcnVpAACgGqjWQWbRokWuLgEAAFRj1frSEgAAwOUQZAAAgGURZAAAgGURZAAAgGVV68G+AKqXtLS0yy4RUtV2797t9L/VRUBAgFq2bOnqMoAagSADoELS0tLUqlUrV5dRrhEjRri6hDL27dtHmAGqAEEGQIWU9sR8+OGHatOmjYurOa+wsFAZGRkKDw+Xr6+vq8uRdL53aMSIEdWq5wpwZwQZAFekTZs21WqBxm7durm6BAAuxGBfAABgWQQZAABgWQQZAABgWQQZAABgWQQZAABgWQQZAABgWQQZAABgWQQZAABgWQQZAABgWQQZAABgWQQZAABgWQQZAABgWQQZAABgWQQZAABgWV6uLgCANdjOndH/hHrI9/Q+6SjfgS7F9/Q+/U+oh2znzri6FKBGIMgAqJDaeYe0/SF/aeND0kZXV1N9tZG0/SF/7c47JCna1eUAbo8gA6BCzvg31S1v5+n//u//1KZ1a1eXU23t3rNHw4cP19/vaOrqUoAagSADoEIMr9r6NsuhwrqtpEYdXF1OtVWY5dC3WQ4ZXrVdXQpQI3ChGwAAWBZBBgAAWBZBBgAAWBZBBgAAWBZBBgAAWBZBBgAAWBbTrwFUSEFBgSRp+/btLq7kvwoLC5WRkaHw8HD5+vq6uhxJ0u7du11dAlCjEGQAVMiePXskSQ8++KCLK7GGgIAAV5cA1AgEGQAVEh8fL0lq3bq1/Pz8XFvM/7d7926NGDFCH374odq0aePqckwBAQFq2bKlq8sAagSCDIAKadiwoR544AFXl1GuNm3a6JZbbnF1GQBcgMG+AADAsggyAADAsggyAADAsggyAADAsiwRZN58802Fh4erdu3a6ty5s/7973+7uiQAAFANVPsg89FHH2nixImaOnWqtm/frvbt26tfv346fvy4q0sDAAAuVu2DzKuvvqoHH3xQo0eP1k033aS33npLfn5++sc//uHq0gAAgItV6/vInD17Vtu2bVNiYqK5z8PDQ3369FFKSkq5rykqKlJRUZG5nZOTc93rBFBxBQUF5l2Cr1XpcgCVuSxAdbrhH4BfV62DzMmTJ1VSUqKQkBCn/SEhIZf8h3DmzJmaPn16VZQH4Crs2bNHHTt2rNRzjhgxotLOtW3bNm6uB1hItQ4yVyMxMVETJ040t3NyctSkSRMXVgTgQq1bt9a2bdsq5VzXY9HI1q1bV8p5AFSNah1kGjZsKE9PTx07dsxp/7FjxxQaGlrua3x8fOTj41MV5QG4Cn5+fpXa49GtW7dKOxcA66nWg329vb3VsWNHrVmzxtzncDi0Zs0ade3a1YWVAQCA6qBa98hI0sSJEzVy5Eh16tRJt912m+bOnav8/HyNHj3a1aUBAAAXq/ZB5p577tGJEyf07LPPKisrSx06dNCKFSvKDAAGAAA1j80wDMPVRVxPOTk5stvtys7OVmBgoKvLAQAAFVDRz+9qPUYGAADgcggyAADAsggyAADAsggyAADAsggyAADAsggyAADAsggyAADAsggyAADAsggyAADAsqr9EgXXqvTGxTk5OS6uBAAAVFTp5/avLUDg9kEmNzdXktSkSRMXVwIAAK5Ubm6u7Hb7JY+7/VpLDodDR48eVUBAgGw2m6vLAVCJcnJy1KRJEx0+fJi11AA3YxiGcnNz1ahRI3l4XHokjNsHGQDui0VhATDYFwAAWBZBBgAAWBZBBoBl+fj4aOrUqfLx8XF1KQBchDEyAADAsuiRAQAAlkWQAQAAlkWQAQAAlkWQAQAAlkWQAWA5Gzdu1J133qlGjRrJZrNp6dKlri4JgIsQZABYTn5+vtq3b68333zT1aUAcDG3XzQSgPuJi4tTXFycq8sAUA3QIwMAACyLIAMAACyLIAMAACyLIAMAACyLIAMAACyLWUsALCcvL0/79+83tw8cOKAdO3aofv36atq0qQsrA1DVWP0agOWsX79esbGxZfaPHDlSSUlJVV8QAJchyAAAAMtijAwAALAsggwAALAsggwAALAsggwAALAsggwAALAsggwAALAsggwAALAsggyAazJt2jR16NChyt5v1KhRio+Pr7L3A1C9cUM8ACabzXbZ41OnTtW0adOc9uXl5amoqEgNGjS46ve98E69NptNAQEBat68uX7zm99owoQJCgsLM9tmZ2fLMAzVrVv3V887atQonT59WkuXLr3q2gBUb6y1BMCUmZlpPv/oo4/07LPPau/eveY+f39/87lhGCopKZG/v7/T/muxd+9eBQYGKicnR9u3b9esWbP097//XevXr1dUVJQkyW63V8p7AXAPXFoCYAoNDTUfdrtdNpvN3N6zZ48CAgK0fPlydezYUT4+Ptq0aVOZS0ull36mT5+uoKAgBQYGauzYsTp79uyvvn9wcLBCQ0PVqlUr3Xvvvfr6668VFBSkhx9+uMz5Sy1evFhRUVHy9fVVgwYN1KdPH+Xn52vatGl677339K9//Us2m002m03r16+XJD355JNq1aqV/Pz81Lx5cz3zzDMqLi42z1n6M33wwQcKDw+X3W7Xvffeq9zcXLONw+HQrFmzFBkZKR8fHzVt2lQvvPCCefzw4cMaOnSo6tatq/r16+uuu+5SRkbGlf9RAFwWQQbAFZk8ebJefPFF7d69W+3atSu3zZo1a7R7926tX79eCxcu1JIlSzR9+vQrfi9fX1+NHTtWX3/9tY4fP17meGZmpu677z7df//95vslJCTIMAw99thjGjp0qPr376/MzExlZmYqOjpakhQQEKCkpCTt2rVLr732mt59913NmTPH6dzp6elaunSpli1bpmXLlmnDhg168cUXzeOJiYl68cUX9cwzz2jXrl1asGCBQkJCJEnFxcXq16+fAgIClJycrK+//lr+/v7q379/hQIdgIrj0hKAK/Lcc8/pN7/5zWXbeHt76x//+If8/Px0880367nnntPjjz+uP//5z/LwuLLvT61bt5YkZWRkKDg42OlYZmamzp07p4SEBDVr1kySzEtQ0vkgVFRUpNDQUKfXPf300+bz8PBwPfbYY1q0aJGeeOIJc7/D4VBSUpICAgIkSb/73e+0Zs0avfDCC8rNzdVrr72mv/zlLxo5cqQkqUWLFurevbuk85flHA6H/va3v5njjubPn6+6detq/fr16tu37xX9DgBcGkEGwBXp1KnTr7Zp3769/Pz8zO2uXbsqLy9Phw8fNgNHRZXORyhvIHL79u3Vu3dvRUVFqV+/furbt6+GDBmievXqXfacH330kV5//XWlp6crLy9P586dU2BgoFOb8PBwM8RIUlhYmNkrtHv3bhUVFal3797lnv+7777T/v37nV4vSWfOnFF6evqv/9AAKoxLSwCuSJ06dar0/Xbv3i3pfLC4mKenp1atWqXly5frpptu0htvvKEbb7xRBw4cuOT5UlJSNHz4cN1xxx1atmyZvv32Wz311FNlLvnUqlXLadtms8nhcEg639NzOXl5eerYsaN27Njh9Ni3b5+GDRtWkR8bQAURZABUuu+++06FhYXm9pYtW+Tv768mTZpc0XkKCwv1zjvvqEePHgoKCiq3jc1mU7du3TR9+nR9++238vb21qeffirp/CWukpISp/abN29Ws2bN9NRTT6lTp05q2bKlDh48eEV1tWzZUr6+vlqzZk25x2+55RalpaUpODhYkZGRTg9mXQGViyADoNKdPXtWY8aM0a5du/Tll19q6tSpGj9+/K+Ojzl+/LiysrKUlpamRYsWqVu3bjp58qTmzZtXbvtvvvlGM2bM0NatW3Xo0CEtWbJEJ06cUJs2bSSd78XZuXOn9u7dq5MnT6q4uFgtW7bUoUOHtGjRIqWnp+v11183g09F1a5dW08++aSeeOIJvf/++0pPT9eWLVv097//XZI0fPhwNWzYUHfddZeSk5N14MABrV+/Xo888oiOHDlyRe8F4PIYIwOg0vXu3VstW7ZUjx49VFRUpPvuu6/MjfTKc+ONN8pms8nf31/NmzdX3759NXHixDKDdUsFBgZq48aNmjt3rnJyctSsWTPNnj1bcXFxkqQHH3xQ69evV6dOnZSXl6d169bpt7/9rSZMmKDx48erqKhIAwYM0DPPPFOh+i70zDPPyMvLS88++6yOHj2qsLAwjR07VpLk5+enjRs36sknn1RCQoJyc3PVuHFj9e7du8xYHADXhjv7AqhU3E0XQFXi0hIAALAsggwAALAsLi0BAADLokcGAABYFkEGAABYFkEGAABYFkEGAABYFkEGAABYFkEGAABYFkEGAABYFkEGAABYFkEGAABY1v8DD4XjW69zCxAAAAAASUVORK5CYII=", + "text/plain": [ + "
" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "result = %sql SELECT trip_distance FROM default.taxi\n", + "\n", + "import matplotlib.pyplot as plt\n", + "\n", + "data = result.DataFrame()\n", + "\n", + "plt.boxplot(data[\"trip_distance\"])\n", + "plt.xlabel(\"Trip Distance\")\n", + "plt.ylabel(\"Value\")\n", + "plt.title(\"Boxplot of Trip Distance\")\n", + "plt.show()" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Persist" + ] + }, + { + "cell_type": "code", + "execution_count": 22, + "metadata": {}, + "outputs": [ + { + "data": { + "text/html": [ + "Running query in 'trino://user@localhost:8080/memory'" + ], + "text/plain": [ + "Running query in 'trino://user@localhost:8080/memory'" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "result = %sql SELECT * FROM default.taxi WHERE passenger_count > 3" + ] + }, + { + "cell_type": "code", + "execution_count": 23, + "metadata": {}, + "outputs": [], + "source": [ + "df = result.DataFrame()" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "We need to pass `--no-index` since index creation is not supported in `Trino DB`." + ] + }, + { + "cell_type": "code", + "execution_count": 24, + "metadata": {}, + "outputs": [ + { + "data": { + "text/html": [ + "Running query in 'trino://user@localhost:8080/memory'" + ], + "text/plain": [ + "Running query in 'trino://user@localhost:8080/memory'" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "text/html": [ + "Success! Persisted df to the database." + ], + "text/plain": [ + "Success! Persisted df to the database." + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "%sql --persist default.df --no-index" + ] + }, + { + "cell_type": "code", + "execution_count": 25, + "metadata": {}, + "outputs": [ + { + "data": { + "text/html": [ + "Running query in 'trino://user@localhost:8080/memory'" + ], + "text/plain": [ + "Running query in 'trino://user@localhost:8080/memory'" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "text/html": [ + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
vendoridtpep_pickup_datetimetpep_dropoff_datetimepassenger_counttrip_distanceratecodeidstore_and_fwd_flagpulocationiddolocationidpayment_typefare_amountextramta_taxtip_amounttolls_amountimprovement_surchargetotal_amountcongestion_surchargeairport_fee
22021-01-01 00:31:062021-01-01 00:38:525.01.71.0N1425018.00.50.52.360.00.314.162.5None
22021-01-01 00:42:112021-01-01 00:44:245.00.811.0N5014224.50.50.50.00.00.38.32.5None
22021-01-01 00:31:062021-01-01 00:38:525.01.71.0N1425018.00.50.52.360.00.314.162.5None
22021-01-01 00:42:112021-01-01 00:44:245.00.811.0N5014224.50.50.50.00.00.38.32.5None
22021-01-01 00:34:372021-01-01 00:47:224.03.151.0N238162112.50.50.52.00.00.318.32.5None
" + ], + "text/plain": [ + "+----------+----------------------+-----------------------+-----------------+---------------+------------+--------------------+--------------+--------------+--------------+-------------+-------+---------+------------+--------------+-----------------------+--------------+----------------------+-------------+\n", + "| vendorid | tpep_pickup_datetime | tpep_dropoff_datetime | passenger_count | trip_distance | ratecodeid | store_and_fwd_flag | pulocationid | dolocationid | payment_type | fare_amount | extra | mta_tax | tip_amount | tolls_amount | improvement_surcharge | total_amount | congestion_surcharge | airport_fee |\n", + "+----------+----------------------+-----------------------+-----------------+---------------+------------+--------------------+--------------+--------------+--------------+-------------+-------+---------+------------+--------------+-----------------------+--------------+----------------------+-------------+\n", + "| 2 | 2021-01-01 00:31:06 | 2021-01-01 00:38:52 | 5.0 | 1.7 | 1.0 | N | 142 | 50 | 1 | 8.0 | 0.5 | 0.5 | 2.36 | 0.0 | 0.3 | 14.16 | 2.5 | None |\n", + "| 2 | 2021-01-01 00:42:11 | 2021-01-01 00:44:24 | 5.0 | 0.81 | 1.0 | N | 50 | 142 | 2 | 4.5 | 0.5 | 0.5 | 0.0 | 0.0 | 0.3 | 8.3 | 2.5 | None |\n", + "| 2 | 2021-01-01 00:31:06 | 2021-01-01 00:38:52 | 5.0 | 1.7 | 1.0 | N | 142 | 50 | 1 | 8.0 | 0.5 | 0.5 | 2.36 | 0.0 | 0.3 | 14.16 | 2.5 | None |\n", + "| 2 | 2021-01-01 00:42:11 | 2021-01-01 00:44:24 | 5.0 | 0.81 | 1.0 | N | 50 | 142 | 2 | 4.5 | 0.5 | 0.5 | 0.0 | 0.0 | 0.3 | 8.3 | 2.5 | None |\n", + "| 2 | 2021-01-01 00:34:37 | 2021-01-01 00:47:22 | 4.0 | 3.15 | 1.0 | N | 238 | 162 | 1 | 12.5 | 0.5 | 0.5 | 2.0 | 0.0 | 0.3 | 18.3 | 2.5 | None |\n", + "+----------+----------------------+-----------------------+-----------------+---------------+------------+--------------------+--------------+--------------+--------------+-------------+-------+---------+------------+--------------+-----------------------+--------------+----------------------+-------------+" + ] + }, + "execution_count": 25, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "%sql SELECT * FROM default.df LIMIT 5" + ] + }, + { + "attachments": {}, + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Clean up\n", + "\n", + "To stop and remove the container:" + ] + }, + { + "cell_type": "code", + "execution_count": 26, + "metadata": { + "tags": [] + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES\n", + "cba8365556d3 trinodb/trino \"/usr/lib/trino/bin/…\" 2 minutes ago Up 2 minutes (healthy) 0.0.0.0:8080->8080/tcp trino\n" + ] + } + ], + "source": [ + "! docker container ls" + ] + }, + { + "cell_type": "code", + "execution_count": 27, + "metadata": { + "tags": [] + }, + "outputs": [], + "source": [ + "%%capture out\n", + "! docker container ls --filter ancestor=trinodb/trino --quiet" + ] + }, + { + "cell_type": "code", + "execution_count": 28, + "metadata": { + "tags": [] + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Container id: cba8365556d3\n" + ] + } + ], + "source": [ + "container_id = out.stdout.strip()\n", + "print(f\"Container id: {container_id}\")" + ] + }, + { + "cell_type": "code", + "execution_count": 29, + "metadata": { + "tags": [] + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "cba8365556d3\n" + ] + } + ], + "source": [ + "! docker container stop {container_id}" + ] + }, + { + "cell_type": "code", + "execution_count": 30, + "metadata": { + "tags": [] + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "cba8365556d3\n" + ] + } + ], + "source": [ + "! docker container rm {container_id}" + ] + }, + { + "cell_type": "code", + "execution_count": 31, + "metadata": { + "tags": [] + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES\n" + ] + } + ], + "source": [ + "! docker container ls" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3 (ipykernel)", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.10.12" + }, + "myst": { + "html_meta": { + "description lang=en": "Query a PostgreSQL database from Jupyter via JupySQL", + "keywords": "jupyter, sql, jupysql, postgres", + "property=og:locale": "en_US" + } + } + }, + "nbformat": 4, + "nbformat_minor": 4 +} diff --git a/doc/intro.md b/doc/intro.md new file mode 100644 index 000000000..07bc3dfce --- /dev/null +++ b/doc/intro.md @@ -0,0 +1,180 @@ +--- +jupytext: + notebook_metadata_filter: myst + cell_metadata_filter: -all + formats: md:myst + text_representation: + extension: .md + format_name: myst + format_version: 0.13 + jupytext_version: 1.14.4 +kernelspec: + display_name: Python 3 (ipykernel) + language: python + name: python3 +myst: + html_meta: + description lang=en: Run SQL in a Jupyter notebook with JupySQL + keywords: jupyter, sql, jupysql + property=og:locale: en_US +--- + +# Introduction + +JupySQL allows you to run SQL in Jupyter/IPython via a `%sql` and `%%sql` magics. + +```{code-cell} ipython3 +%load_ext sql +``` + +```{code-cell} ipython3 +%%sql sqlite:// +CREATE TABLE languages (name, rating, change); +INSERT INTO languages VALUES ('Python', 14.44, 2.48); +INSERT INTO languages VALUES ('C', 13.13, 1.50); +INSERT INTO languages VALUES ('Java', 11.59, 0.40); +INSERT INTO languages VALUES ('C++', 10.00, 1.98); +``` + +*Note: data from the TIOBE index* + +```{code-cell} ipython3 +%sql SELECT * FROM languages +``` + +```{code-cell} ipython3 +result = _ +print(result) +``` + +```{code-cell} ipython3 +result.keys +``` + +```{code-cell} ipython3 +result[0][0] +``` + +```{code-cell} ipython3 +result[0].rating +``` + +After the first connection, connect info can be omitted:: + +```{code-cell} ipython3 +%sql select count(*) from languages +``` + +Connections to multiple databases can be maintained. You can switch connection using --alias +Suppose we create two database, named one and two. Then, assign alias to both connections so we can switch them by name: + +```sql +%sql sqlite:///one.db --alias one +%sql sqlite:///two.db --alias two +``` + +```sql +%sql +``` + +It will run query in "two" database since it's the latest one we connected to. + +Pass the alias to make it the current connection: + +```sql +%sql one +``` + +You can pass an alias and query in the same cell: + +```sql +%sql two +SELECT * FROM two +``` + +However, this isn’t supported with the line magic (e.g., `%sql one SELECT * FROM one`). + ++++ + +For secure access, you may dynamically access your credentials (e.g. from your system environment or `getpass.getpass`) to avoid storing your password in the notebook itself. Then, create the connection and pass it to the magic: + ++++ + +```python +from sqlalchemy import create_engine + +user = os.getenv('SOME_USER') +password = os.getenv('SOME_PASSWORD') + +engine = create_engine(f"postgresql://{user}:{password}@localhost/some_database") +%sql engine +``` + ++++ + +You may use multiple SQL statements inside a single cell, but you will only see any query results from the last of them, so this really only makes sense for statements with no output + ++++ + +``` +%%sql sqlite:// +CREATE TABLE writer (first_name, last_name, year_of_death); +INSERT INTO writer VALUES ('William', 'Shakespeare', 1616); +INSERT INTO writer VALUES ('Bertold', 'Brecht', 1956); +``` + ++++ + +As a convenience, dict-style access for result sets is supported, with the +leftmost column serving as key, for unique values. + ++++ + +``` +result = %sql select * from work +result['richard2'] +``` + ++++ + +Results can also be retrieved as an iterator of dictionaries (``result.dicts()``) +or a single dictionary with a tuple of scalar values per key (``result.dict()``) + +## Assignment + +Ordinary IPython assignment works for single-line `%sql` queries: + +```{code-cell} ipython3 +lang = %sql SELECT * FROM languages +``` + +The `<<` operator captures query results in a local variable, and +can be used in multi-line ``%%sql``: + +```{code-cell} ipython3 +%%sql lang << SELECT * +FROM languages +``` + +The `myvar= <<` syntax captures query results in a local variable as well as +returning the results. + +```{code-cell} ipython3 +%%sql lang= << SELECT * +FROM languages +``` + ++++ + +## Considerations + +Because jupysql accepts `--`-delimited options like `--persist`, but `--` +is also the syntax to denote a SQL comment, the parser needs to make some assumptions. + +- If you try to pass an unsupported argument, like `--lutefisk`, it will + be interpreted as a SQL comment and will not throw an unsupported argument + exception. +- If the SQL statement begins with a first-line comment that looks like one + of the accepted arguments - like `%sql --persist is great!` - it will be + parsed like an argument, not a comment. Moving the comment to the second + line or later will avoid this. diff --git a/doc/jupyterlab/autocompletion.md b/doc/jupyterlab/autocompletion.md new file mode 100644 index 000000000..34af0bcae --- /dev/null +++ b/doc/jupyterlab/autocompletion.md @@ -0,0 +1,42 @@ +--- +jupytext: + notebook_metadata_filter: myst + text_representation: + extension: .md + format_name: myst + format_version: 0.13 + jupytext_version: 1.14.5 +kernelspec: + display_name: Python 3 (ipykernel) + language: python + name: python3 +myst: + html_meta: + description lang=en: Enable SQL keywords autocompletion in JupyterLab + keywords: jupyter, jupyterlab, sql + property=og:locale: en_US +--- + +# SQL keywords autocompletion + + +JupySQL supports autocompletion of the most common SQL keywords. You can press +the `tab` key while typing the keyword to view the list of suggestions. + +## Installation + +```bash +pip install jupysql --quiet +``` + ++++ + +Now, start Jupyter Lab, and try out the autocomplete feature: + +![syntax](../static/sql-autocompletion.png) + + +## Known limitations + +- It currently autocompletes all cells +- Limited to most common SQL keywords \ No newline at end of file diff --git a/doc/jupyterlab/format-sql.ipynb b/doc/jupyterlab/format-sql.ipynb new file mode 100644 index 000000000..ed42b0bc8 --- /dev/null +++ b/doc/jupyterlab/format-sql.ipynb @@ -0,0 +1,308 @@ +{ + "cells": [ + { + "attachments": {}, + "cell_type": "markdown", + "id": "f09df847", + "metadata": {}, + "source": [ + "# SQL formatting" + ] + }, + { + "attachments": {}, + "cell_type": "markdown", + "id": "4ebd8538", + "metadata": { + "user_expressions": [] + }, + "source": [ + "To enable SQL formatting, install `jupysql`:\n", + "\n", + "```sh\n", + "pip install jupysql --upgrade\n", + "```\n", + "\n", + "Then, a \"Format SQL\" button will appear in JupyterLab:\n", + "\n", + "![format](../static/format-sql.gif)\n", + "\n", + "\n", + "Click on \"Format SQL\" and you'll see that the SQL cell below is formatted!" + ] + }, + { + "cell_type": "code", + "execution_count": 1, + "id": "aa377aa3", + "metadata": {}, + "outputs": [], + "source": [ + "from pathlib import Path\n", + "from urllib.request import urlretrieve\n", + "\n", + "if not Path(\"penguins.csv\").is_file():\n", + " urlretrieve(\n", + " \"https://raw.githubusercontent.com/mwaskom/seaborn-data/master/penguins.csv\",\n", + " \"penguins.csv\",\n", + " )" + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "id": "3d31021c", + "metadata": {}, + "outputs": [ + { + "data": { + "text/html": [ + "Tip: You may define configurations in /Users/neelashasen/Dev/jupysql_master/jupysql/pyproject.toml or /Users/neelashasen/.jupysql/config. " + ], + "text/plain": [ + "Tip: You may define configurations in /Users/neelashasen/Dev/jupysql_master/jupysql/pyproject.toml or /Users/neelashasen/.jupysql/config. " + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "text/html": [ + "Please review our configuration guideline." + ], + "text/plain": [ + "" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "text/html": [ + "Loading configurations from /Users/neelashasen/.jupysql/config." + ], + "text/plain": [ + "Loading configurations from /Users/neelashasen/.jupysql/config." + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "text/html": [ + "Settings changed:" + ], + "text/plain": [ + "Settings changed:" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "text/html": [ + "\n", + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
Configvalue
feedbackTrue
autopandasTrue
" + ], + "text/plain": [ + "\n", + "+------------+-------+\n", + "| Config | value |\n", + "+------------+-------+\n", + "| feedback | True |\n", + "| autopandas | True |\n", + "+------------+-------+" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "text/html": [ + "Connecting to 'default'" + ], + "text/plain": [ + "Connecting to 'default'" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "text/html": [ + "Connecting and switching to connection 'duckdb://'" + ], + "text/plain": [ + "Connecting and switching to connection 'duckdb://'" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "%load_ext sql\n", + "%sql duckdb://" + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "id": "4185be28", + "metadata": {}, + "outputs": [ + { + "data": { + "text/html": [ + "Running query in 'duckdb://'" + ], + "text/plain": [ + "Running query in 'duckdb://'" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "text/html": [ + "
\n", + "\n", + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
speciesislandbill_length_mmbill_depth_mmflipper_length_mmbody_mass_gsex
0AdelieTorgersen39.118.71813750MALE
1AdelieTorgersen39.517.41863800FEMALE
2AdelieTorgersen40.318.01953250FEMALE
\n", + "
" + ], + "text/plain": [ + " species island bill_length_mm bill_depth_mm flipper_length_mm \\\n", + "0 Adelie Torgersen 39.1 18.7 181 \n", + "1 Adelie Torgersen 39.5 17.4 186 \n", + "2 Adelie Torgersen 40.3 18.0 195 \n", + "\n", + " body_mass_g sex \n", + "0 3750 MALE \n", + "1 3800 FEMALE \n", + "2 3250 FEMALE " + ] + }, + "execution_count": 3, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "%%sql\n", + "select * from penguins.csv where island = 'Torgersen' limit 3" + ] + } + ], + "metadata": { + "jupytext": { + "notebook_metadata_filter": "myst" + }, + "kernelspec": { + "display_name": "Python 3 (ipykernel)", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.10.13" + }, + "myst": { + "html_meta": { + "description lang=en": "Format your SQL cells in Jupyter", + "keywords": "jupyter, jupyterlab, sql", + "property=og:locale": "en_US" + } + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/doc/jupyterlab/syntax-highlighting.md b/doc/jupyterlab/syntax-highlighting.md new file mode 100644 index 000000000..9e093a8f9 --- /dev/null +++ b/doc/jupyterlab/syntax-highlighting.md @@ -0,0 +1,32 @@ +--- +jupytext: + notebook_metadata_filter: myst + text_representation: + extension: .md + format_name: myst + format_version: 0.13 + jupytext_version: 1.14.5 +kernelspec: + display_name: Python 3 (ipykernel) + language: python + name: python3 +myst: + html_meta: + description lang=en: Enable SQL syntax highlighting in JupyterLab + keywords: jupyter, jupyterlab, sql + property=og:locale: en_US +--- + +# SQL syntax highlighting + ++++ + +To enable syntax highlighting, install `jupysql`: + +```sh +pip install jupysql --upgrade +``` + +Then, open a notebook and *click* on any `%%sql` cell: + +![syntax](../static/syntax-highlighting.png) diff --git a/doc/logo.drawio b/doc/logo.drawio new file mode 100644 index 000000000..5c4dbafe2 --- /dev/null +++ b/doc/logo.drawio @@ -0,0 +1 @@ +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 \ No newline at end of file diff --git a/doc/plot.md b/doc/plot.md new file mode 100644 index 000000000..d2f9736d9 --- /dev/null +++ b/doc/plot.md @@ -0,0 +1,175 @@ +--- +jupytext: + notebook_metadata_filter: myst + text_representation: + extension: .md + format_name: myst + format_version: 0.13 + jupytext_version: 1.15.1 +kernelspec: + display_name: Python 3 (ipykernel) + language: python + name: python3 +myst: + html_meta: + description lang=en: Create visualizations for large-scale datasets in a Jupyter + notebook using JupySQL + keywords: jupyter, sql, jupysql, plotting, warehouse, duckdb + property=og:locale: en_US +--- + +# Plotting + +```{versionadded} 0.5.2 +`%sqlplot` was introduced in 0.5.2; however, the underlying +[Python API](api/python) was introduced in 0.4.4 +``` + + +The most common way for plotting datasets in Python is to load them using pandas and then use matplotlib or seaborn for plotting. This approach requires loading all your data into memory which is highly inefficient, since you can easily run out of memory as you perform data transformations. + +The plotting module in JupySQL runs computations in the SQL engine (database, warehouse, or embedded engine). This delegates memory management to the engine and ensures that intermediate computations do not keep eating up memory, allowing you to efficiently plot massive datasets. There are two primary use cases: + +**1. Plotting large remote tables** + +If your data is stored in a data warehouse such as Snowflake, Redshift, or BigQuery, downloading entire tables locally is extremely inefficient, and you might not even have enough memory in your laptop to load the entire dataset. With JupySQL, the data is aggregated and summarized in the warehouse, and only the summary statistics are fetched over the network. Keeping memory usage at minimum and allowing you to quickly plot entire warehouse tables efficiently. + +**2. Plotting large local files** + +If you have large `.csv` or `.parquet` files, plotting them locally is challenging. You might not have enough memory in your laptop. Furthermore, as you transform your data, those transformed datasets will consume memory, making it even more challenging. With JupySQL, loading, aggregating, and summarizing is performed in DuckDB, an embedded SQL engine; allowing you to plot larger-than-memory datasets from your laptop. + +## Download data + +In this example, we'll demonstrate this second use case and query a `.parquet` file using DuckDB. However, the same code applies for plotting data stored in a database or data warehoouse such as Snowflake, Redshift, BigQuery, PostgreSQL, etc. + +```{code-cell} ipython3 +from pathlib import Path +from urllib.request import urlretrieve + +url = "https://d37ci6vzurychx.cloudfront.net/trip-data/yellow_tripdata_2021-01.parquet" + +if not Path("yellow_tripdata_2021-01.parquet").is_file(): + urlretrieve(url, "yellow_tripdata_2021-01.parquet") +``` + +## Setup + +```{note} +`%sqlplot` requires `matplotlib`: `pip install matplotlib` and this example requires +duckdb-engine: `pip install duckdb-engine` +``` + ++++ + +Load the extension and connect to an in-memory DuckDB database: + +```{code-cell} ipython3 +%load_ext sql +``` + +```{code-cell} ipython3 +%sql duckdb:// +``` + +We'll be using a sample dataset that contains historical taxi data from NYC: + ++++ + +## Data preview + +```{code-cell} ipython3 +%%sql +SELECT * FROM "yellow_tripdata_2021-01.parquet" LIMIT 3 +``` + +```{code-cell} ipython3 +%%sql +SELECT COUNT(*) FROM "yellow_tripdata_2021-01.parquet" +``` + +## Boxplot + +```{note} +To use `%sqlplot boxplot`, your SQL engine must support: + +`percentile_disc(...) WITHIN GROUP (ORDER BY ...)` + +[Snowflake](https://docs.snowflake.com/en/sql-reference/functions/percentile_disc.html), +[Postgres](https://www.postgresql.org/docs/9.4/functions-aggregate.html), +[DuckDB](https://duckdb.org/docs/sql/aggregates), and others support this. +``` + +To create a boxplot, call `%sqlplot boxplot`, and pass the name of the table, and the column you want to plot. Since we're using DuckDB for this example, the table is the path to the parquet file. + +```{code-cell} ipython3 +%sqlplot boxplot --table yellow_tripdata_2021-01.parquet --column trip_distance +``` + +There are many outliers in the data, let's find the 90th percentile to use it as cutoff value, this will allow us to create a cleaner visualization: + +```{code-cell} ipython3 +%%sql +SELECT percentile_disc(0.90) WITHIN GROUP (ORDER BY trip_distance), +FROM 'yellow_tripdata_2021-01.parquet' +``` + +Now, let's create a query that filters by the 90th percentile. Note that we're using the `--save`, and `--no-execute` functions. This tells JupySQL to store the query, but *skips execution*. We'll reference it in our next plotting call. + +```{code-cell} ipython3 +%%sql --save short_trips --no-execute +SELECT * +FROM "yellow_tripdata_2021-01.parquet" +WHERE trip_distance < 6.3 +``` + +Now, let's plot again, but this time let's pass `--table short_trips`. Note that this table *doesn't exist*; JupySQL will automatically infer and use the saved snippet defined above. + +```{code-cell} ipython3 +%sqlplot boxplot --table short_trips --column trip_distance +``` + +We can see the highest value is a bit over 6, that's expected since we set a 6.3 cutoff value. + ++++ + +If you wish to specify the saved snippet explicitly, please use the `--with` argument. +[Click here](../compose) for more details on when to specify `--with` explicitly. + +```{code-cell} ipython3 +%sqlplot boxplot --table short_trips --column trip_distance --with short_trips +``` + +## Histogram + +To create a histogram, call `%sqlplot histogram`, and pass the name of the table, the column you want to plot, and the number of bins. Similarly to what we did in the [Boxplot](#boxplot) example, JupySQL detects a saved snippet and only plots such data subset. + +```{code-cell} ipython3 +%sqlplot histogram --table short_trips --column trip_distance --bins 10 +``` + +## Customize plot + +`%sqlplot` returns a `matplotlib.Axes` object that you can further customize: + +```{code-cell} ipython3 +ax = %sqlplot histogram --table short_trips --column trip_distance --bins 50 +ax.grid() +ax.set_title("Trip distance from trips < 6.3") +_ = ax.set_xlabel("Trip distance") +``` + +## Bar plot + +To create a bar plot, call `%sqlplot bar`, and pass the name of the table and the column you want to plot. We will use the snippet created in the [Boxplot](#boxplot) example and JupySQL will plot for that subset of data. + +```{code-cell} ipython3 +%sqlplot bar --table short_trips --column payment_type +``` + +## Pie plot + +To create a pie plot, call `%sqlplot pie`, and pass the name of the table and the column you want to plot. We will reuse the code snippet from the previous example on [Boxplot](#boxplot), and JupySQL will generate a plot for that specific subset of data. + +```{code-cell} ipython3 +%sqlplot pie --table short_trips --column payment_type +``` diff --git a/doc/quick-start.md b/doc/quick-start.md new file mode 100644 index 000000000..ee4c47810 --- /dev/null +++ b/doc/quick-start.md @@ -0,0 +1,137 @@ +--- +jupytext: + cell_metadata_filter: -all + formats: md:myst + notebook_metadata_filter: myst + text_representation: + extension: .md + format_name: myst + format_version: 0.13 + jupytext_version: 1.15.1 +kernelspec: + display_name: Python 3 (ipykernel) + language: python + name: python3 +myst: + html_meta: + description lang=en: 'Quickstart for JupySQL: a package to run SQL in Jupyter' + keywords: jupyter, sql, jupysql + property=og:locale: en_US +--- + +# Quick Start + +JupySQL allows you to run SQL and plot large datasets in Jupyter via a `%sql`, `%%sql`, and `%sqlplot` magics. JupySQL is compatible with all major databases (e.g., PostgreSQL, MySQL, SQL Server), data warehouses (e.g., Snowflake, BigQuery, Redshift), and embedded engines (SQLite, and DuckDB). + +It is a fork of `ipython-sql` with many bug fixes and a lot of great new features! + ++++ + +## Installation + +Run this on your terminal (we'll use DuckDB for this example): + +```sh +pip install jupysql duckdb-engine +``` + +Or the following in a Jupyter notebook: + +```{code-cell} ipython3 +%pip install jupysql duckdb-engine --quiet +``` + +You might also install it from conda: + +```sh +conda install jupysql -c conda-forge +``` + +## Setup + +```{tip} +If you are unfamiliar with Jupyter magics, you can refer to our [FAQ](community/FAQ.md#what-is-a-magic). Also, you can view the documentation and command line arguments of any magic command by running `%magic?` like `%sql?` or `%sqlplot?`. +``` + + +Load the extension: + +```{code-cell} ipython3 +%load_ext sql +``` + +Let's download some sample `.csv` data: + +```{code-cell} ipython3 +from pathlib import Path +from urllib.request import urlretrieve + +if not Path("penguins.csv").is_file(): + urlretrieve( + "https://raw.githubusercontent.com/mwaskom/seaborn-data/master/penguins.csv", + "penguins.csv", + ) +``` + +Start a DuckDB in-memory database: + +```{code-cell} ipython3 +%sql duckdb:// +``` + +```{tip} +You can create as many connections as you want. Pass an `--alias {alias}` to easily +[switch them or close](howto.md#switch-connections) them. +``` + +## Querying + +For short queries, you can write them in a single line via the `%sql` line magic: + +```{code-cell} ipython3 +%sql SELECT * FROM penguins.csv LIMIT 3 +``` + +For longer queries, you can break them down into multiple lines using the `%%sql` cell magic: + +```{code-cell} ipython3 +%%sql +SELECT * +FROM penguins.csv +WHERE bill_length_mm > 40 +LIMIT 3 +``` + +## Saving queries + +```{code-cell} ipython3 +%%sql --save not_nulls --no-execute +SELECT * +FROM penguins.csv +WHERE bill_length_mm IS NOT NULL +AND bill_depth_mm IS NOT NULL +``` + +## Plotting + +```{code-cell} ipython3 +%sqlplot boxplot --column bill_length_mm bill_depth_mm --table not_nulls +``` + +```{code-cell} ipython3 +%sqlplot histogram --column bill_length_mm bill_depth_mm --table not_nulls +``` + +## `pandas` integration + +```{code-cell} ipython3 +result = %sql SELECT * FROM penguins.csv +``` + +```{code-cell} ipython3 +df = result.DataFrame() +``` + +```{code-cell} ipython3 +df.head() +``` diff --git a/doc/square-no-bg-small.png b/doc/square-no-bg-small.png new file mode 100644 index 000000000..18b5c3ecf Binary files /dev/null and b/doc/square-no-bg-small.png differ diff --git a/doc/static/benchmarking-time_1.png b/doc/static/benchmarking-time_1.png new file mode 100644 index 000000000..7482b811f Binary files /dev/null and b/doc/static/benchmarking-time_1.png differ diff --git a/doc/static/benchmarking-time_2.png b/doc/static/benchmarking-time_2.png new file mode 100644 index 000000000..bf37f586d Binary files /dev/null and b/doc/static/benchmarking-time_2.png differ diff --git a/doc/static/benchmarking-time_3.png b/doc/static/benchmarking-time_3.png new file mode 100644 index 000000000..5d0cd3ef0 Binary files /dev/null and b/doc/static/benchmarking-time_3.png differ diff --git a/doc/static/body_mass_g_R.png b/doc/static/body_mass_g_R.png new file mode 100644 index 000000000..f24d32afd Binary files /dev/null and b/doc/static/body_mass_g_R.png differ diff --git a/doc/static/create-connection.gif b/doc/static/create-connection.gif new file mode 100644 index 000000000..fe3bc2705 Binary files /dev/null and b/doc/static/create-connection.gif differ diff --git a/doc/static/delete-connection.gif b/doc/static/delete-connection.gif new file mode 100644 index 000000000..333699427 Binary files /dev/null and b/doc/static/delete-connection.gif differ diff --git a/doc/static/edit-connection.gif b/doc/static/edit-connection.gif new file mode 100644 index 000000000..3c0ec9a7b Binary files /dev/null and b/doc/static/edit-connection.gif differ diff --git a/doc/static/etl-header.png b/doc/static/etl-header.png new file mode 100644 index 000000000..4ee60e7ef Binary files /dev/null and b/doc/static/etl-header.png differ diff --git a/doc/static/existing-connection.gif b/doc/static/existing-connection.gif new file mode 100644 index 000000000..e64df731f Binary files /dev/null and b/doc/static/existing-connection.gif differ diff --git a/doc/static/format-sql.gif b/doc/static/format-sql.gif new file mode 100644 index 000000000..213344c99 Binary files /dev/null and b/doc/static/format-sql.gif differ diff --git a/doc/static/github-codespace-setup.png b/doc/static/github-codespace-setup.png new file mode 100644 index 000000000..fb45ece19 Binary files /dev/null and b/doc/static/github-codespace-setup.png differ diff --git a/doc/static/github-codespace.png b/doc/static/github-codespace.png new file mode 100644 index 000000000..e156c65a7 Binary files /dev/null and b/doc/static/github-codespace.png differ diff --git a/doc/static/launch-on-binder.png b/doc/static/launch-on-binder.png new file mode 100644 index 000000000..7078e1390 Binary files /dev/null and b/doc/static/launch-on-binder.png differ diff --git a/doc/static/ploomber-engine-output.png b/doc/static/ploomber-engine-output.png new file mode 100644 index 000000000..06f181dac Binary files /dev/null and b/doc/static/ploomber-engine-output.png differ diff --git a/doc/static/pycharm-interactive.png b/doc/static/pycharm-interactive.png new file mode 100644 index 000000000..c18780b63 Binary files /dev/null and b/doc/static/pycharm-interactive.png differ diff --git a/doc/static/share-notebook.png b/doc/static/share-notebook.png new file mode 100644 index 000000000..f66e5b04f Binary files /dev/null and b/doc/static/share-notebook.png differ diff --git a/doc/static/spyder-interactive.png b/doc/static/spyder-interactive.png new file mode 100644 index 000000000..790ad8384 Binary files /dev/null and b/doc/static/spyder-interactive.png differ diff --git a/doc/static/sql-autocompletion.png b/doc/static/sql-autocompletion.png new file mode 100644 index 000000000..a895d35f3 Binary files /dev/null and b/doc/static/sql-autocompletion.png differ diff --git a/doc/static/syntax-highlighting.png b/doc/static/syntax-highlighting.png new file mode 100644 index 000000000..ef7f4d8fa Binary files /dev/null and b/doc/static/syntax-highlighting.png differ diff --git a/doc/static/vscode-env.png b/doc/static/vscode-env.png new file mode 100644 index 000000000..5f8e13284 Binary files /dev/null and b/doc/static/vscode-env.png differ diff --git a/doc/static/vscode-file-type.png b/doc/static/vscode-file-type.png new file mode 100644 index 000000000..10fb0c5cf Binary files /dev/null and b/doc/static/vscode-file-type.png differ diff --git a/doc/static/vscode-ipykernel.png b/doc/static/vscode-ipykernel.png new file mode 100644 index 000000000..fa72fe3e2 Binary files /dev/null and b/doc/static/vscode-ipykernel.png differ diff --git a/doc/static/vscode-run-interactive.png b/doc/static/vscode-run-interactive.png new file mode 100644 index 000000000..58d888c24 Binary files /dev/null and b/doc/static/vscode-run-interactive.png differ diff --git a/doc/tutorials/duckdb-github.md b/doc/tutorials/duckdb-github.md new file mode 100644 index 000000000..1490ba6b4 --- /dev/null +++ b/doc/tutorials/duckdb-github.md @@ -0,0 +1,137 @@ +--- +jupytext: + notebook_metadata_filter: myst + text_representation: + extension: .md + format_name: myst + format_version: 0.13 + jupytext_version: 1.14.5 +kernelspec: + display_name: Python 3 (ipykernel) + language: python + name: python3 +myst: + html_meta: + description lang=en: Use JupySQL and DuckDB to query JSON files with SQL + keywords: jupyter, sql, jupysql, json, duckdb + property=og:locale: en_US +--- + +# Analyzing Github Data with JupySQL + DuckDB + +JupySQL and DuckDB have many use cases. Here, let's query the Github REST API to run some analysis using these tools. + +```{code-cell} ipython3 +:tags: [remove-cell] + +from pathlib import Path + +paths = ["jupyterdata.json", "jupyterdata.csv"] + +for path in paths: + path = Path(path) + + if path.exists(): + print(f"Deleting {path}") + path.unlink() +``` + +```{code-cell} ipython3 +:tags: [hide-output] + +%pip install jupysql duckdb duckdb-engine rich --quiet +``` + +## Pulling from Github API + +First, let's pull information on repositories relating to 'Jupyter' from the Github API. Some operations may require a token, but accessing them is very simple if you have a Github account. More information on authentication can be found [here](https://docs.github.com/en/rest/guides/getting-started-with-the-rest-api?apiVersion=2022-11-28#authenticating). Our query will pull any repository relating to Jupyter, sorted by most to least stars. + +```{code-cell} ipython3 +import requests +import json +from pathlib import Path + +res = requests.get( + "https://api.github.com/search/repositories?q=jupyter&sort=stars&order=desc", +) +``` + +We then parse the information pulled from the API into a JSON format that we can run analysis on with JupySQL. We also need to save it locally as a `.json` file. Let's make it easier by only dumping the 'items' array. + +```{code-cell} ipython3 +parsed = res.json() + +_ = Path("jupyterdata.json").write_text(json.dumps(parsed["items"], indent=4)) +``` + +## Querying JSON File + +Let's get some information on our first result. Load the extension and start a DuckDB in-memory database: + +```{code-cell} ipython3 +%load_ext sql +%sql duckdb:// +``` + +Looking at our .json file, we have information on thousands of repositories. To start, let's load information on our results. + +```{code-cell} ipython3 +:tags: [hide-output] + +%%sql +SELECT * +FROM read_json_auto('jupyterdata.json') +``` + +However, this is a lot of information. After seeing what we're working with, let's pull the name of the repository, the author, the description, and the URL to make things cleaner. Let's also limit our results to the top 5 starred repos. + +```{code-cell} ipython3 +%%sql +SELECT + name AS name, + owner.login AS user, + description AS description, + html_url AS URL, + stargazers_count AS stars +FROM read_json_auto('jupyterdata.json') +LIMIT 5 +``` + +We can also load all of the pulled repositories that, say, have a certain range of stars: + +```{code-cell} ipython3 +%%sql +SELECT + name AS name, + owner.login AS user, + description AS description, + html_url AS URL, + stargazers_count AS stars +FROM read_json_auto('jupyterdata.json') +WHERE stargazers_count < 15000 AND stargazers_count > 10000 +``` + +And save it to a .csv file: + +```{code-cell} ipython3 +%%sql +COPY ( + SELECT + name AS name, + owner.login AS user, + description AS description, + html_url AS URL, + stargazers_count AS stars + FROM read_json_auto('jupyterdata.json') + WHERE stargazers_count < 15000 AND stargazers_count > 10000 +) + +TO 'jupyterdata.csv' (HEADER, DELIMITER ','); +``` + +```{code-cell} ipython3 +%%sql +SELECT * FROM 'jupyterdata.csv' +``` + +There's no shortage of information that we can pull from this API, so this is just one example. Feel free to give it a try yourself— or explore using JupySQL with another API or `.json` file! diff --git a/doc/tutorials/duckdb-native-sqlalchemy.md b/doc/tutorials/duckdb-native-sqlalchemy.md new file mode 100644 index 000000000..a6cc9f60f --- /dev/null +++ b/doc/tutorials/duckdb-native-sqlalchemy.md @@ -0,0 +1,122 @@ +--- +jupytext: + notebook_metadata_filter: myst + text_representation: + extension: .md + format_name: myst + format_version: 0.13 + jupytext_version: 1.14.7 +kernelspec: + display_name: Python 3 (ipykernel) + language: python + name: python3 +myst: + html_meta: + description lang=en: JupySQL and DuckDB with SQLAlchemy vs native connection + keywords: jupyter, jupysql, duckdb, sqlalchemy + property=og:locale: en_US +--- + +# DuckDB (native vs SQLAlchemy) + +Beginning in 0.9, JupySQL supports DuckDB via a native connection and SQLAlchemy, both with comparable performance. JupySQL adds a small overhead; however, this overhead is constant. + +At the moment, the only difference is that some features are only available when using SQLAlchemy. + ++++ + +## Performance comparison (pandas) + +```{code-cell} ipython3 +import pandas as pd +import numpy as np + +num_rows = 1_000_000 +num_cols = 100 + +df = pd.DataFrame(np.random.randn(num_rows, num_cols)) +``` + +## Raw DuckDB + +```{code-cell} ipython3 +import duckdb + +conn = duckdb.connect() +``` + +```{code-cell} ipython3 +%%timeit +conn.execute("SELECT * FROM df").df() +``` + +### DuckDB + SQLALchemy + +```{code-cell} ipython3 +%load_ext sql +%config SqlMagic.autopandas = True +%config SqlMagic.displaycon = False +%sql duckdb:// --alias duckdb-sqlalchemy +``` + +```{code-cell} ipython3 +%%timeit +_ = %sql SELECT * FROM df +``` + +## DuckDB + native + +```{code-cell} ipython3 +%sql conn --alias duckdb-native +``` + +```{code-cell} ipython3 +%%timeit +_ = %sql SELECT * FROM df +``` + +## Performance comparison (polars) + +```{code-cell} ipython3 +%config SqlMagic.autopolars = True +%sql duckdb-sqlalchemy +``` + +## Raw DuckDB + +```{code-cell} ipython3 +%%timeit +conn.execute("SELECT * FROM df").pl() +``` + +### DuckDB + SQLAlchemy + +```{code-cell} ipython3 +%%timeit +_ = %sql SELECT * FROM df +``` + +### DuckDB + native + +```{code-cell} ipython3 +%sql duckdb-native +``` + +```{code-cell} ipython3 +%%timeit +_ = %sql SELECT * FROM df +``` + +## Limitations of using native connections + +As of version 0.9.0, the only caveat is that `%sqlcmd` won't work with a native connection. + +```{code-cell} ipython3 +--- +editable: true +slideshow: + slide_type: '' +tags: [raises-exception] +--- +%sqlcmd +``` diff --git a/doc/tutorials/etl.md b/doc/tutorials/etl.md new file mode 100644 index 000000000..80d71cb9d --- /dev/null +++ b/doc/tutorials/etl.md @@ -0,0 +1,374 @@ +--- +jupyter: + jupytext: + notebook_metadata_filter: myst + text_representation: + extension: .md + format_name: markdown + format_version: '1.3' + jupytext_version: 1.14.5 + kernelspec: + display_name: Python 3 (ipykernel) + language: python + name: python3 +--- + +# Schedule ETLs with Jupysql and GitHub actions + + +![etl-header](../static/etl-header.png) + + +In this blog you'll achieve: +1. Have basic understanding of ETLs and JupySQL +2. Use the public Penguins dataset and perform ETL. +3. Schedule the ETL we've built on GitHub actions. + + +## Introduction +In this brief yet informative guide, we aim to provide you with a comprehensive +understanding of the fundamental concepts of ETL (Extract, Transform, Load) and JupySQL, +a flexible and versatile tool that allows for seamless SQL based ETL from Jupyter. + +Our primary focus will be on demonstrating how to effectively execute ETLs through +JupySQL, the popular and powerful Python library designed for SQL interaction, +while also highlighting the benefits of automating the ETL process through +scheduling a full example ETL notebook via GitHub actions. + + +### But first, what is an ETL? +Now, let's dive into the details. `ETL` (Extract, Transform, Load) crucial process +in data management that involves the extraction of data from various sources, +transformation of the extracted data into a usable format, and loading the +transformed data into a target database or data warehouse. It is an essential +process for data analysis, data science, data integration, and data migration, among other purposes. +On the other hand, JupySQL is a widely-used Python library that simplifies the interaction +with databases through the power of SQL queries. By using JupySQL, data scientists +and analysts can easily execute SQL queries, manipulate data frames, and interact +with databases from their Jupyter notebooks. + + +### Why ETLs are important? + +ETLs play a significant role in data analytics and business intelligence. +They help businesses to collect data from various sources, including social media, +web pages, sensors, and other internal and external systems. By doing this, +businesses can obtain a holistic view of their operations, customers, and market trends. + +After extracting data, ETLs transform it into a structured format, such as a relational +database, which allows businesses to analyze and manipulate data easily. +By transforming data, ETLs can clean, validate, and standardize it, making it easier +to understand and analyze. + +Finally, ETLs load the data into a database or data warehouse, +where businesses can access it easily. By doing this, +ETLs enable businesses to access accurate and up-to-date information, +allowing them to make informed decisions. + + +### What is JupySQL? + +JupySQL is an extension for Jupyter notebooks that allows you to interact + with databases using SQL queries. It provides a convenient way to access +databases and data warehouses directly from Jupyter notebooks, allowing you to + perform complex data manipulations and analyses. + +JupySQL supports multiple database management systems, including SQLite, MySQL, +PostgreSQL, DuckDB, Oracle, Snowflake and more (check out our integrations section +on the left to learn more). You can connect to databases using standard connection +strings or through the use of environment variables. + + +### Why JupySQL? +JupySQL, a powerful tool, facilitates direct SQL query interaction with +databases inside Jupyter notebooks. With a view to carrying out efficient +and accurate data extraction and transformation processes, there are several +critical factors to consider when performing ETLs via JupySQL. JupySQL provides +users with the necessary tools to interact with data sources and perform data +transformations with ease. To save valuable time and effort while guaranteeing +consistency and reliability, automating the ETL process through scheduling a +full ETL notebook via GitHub actions can be a game-changer. By utilizing +JupySQL, users can achieve the best of both worlds, data interactivity (Jupyter) +and ease of usage and SQL connectivity (JupySQL), thereby streamlining the data +management process and allowing data scientists and analysts to concentrate on +their core competencies - generating valuable insights and reports. + + +### Getting started with JupySQL + +To use JupySQL, you need to install it using pip. +You can run the following command: + +```python +!pip install jupysql --quiet +``` + +Once installed, you can load the extension in Jupyter notebooks using the following command: + +```python +%load_ext sql +``` + + +After loading the extension, you can connect to a database using the following command: + +```python +%sql dialect://username:password@host:port/database +``` + +For example, to connect to a local DuckDB database, you can use the following command: + + +```python +%sql duckdb:// +``` + +## Performing ETLs using JupySQL + +To perform ETLs using JupySQL, we will follow the standard ETL process, which involves +the following steps: + +1. Extract data +2. Transform data +3. Load data +4. Extract data + + +### Extract data +To extract data using JupySQL, we need to connect to the source database and execute +a query to retrieve the data. For example, to extract data from a MySQL database, +we can use the following command: + +```python +%sql mysql://username:password@host:port/database +data = %sql SELECT * FROM mytable +``` +This command connects to the MySQL database using the specified connection string +and retrieves all the data from the "mytable" table. The data is stored in the +"data" variable as a Pandas DataFrame. + +**Note**: We can also use `%%sql df <<` to save the data into the `df` variable + +Since we'll be running locally via DuckDB we can simply Extract a public dataset and start working immediately. +We're going to get our sample dataset (we will work with the Penguins datasets via a csv file): + + +```python +from urllib.request import urlretrieve + +_ = urlretrieve( + "https://raw.githubusercontent.com/mwaskom/seaborn-data/master/penguins.csv", + "penguins.csv", +) +``` + +And we can get a sample of the data to check we're connected and we can query the data: + +```sql +SELECT * +FROM penguins.csv +LIMIT 3 +``` + +### Transform data +After extracting data, it's often necessary to transform it into a format that's +more suitable for analysis. This step may include cleaning data, filtering data, +aggregating data, and combining data from multiple sources. Here are some common +data transformation techniques: + +* **Cleaning data**: Data cleaning involves removing or fixing errors, inconsistencies, + or missing values in the data. For example, you might remove rows with missing values, + replace missing values with the mean or median value, or fix typos or formatting errors. +* **Filtering data**: Data filtering involves selecting a subset of data that meets + specific criteria. For example, you might filter data to only include records + from a specific date range, or records that meet a certain threshold. +* **Aggregating data**: Data aggregation involves summarizing data by calculating + statistics such as the sum, mean, median, or count of a particular variable. + For example, you might aggregate sales data by month or by product category. +* **Combining data**: Data combination involves merging data from multiple sources + to create a single dataset. For example, you might combine data from different + tables in a relational database, or combine data from different files. + +In JupySQL, you can use Pandas DataFrame methods to perform data transformations or native SQL. +For example, you can use the rename method to rename columns, the dropna method to +remove missing values, and the astype method to convert data types. I'll demonstrate how to do it either with pandas or SQL. + +* Note: You can use either `%sql` or `%%sql`, check out the difference between the two [here](https://jupysql.ploomber.io/en/latest/community/developer-guide.html?highlight=%25sql%20vs%20%25%25sql#magics-e-g-sql-sql-etc) + + +Here's an example of how to use Pandas and the JupySQL alternatives to transform data: +```python +# Rename columns +df = data.rename(columns={'old_column_name': 'new_column_name'}) # Pandas +%%sql df << +SELECT *, old_column_name +AS new_column_name +FROM data; # JupySQL + + +# Remove missing values +data = data.dropna() # Pandas +%%sql df << +SELECT * +FROM data +WHERE column_name IS NOT NULL; # JupySQL single column, can add conditions to all columns as needed. + + +# Convert data types +data['date_column'] = data['date_column'].astype('datetime64[ns]') # Pandas +%sql df << +SELECT *, +CAST(date_column AS timestamp) AS date_column +FROM data # Jupysql + +# Filter data +filtered_data = data[data['sales'] > 1000] # Pandas +%%sql df << +SELECT * FROM data +WHERE sales > 1000; # JupySQL + +# Aggregate data +monthly_sales = data.groupby(['year', 'month'])['sales'].sum() # Pandas +%%sql df << +SELECT year, month, +SUM(sales) as monthly_sales +FROM data +GROUP BY year, month # JupySQL + +# Combine data +merged_data = pd.merge(data1, data2, on='key_column') # Pandas +%%sql df << +SELECT * FROM data1 +JOIN data2 +ON data1.key_column = data2.key_column; # JupySQL +``` +In our example we'll use a simple transformations, in a similar manner to the above code. +We'll clean our data from NAs and will split a column (species) into 3 individual columns (named for each species): + + +```sql magic_args="transformed_df <<" +SELECT * +FROM penguins.csv +WHERE species IS NOT NULL AND island IS NOT NULL AND bill_length_mm IS NOT NULL AND bill_depth_mm IS NOT NULL +AND flipper_length_mm IS NOT NULL AND body_mass_g IS NOT NULL AND sex IS NOT NULL; +``` + +```python +# Map the species column into classifiers +transformed_df = transformed_df.DataFrame().dropna() +transformed_df["mapped_species"] = transformed_df.species.map( + {"Adelie": 0, "Chinstrap": 1, "Gentoo": 2} +) +transformed_df.drop("species", inplace=True, axis=1) +``` + +```python +# Checking our transformed data +transformed_df.head() +``` + + +### Load data + +After transforming the data, we need to load it into a destination database or +data warehouse. We can use ipython-sql to connect to the destination database +and execute SQL queries to load the data. For example, to load data into a PostgreSQL +database, we can use the following command: + +```python +%sql postgresql://username:password@host:port/database +%sql DROP TABLE IF EXISTS mytable; +%sql CREATE TABLE mytable (column1 datatype1, column2 datatype2, ...); +%sql COPY mytable FROM '/path/to/datafile.csv' DELIMITER ',' CSV HEADER; +``` + +This command connects to the PostgreSQL database using the specified connection +string, drops the "mytable" table if it exists, creates a new table with the specified +columns and data types, and loads the data from the CSV file. + + + +Since our use case is using DuckDB locally we can simply save the newly created `transformed_df` into a csv file, but we can also use the snipped above to save it into our DB or DWH depending on our use case. + +Run the following step to save the new data as a CSV file: + +```python +transformed_df.to_csv("transformed_data.csv") +``` + +We can see a new file called `transformed_data.csv` was created for us. +In the next step we'll see how we can automate this process and consume the final file via GitHub. + + +## Scheduling on GitHub actions +The last step in our process is executing the complete notebook via GitHub actions. +To do that we can use `ploomber-engine` which lets you schedule notebooks, along with other notebook capabilities such as profiling, debugging etc. If needed we can pass external parameters to our notebook and make it a generic template. +- Note: Our notebook file is loading a public dataset and saves it after ETL locally, we can easily change it to consume any dataset, and load it to S3, visualize the data as a dashboard and more. + +For our example we can use this sample ci.yml file (this is what sets the github workflow in your repository), and put it in our repository, the final file should +be located under `.github/workflows/ci.yml`. + +Content of the `ci.yml` file: + +```yaml +name: CI + +on: + push: + pull_request: + schedule: + - cron: '0 0 4 * *' + +# These permissions are needed to interact with GitHub's OIDC Token endpoint. +permissions: + id-token: write + contents: read + +jobs: + report: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + - name: Set up Python ${{ matrix.python-version }} + uses: conda-incubator/setup-miniconda@v2 + with: + python-version: '3.10' + miniconda-version: latest + activate-environment: conda-env + channels: conda-forge, defaults + + + - name: Run notebook + env: + PLOOMBER_STATS_ENABLED: false + PYTHON_VERSION: '3.10' + shell: bash -l {0} + run: | + eval "$(conda shell.bash hook)" + + # pip install -r requirements.txt + pip install jupysql pandas ploomber-engine --quiet + ploomber-engine --log-output posthog.ipynb report.ipynb + + - uses: actions/upload-artifact@v3 + if: always() + with: + name: Transformed_data + path: transformed_data.csv +``` + +In this example CI, I've also added a scheduled trigger, this job will run nightly at 4 am. + + +## Conclusion + +ETLs are an essential process for data analytics and business intelligence. +They help businesses to collect, transform, and load data from various sources, +making it easier to analyze and make informed decisions. JupySQL is a powerful +tool that allows you to interact with databases using SQL queries directly in Jupyter +notebooks. Combined with Github actions we can create powerful workflows that +can be scheduled and help us get the data to its final stage. + +By using JupySQL, you can perform ETLs easily and efficiently, +allowing you to extract, transform, and load data in a structured format while +Github actions allocate compute and set the environment. diff --git a/doc/tutorials/excel.md b/doc/tutorials/excel.md new file mode 100644 index 000000000..71cb9802a --- /dev/null +++ b/doc/tutorials/excel.md @@ -0,0 +1,91 @@ +--- +jupytext: + notebook_metadata_filter: myst + text_representation: + extension: .md + format_name: myst + format_version: 0.13 + jupytext_version: 1.14.5 +kernelspec: + display_name: Python 3 (ipykernel) + language: python + name: python3 +myst: + html_meta: + description lang=en: Read Excel files using Jupysql and query on it + keywords: jupyter, sql, jupysql, excel, xlsx + property=og:locale: en_US +--- + +# Loading and Querying Excel Files + +In this tutorial, we will be using small financial data stored in an Excel file containing over 700 records. The dataset is publicly available [here](https://go.microsoft.com/fwlink/?LinkID=521962). We will use the `read_excel` function from the pandas library to read the Excel file and store it in the database using the `%sql --persist` command of jupysql, which works across multiple databases. For additional compatibility between different databases and jupysql, please check out this [page](../integrations/compatibility.md). + +```{note} +DuckDB doesn't support reading excel files. Their `excel` [extension](https://duckdb.org/docs/extensions/overview) provides excel like formatting. +``` + + +```{note} +For this tutorial, we aim to showcase the versatility of jupysql as a framework by using `--persist`. However, DuckDB natively supports Pandas DataFrame and you do not need to use `--persist`. With DuckDB, complex queries such as aggregations and joins can run more efficiently on the DataFrame compared to Pandas native functions. You can refer to this [blog](https://duckdb.org/2021/05/14/sql-on-pandas.html) for a detailed comparison (Note: the comparison is based on Pandas v1.\*, not the recently released Pandas v2.\*, which uses PyArrow as a backend). +``` + +Installing dependencies: + +```{code-cell} ipython3 +--- +:tags: [hide-output] +--- + +%pip install jupysql duckdb duckdb-engine pandas openpyxl --quiet +``` + +Reading dataframe using `pandas.read_excel`: + +```{code-cell} ipython3 +import pandas as pd + +df = pd.read_excel("https://go.microsoft.com/fwlink/?LinkID=521962") +``` + +Initializing jupysql and connecting to `duckdb` database + +```{code-cell} ipython3 +%load_ext sql +%sql duckdb:// +``` + +Persisting the dataframe in duckdb database. It is stored in table named `df`. + +```{code-cell} ipython3 +# If you are using DuckDB, you can omit this cell +%sql --persist df +``` + +## Running some standard queries +- Selecting first 3 queries + +```{code-cell} ipython3 +%%sql +SELECT * +FROM df +LIMIT 3 +``` + +- Countries in the database + +```{code-cell} ipython3 +%%sql +SELECT DISTINCT Country +FROM df +``` + +- Evaluating total profit country-wise and ordering them in desceding order according to profit. + +```{code-cell} ipython3 +%%sql +select Country, SUM(Profit) Total_Profit +from df +group by Country +order by Total_Profit DESC +``` diff --git a/doc/tutorials/product-analytics.md b/doc/tutorials/product-analytics.md new file mode 100644 index 000000000..08ea554ed --- /dev/null +++ b/doc/tutorials/product-analytics.md @@ -0,0 +1,205 @@ +--- +jupytext: + notebook_metadata_filter: myst + text_representation: + extension: .md + format_name: myst + format_version: 0.13 + jupytext_version: 1.15.1 +kernelspec: + display_name: Python 3 (ipykernel) + language: python + name: python3 +--- + +# Product Analytics + ++++ + +Product analytics is the process of analyzing users' behaviours when they interact with a product or service. It helps to understand which features users like, what challenges they face when using the product or service, and at what point they turn away. Product teams use these insights to improve the product or service. + +In this tutorial, we will demonstrate how to perform product analytics using SQL for an e-commerce website. + ++++ + +## Metrics + ++++ + +Let's look at some common metrics used in product analytics. + +`Growth Rate`: User growth rate is the speed at which a business gains new users over a particular period. It is usually measured within a monthly period. + +`Retention`: User retention is an important metric that looks at what percentage of first-time users returned in subsequent periods. + +Both these metrics will help to understand how well the users are interacting with the E-commerce platform. + ++++ + +## Dataset + +For this tutorial, we will generate a small dataset `user_activity`. It consists of three columns: `user_id`, `date`, `activity_count`. + +- **user_id** : the unique identifier of the user +- **date**: the date on which a user interaction has taken place +- **activity_count**: the number of interactions made by the user on that date. If the user never used this app before this month, this is considered their sign-up month. + +First, we'll install the required packages. + +```{code-cell} ipython3 +:tags: [hide-output] + +%pip install jupysql duckdb-engine --quiet +``` + +Now, load the extension and connect to an in-memory DuckDB database: + +```{code-cell} ipython3 +%load_ext sql +%sql duckdb:// +``` + +JupySQL allows users to run SQL queries easily using `%sql` and `%%sql` magics. We will use these magics to generate the dataset: + +```{code-cell} ipython3 +%%sql +CREATE TABLE user_activity ( + user_id INT NOT NULL, + date DATE NOT NULL, + activity_count INT NOT NULL, + PRIMARY KEY (user_id, date) +); +INSERT INTO user_activity (user_id, date, activity_count) +VALUES + (1, '2021-01-01', 5), + (1, '2021-02-01', 3), + (1, '2021-03-01', 2), + (2, '2021-01-01', 10), + (3, '2021-02-01', 1), + (3, '2021-03-01', 0), + (4, '2021-02-01', 6), + (5, '2021-01-01', 4), + (5, '2021-02-01', 5), + (5, '2021-03-01', 6), + (6, '2021-03-01', 7), + (7, '2021-03-01', 10); +``` + +Let's verify that the table is populated correctly. + +```{code-cell} ipython3 +%%sql +SELECT * FROM user_activity +``` + +## Growth + +As defined above, the growth rate is the percentage increase of the total number of users each month. + +We first calculate the total number of users in each month. JupySQL allows users to save query snippets using `--save` argument and use these snippets to compose larger queries. + +```{code-cell} ipython3 +%%sql --save monthly_user_count +Select MONTH(date) as month, COUNT(DISTINCT user_id) AS total_users +FROM user_activity +GROUP BY MONTH(date) +``` + +Here, we will group the dataset by the month of the date, and then count the number of distinct users as the total number of users. +We can use `monthly_user_count` in the cell below because it is saved from the cell above and jupysql automatically infers it when `monthly_user_count` is passed. +Also, note that '/' in SQL between two integers performs integer division. For example, 10/3 would be 3 instead of 3.33333. So the result needs to be multiplied by 1.0 to convert it to float. + +```{code-cell} ipython3 +%%sql +SELECT c1.month as PrevMonth, c2.month as CurrentMonth,ROUND((c2.total_users - c1.total_users)*1.0/c1.total_users*100, 2) AS Growth_Rate_in_Percentage +FROM monthly_user_count c1, monthly_user_count c2 +WHERE c1.month = c2.month - 1 +``` + +The user growth rate between January and February is 33.33% while that of the February-March period is 25%. + ++++ + +The use of self join in the query might be confusing. Here is a brief explanation of what the self join is doing. After we run the command +`FROM monthly_user_count c1, monthly_user_count c2` +The table we get is a cartesian product of these three rows: + +```{code-cell} ipython3 +%%sql +SELECT c1.month AS 'c1.month', c1.total_users AS 'c1.total_users', c2.month AS 'c2.month', c2.total_users AS 'c2.total_users' +FROM monthly_user_count c1, monthly_user_count c2 +``` + +Then, with **WHERE c1.month = c2.month - 1**, we filter out the total number of users for subsequential months. + +```{code-cell} ipython3 +%%sql +SELECT c1.month AS 'c1.month', c1.total_users AS 'c1.total_users', c2.month AS 'c2.month', c2.total_users AS 'c2.total_users' +FROM monthly_user_count c1, monthly_user_count c2 +WHERE c1.month = c2.month - 1 +``` + +As shown above, we calculate the final growth rate using c1.total_users and c2.total_users. + ++++ + +## Retention + ++++ + +The period over which user retention is calculated can vary across companies, Here, we define retention as the percentage of users who still use the app one month after their first login. + ++++ + +We will first create two query snippets : `first_time_users` and `retention_users`. + +```{code-cell} ipython3 +%%sql --save first_time_users + +SELECT MONTH(date) AS month, COUNT(DISTINCT u.user_id) AS first_time_users +FROM user_activity u +INNER JOIN ( + SELECT user_id, MIN(date) AS first_login + FROM user_activity + GROUP BY user_id +) t ON u.user_id = t.user_id AND u.date = t.first_login +GROUP BY MONTH(date) +``` + +From the results, we can see that in January, 3 users started to use the app. Similarly, 2 users started using the app in the month of February, and 2 users start using in March. + ++++ + +Then, for each month, we calculate the number of users who still use the app after one month of first-login + +```{code-cell} ipython3 +%%sql --save retention_users +SELECT MONTH(first_login) AS month, COUNT(DISTINCT u. user_id) AS retention_users +FROM user_activity u +INNER JOIN ( +SELECT user_id, MIN(date) AS first_login +FROM user_activity +GROUP BY user_id) t +ON u.user_id = t.user_id +WHERE MONTH(date) = MONTH(first_login) +1 +GROUP BY MONTH(first_login) +``` + +Here, the condition `WHERE MONTH(date) = MONTH(first_login) + 1` ensured that we only consider users who still using the app for at least one month since signing up on the platform. +As we can see, 2 out of 3 users continue to use the app beyond a month. + ++++ + +Now, we will join the `first_time_users` and `retention_users` tables and calculate the retention rate. + +```{code-cell} ipython3 +%%sql +SELECT f.month, first_time_users, IFNULL(retention_users, 0) AS retention_users, ROUND(retention_users * 1.0 / first_time_users, 4)*100 AS retention_rate +FROM first_time_users f +FULL OUTER JOIN retention_users r +ON f.month = r.month +``` + +## Summary + +In this tutorial, we learnt how to use cell magics in JupySQL and easily run SQL queries. We also learnt how we can formulate complex queries using `--save` argument. These tools come in handy when performing complex data analytics tasks like product analytics. diff --git a/doc/user-guide/argument-expansion.md b/doc/user-guide/argument-expansion.md new file mode 100644 index 000000000..4bf70b453 --- /dev/null +++ b/doc/user-guide/argument-expansion.md @@ -0,0 +1,108 @@ +--- +jupytext: + notebook_metadata_filter: myst + text_representation: + extension: .md + format_name: myst + format_version: 0.13 + jupytext_version: 1.14.7 +kernelspec: + display_name: Python 3 (ipykernel) + language: python + name: python3 +myst: + html_meta: + description lang=en: Variable substitution of arguments in Jupyter via JupySQL + keywords: jupyter, sql, jupysql, jinja + property=og:locale: en_US +--- + +# Parameterizing arguments + +```{versionadded} 0.10.8 +JupySQL uses Jinja templates for enabling parametrization of arguments. Arguments are parametrized with `{{variable}}`. +``` + + +## Parametrization via `{{variable}}` + +JupySQL supports variable expansion of arguments in the form of `{{variable}}`. This allows the user to specify arguments with placeholders that can be replaced by variables dynamically. + +The benefits of using parametrized arguments is that they can be reused for different purposes. + +Let's load some data and connect to the in-memory DuckDB instance: + +```{code-cell} ipython3 +%load_ext sql +%sql duckdb:// +%config SqlMagic.displaylimit = 3 +``` + +```{code-cell} ipython3 +filename = "penguins.csv" +``` + + +```{code-cell} ipython3 +from pathlib import Path +from urllib.request import urlretrieve + +if not Path("penguins.csv").is_file(): + urlretrieve( + "https://raw.githubusercontent.com/mwaskom/seaborn-data/master/penguins.csv", + filename, + ) +``` + +Now let's create a snippet from the data by declaring a `table` variable and use it in the `--save` argument. + ++++ + +### Create a snippet + +```{code-cell} ipython3 +table = "penguins_data" +``` + +```{code-cell} ipython3 +%%sql --save {{table}} +SELECT * +FROM penguins.csv +``` + +```{code-cell} ipython3 +snippet = %sqlcmd snippets {{table}} +print(snippet) +``` + + +### Plot a histogram + +Now, let's declare a variable `column` and plot a histogram on the data. + +```{code-cell} ipython3 +column = "body_mass_g" +``` + +```{code-cell} ipython3 +%sqlplot boxplot --table {{table}} --column {{column}} +``` + +### Profile and Explore + +We can use the `filename` variable to profile and explore the data as well: + +```{code-cell} ipython3 +%sqlcmd profile --table {{filename}} +``` + +```{code-cell} ipython3 +%sqlcmd explore --table {{filename}} +``` + +### Run some tests + +```{code-cell} ipython3 +%sqlcmd test --table {{table}} --column {{column}} --greater 3500 +``` + diff --git a/doc/user-guide/connection-file.md b/doc/user-guide/connection-file.md new file mode 100644 index 000000000..942b3c03a --- /dev/null +++ b/doc/user-guide/connection-file.md @@ -0,0 +1,219 @@ +--- +jupytext: + notebook_metadata_filter: myst + text_representation: + extension: .md + format_name: myst + format_version: 0.13 + jupytext_version: 1.15.1 +kernelspec: + display_name: Python 3 (ipykernel) + language: python + name: python3 +myst: + html_meta: + description lang=en: Using a connection file + keywords: jupyter, jupysql, sqlalchemy + property=og:locale: en_US +--- + +# Using a connection file + +```{important} +When using a connection file, ensure the file has the appropriate permissions, so only you can read its contents. +``` + +Using a connection file is the recommended way to manage connections, it helps you to: + +- Avoid storing your credentials in your notebook +- Manage multiple database connections +- Define them in a single place to use it in all your notebooks + +```{code-cell} ipython3 +%load_ext sql +``` + +By default, connections are read/stored in a `~/.jupysql/connections.ini` file: + +```{code-cell} ipython3 +%config SqlMagic.dsn_filename +``` + +However, you can change this: + +```{code-cell} ipython3 +%config SqlMagic.dsn_filename = "connections.ini" +``` + +```{tip} +For configuration settings other than connections, you can use a [`pyproject.toml` or `~/.jupysql/config`](../api/configuration.md#loading-from-a-file) file. +``` + +The `.ini` format defines sections and you can define key-value pairs within each section. For example: + +```ini +[section_name] +key = value +``` + +Add a section and set the key-value pairs to add a new connection. When JupySQL loads them, it'll initialize a [`sqlalchemy.engine.URL`](https://docs.sqlalchemy.org/en/20/core/engines.html#sqlalchemy.engine.URL.create) object and then start the connection. Valid keys are: + +- `drivername`: the name of the database backend +- `username`: the username +- `password`: database password +- `host`: name of the host +- `port`: the port number +- `database`: the database name +- `query`: a dictionary of string keys to be passed to the connection upon connect (learn more [here](https://docs.sqlalchemy.org/en/20/core/engines.html#sqlalchemy.engine.URL.create)) + +For example, to configure an in-memory DuckDB database: + +```ini +[duck] +drivername = duckdb +``` + +Or, to connect to a PostgreSQL database: + +```ini +[pg] +drivername = postgresql +username = person +password = mypass +host = localhost +port = 5432 +database = db +``` + +Or, to connect to an Oracle database, which might require some query parameters: + +```ini +[ora] +drivername = oracle+oracledb +username = myuser +password = mypass +host = my_oracle_server.example.com +port = 1521 +database = my_oracle_pdb.example.com +query = {"servicename": "my_oracle_db.example.com"} +``` + +```{code-cell} ipython3 +from pathlib import Path + +_ = Path("connections.ini").write_text( + """ +[duck] +drivername = duckdb +""" +) +``` + +To connect to a database defined in the connections file, use `--section` and pass the section name: + +```{code-cell} ipython3 +%sql --section duck +``` + +```{versionchanged} 0.10.0 +The connection alias is automatically set when using `%sql --section` +``` + +Note that the alias is set to the section name: + +```{code-cell} ipython3 +%sql --connections +``` + +```{versionchanged} 0.10.0 +Loading connections from the `.ini` (`%sql [section_name]`) file has been deprecated. Use `%sql --section section_name` instead. +``` + +```{code-cell} ipython3 +from urllib.request import urlretrieve +from pathlib import Path + +url = "https://raw.githubusercontent.com/mwaskom/seaborn-data/master/penguins.csv" + +if not Path("penguins.csv").exists(): + urlretrieve(url, "penguins.csv") +``` + +```{code-cell} ipython3 +%%sql +drop table if exists penguins; + +create table penguins as +select * from penguins.csv +``` + +```{code-cell} ipython3 +%%sql +select * from penguins +``` + +## Managing multiple connections + +Let's now define another connection so we can show how we can manage multiple ones: + +```{code-cell} ipython3 +_ = Path("connections.ini").write_text( + """ +[duck] +drivername = duckdb + +[second_duck] +drivername = duckdb +""" +) +``` + +Start a new connection from the `second_duck` section name: + +```{code-cell} ipython3 +%sql --section second_duck +``` + +```{code-cell} ipython3 +%sql --connections +``` + +There are no tables since this is a new database: + +```{code-cell} ipython3 +%sqlcmd tables +``` + +If we switch to the first connection (by passing the alias), we'll see the table: + +```{code-cell} ipython3 +%sql duck +``` + +```{code-cell} ipython3 +%sqlcmd tables +``` + +We can change back to the other connection: + +```{code-cell} ipython3 +%sql second_duck +``` + +```{code-cell} ipython3 +%sqlcmd tables +``` + +## Setting a default connection + +```{versionadded} 0.10.1 +``` + +If JupySQL finds a `default` section in your connections file, it'll automatically connect to it when the extension is loaded. For example, to connect to an in-memory DuckDB database: + +```ini +[default] +drivername = duckdb +``` + +Then, whenever you run: `load_ext %sql`, the connection will start. diff --git a/doc/user-guide/data-profiling.md b/doc/user-guide/data-profiling.md new file mode 100644 index 000000000..8e9bd282f --- /dev/null +++ b/doc/user-guide/data-profiling.md @@ -0,0 +1,141 @@ +--- +jupytext: + notebook_metadata_filter: myst + text_representation: + extension: .md + format_name: myst + format_version: 0.13 + jupytext_version: 1.14.7 +kernelspec: + display_name: Python 3 (ipykernel) + language: python + name: python3 +--- + +# Data profiling + + +```{versionadded} 0.7 +~~~ +pip install jupysql --upgrade +~~~ +``` + + +When dealing with a new dataset, it's crucial for practitioners to have a comprehensive understanding of the data in a timely manner. This involves exploring and summarizing the dataset efficiently to extract valuable insights. However, this can be a time-consuming process. Fortunately, `%sqlcmd profile` offers an easy way to generate statistics and descriptive information, enabling practitioners to quickly gain a deeper understanding of the dataset. + +Available statistics: + +* The count of non empty values +* The number of unique values +* The top (most frequent) value +* The frequency of your top value +* The mean, standard deviation, min and max values +* The percentiles of your data: 25%, 50% and 75%. + +## Examples + +### DuckDB + +In this example we'll demonstrate the process of profiling a sample dataset that contains historical taxi data from NYC, using DuckDB. However, the code used here is compatible with all major databases. + +Download the data + +```{code-cell} ipython3 +from pathlib import Path +from urllib.request import urlretrieve + +url = "https://d37ci6vzurychx.cloudfront.net/trip-data/yellow_tripdata_2021-01.parquet" + +if not Path("yellow_tripdata_2021-01.parquet").is_file(): + urlretrieve(url, "yellow_tripdata_2021-01.parquet") +``` + +Setup + +```{note} +This example requires duckdb-engine: `pip install duckdb-engine` +``` + +Load the extension and connect to an in-memory DuckDB database: + +```{code-cell} ipython3 +%load_ext sql +``` + +```{code-cell} ipython3 +%sql duckdb:// +``` + +```{code-cell} ipython3 +%%sql +CREATE TABLE taxi_trips AS +SELECT * FROM 'yellow_tripdata_2021-01.parquet' +``` + +Profile table: + +```{code-cell} ipython3 +%sqlcmd profile --table taxi_trips +``` + +### Saving report as HTML + +To save the generated report as an HTML file, use the `--output`/`-o` attribute followed by the desired file name + +```{code-cell} ipython3 +:tags: [hide-output] + +%sqlcmd profile --table taxi_trips --output my-report.html +``` + +```{code-cell} ipython3 +from IPython.display import HTML + +HTML("my-report.html") +``` + +### Use schemas + +To profile a specific table from various tables in different schemas, we can use the `--schema/-s` attribute. + +```{code-cell} ipython3 +:tags: [hide-output] + +%%sql +CREATE SCHEMA some_schema +``` + +```{code-cell} ipython3 +:tags: [hide-output] + +%%sql +CREATE TABLE some_schema.trips AS +SELECT * FROM 'yellow_tripdata_2021-01.parquet' +``` + +Let's profile `my_numbers` of `b_schema` + +```{code-cell} ipython3 +%sqlcmd profile --table trips --schema some_schema +``` + +### Parametrizing arguments + +JupySQL supports variable expansion of arguments in the form of `{{variable}}`. Let's see an example using `table`, `schema` and `output`. + +```{code-cell} ipython3 +table = "trips" +schema = "some_schema" +output = "my-report.html" +``` + +```{code-cell} ipython3 +%sqlcmd profile --table {{table}} --schema {{schema}} --output {{output}} +``` + +```{code-cell} ipython3 +from IPython.display import HTML + +HTML(output) +``` diff --git a/doc/user-guide/ggplot.md b/doc/user-guide/ggplot.md new file mode 100644 index 000000000..f2f292310 --- /dev/null +++ b/doc/user-guide/ggplot.md @@ -0,0 +1,237 @@ +--- +jupytext: + notebook_metadata_filter: myst + text_representation: + extension: .md + format_name: myst + format_version: 0.13 + jupytext_version: 1.14.7 +kernelspec: + display_name: Python 3 (ipykernel) + language: python + name: python3 +myst: + html_meta: + description lang=en: Templatize SQL queries in Jupyter via JupySQL + keywords: jupyter, sql, jupysql, jinja + property=og:locale: en_US +--- + +# ggplot + + +```{versionadded} 0.7 +~~~ +pip install jupysql --upgrade +~~~ +``` + + + +```{note} +`ggplot` API requires `matplotlib`: `pip install matplotlib` +``` + +The `ggplot` API is structured around the principles of the grammar of graphics, and allows you to build any graph using the same components: a data set, a coordinate system, and geoms (geometric objects). + +To make it suitble for JupySQL, specifically for the purpose of running SQL and plotting larger-than-memory datasets on any laptop, we made a small modification from the original `ggplot2` API. Rather than providing a dataset, we now provide a SQL table name. + +Other than that, at this point we support: + +Aes: +* `x` - a SQL column mapping +* `color` and `fill` to apply edgecolor and fill colors to plot shapes + +Geoms: +* `geom_boxplot` +* `geom_histogram` + +Facet: +* `facet_wrap` to display multiple plots in 1 layout + +Please note that each geom has its own unique attributes, e.g: number of bins in `geom_histogram`. We'll cover all the possible parameters in this tutorial. + +## Building a graph + +To build a graph, we first should initialize a `ggplot` instance with a reference to our SQL table using the `table` parameter, and a mapping object. +Here's is the complete template to build any graph. + +```python +( + ggplot(table='sql_table_name', mapping=aes(x='table_column_name')) + + + geom_func() # geom_histogram or geom_boxplot (required) + + + facet_func() # facet_wrap (optional) +) +``` + +```{note} +Please note this is the 1st release of `ggplot` API. We highly encourage you to provide us with your feedback through our [Slack](https://ploomber.io/community) channel to assist us in improving the API and addressing any issues as soon as possible. +``` + +## Examples + +First, establish the connection, import necessary functions and prepare the data. + +### Setup + +```{code-cell} ipython3 +:tags: [hide-output] + +%load_ext sql +%sql duckdb:// +``` + +```{code-cell} ipython3 +from sql.ggplot import ggplot, aes, geom_boxplot, geom_histogram, facet_wrap +``` + +```{code-cell} ipython3 +from pathlib import Path +from urllib.request import urlretrieve + +url = "https://d37ci6vzurychx.cloudfront.net/trip-data/yellow_tripdata_2021-01.parquet" + +if not Path("yellow_tripdata_2021-01.parquet").is_file(): + urlretrieve(url, "yellow_tripdata_2021-01.parquet") +``` + +### Boxplot + +```{code-cell} ipython3 +(ggplot("yellow_tripdata_2021-01.parquet", aes(x="trip_distance")) + geom_boxplot()) +``` + +### Histogram + +To make it more interesting, let's create a query that filters by the 90th percentile. Note that we're using the `--save`, and `--no-execute` functions. This tells JupySQL to store the query, but *skips execution*. We'll reference it in our next plotting calls using the `with_` parameter. + +```{code-cell} ipython3 +%%sql --save short_trips --no-execute +select * from 'yellow_tripdata_2021-01.parquet' +WHERE trip_distance < 6.3 +``` + +```{code-cell} ipython3 +( + ggplot(table="short_trips", with_="short_trips", mapping=aes(x="trip_distance")) + + geom_histogram(bins=10) +) +``` + +### Custom Style + +By modifying the `fill` and `color` attributes, we can apply our custom style + +```{code-cell} ipython3 +( + ggplot( + table="short_trips", + with_="short_trips", + mapping=aes(x="trip_distance", fill="#69f0ae", color="#fff"), + ) + + geom_histogram(bins=10) +) +``` + +When using multiple columns we can apply color on each column + +```{code-cell} ipython3 +( + ggplot( + table="short_trips", + with_="short_trips", + mapping=aes( + x=["PULocationID", "DOLocationID"], + fill=["#d500f9", "#fb8c00"], + color="white", + ), + ) + + geom_histogram(bins=10) +) +``` + +### Categorical histogram + +To make it easier to demonstrate, let's use `ggplot2` diamonds dataset. + +```{code-cell} ipython3 +from pathlib import Path +from urllib.request import urlretrieve + +if not Path("diamonds.csv").is_file(): + urlretrieve( + "https://raw.githubusercontent.com/tidyverse/ggplot2/main/data-raw/diamonds.csv", # noqa + "diamonds.csv", + ) +``` + +```{code-cell} ipython3 +%%sql +CREATE TABLE diamonds AS SELECT * FROM diamonds.csv +``` + +Now, let's create a histogram of the different cuts of the diamonds by setting `x='cut'`. +Please note, since the values of `cut` are strings, we don't need the `bins` attribute here. + +```{code-cell} ipython3 +(ggplot("diamonds", aes(x="cut")) + geom_histogram()) +``` + +We can show a histogram of multiple columns by setting `x=['cut', 'color']` + +```{code-cell} ipython3 +(ggplot("diamonds", aes(x=["cut", "color"])) + geom_histogram()) +``` + +We can also plot histograms for a combination of categorical and numerical columns. + +```{code-cell} ipython3 +(ggplot("diamonds", aes(x=["color", "carat"])) + geom_histogram(bins=30)) +``` + +Apply a custom color with `color` and `fill` + +```{code-cell} ipython3 +( + ggplot("diamonds", aes(x="price", fill="green", color="white")) + + geom_histogram(bins=10, fill="cut") +) +``` + +If we map the `fill` attribute to a different variable such as `cut`, the bars will stack automatically. Each colored rectangle on the stacked bars will represent a unique combination of `price` and `cut`. + +```{code-cell} ipython3 +(ggplot("diamonds", aes(x="price")) + geom_histogram(bins=10, fill="cut")) +``` + +We can apply a different coloring using `cmap` + +```{code-cell} ipython3 +( + ggplot("diamonds", aes(x="price")) + + geom_histogram(bins=10, fill="cut", cmap="plasma") +) +``` + +### Facet wrap + +`facet_wrap()` arranges a sequence of panels into a 2D grid, which is beneficial when dealing with a single variable that has multiple levels, and you want to arrange the plots in a more space efficient manner. + +Let's see an example of how we can arrange the diamonds `price` histogram for each different `color` + +```{code-cell} ipython3 +(ggplot("diamonds", aes(x="price")) + geom_histogram(bins=10) + facet_wrap("color")) +``` + +We can even examine the stacked histogram of `price` by `cut`, for each different `color`. +Let's also hide legend with `legend=False` to see each plot clearly. + +```{code-cell} ipython3 +( + ggplot("diamonds", aes(x="price")) + + geom_histogram(bins=10, fill="cut") + + facet_wrap("color", legend=False) +) +``` diff --git a/doc/user-guide/table_explorer.ipynb b/doc/user-guide/table_explorer.ipynb new file mode 100644 index 000000000..26840e320 --- /dev/null +++ b/doc/user-guide/table_explorer.ipynb @@ -0,0 +1,1733 @@ +{ + "cells": [ + { + "attachments": {}, + "cell_type": "markdown", + "id": "b930f418", + "metadata": {}, + "source": [ + "# Table Explorer\n", + "\n", + "\n", + "```{versionadded} 0.7.6\n", + "~~~\n", + "pip install jupysql --upgrade\n", + "~~~\n", + "```\n", + "\n", + "In this guide, we demonstrate how to use JupySQL's table explorer to visualize SQL tables in HTML format and interact with them efficiently. By running SQL queries in the background instead of loading the data into memory, we minimize the resource consumption and processing time required for handling large datasets, making the interaction with the SQL tables faster and more streamlined.\n", + "\n", + "\n", + "Let's start by preparing our dataset. We'll be using the [NYC taxi dataset](https://www.nyc.gov/site/tlc/about/tlc-trip-record-data.page).\n", + "\n", + "## Download the data" + ] + }, + { + "cell_type": "code", + "execution_count": 1, + "id": "67e9f89e", + "metadata": {}, + "outputs": [], + "source": [ + "from pathlib import Path\n", + "from urllib.request import urlretrieve\n", + "\n", + "url = \"https://d37ci6vzurychx.cloudfront.net/trip-data/yellow_tripdata_2021-01.parquet\"\n", + "\n", + "if not Path(\"yellow_tripdata_2021-01.parquet\").is_file():\n", + " urlretrieve(url, \"yellow_tripdata_2021.parquet\")" + ] + }, + { + "attachments": {}, + "cell_type": "markdown", + "id": "50e7c60f", + "metadata": {}, + "source": [ + "## Installation" + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "id": "2708d4a7", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Note: you may need to restart the kernel to use updated packages.\n" + ] + } + ], + "source": [ + "%pip install jupysql --upgrade --quiet" + ] + }, + { + "attachments": {}, + "cell_type": "markdown", + "id": "e41a3624", + "metadata": {}, + "source": [ + "## Set connection\n", + "\n", + "After our dataset is ready, we should set our connection.\n", + "\n", + "For this demonstration, we'll be using the `DuckDB` connection." + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "id": "dbe40317", + "metadata": {}, + "outputs": [ + { + "data": { + "text/html": [ + "Tip: You may define configurations in /Users/neelashasen/Dev/jupysql_master/jupysql/pyproject.toml or /Users/neelashasen/.jupysql/config. " + ], + "text/plain": [ + "Tip: You may define configurations in /Users/neelashasen/Dev/jupysql_master/jupysql/pyproject.toml or /Users/neelashasen/.jupysql/config. " + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "text/html": [ + "Please review our configuration guideline." + ], + "text/plain": [ + "" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "text/html": [ + "Loading configurations from /Users/neelashasen/.jupysql/config." + ], + "text/plain": [ + "Loading configurations from /Users/neelashasen/.jupysql/config." + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "text/html": [ + "Settings changed:" + ], + "text/plain": [ + "Settings changed:" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "text/html": [ + "\n", + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
Configvalue
feedbackTrue
autopandasTrue
" + ], + "text/plain": [ + "\n", + "+------------+-------+\n", + "| Config | value |\n", + "+------------+-------+\n", + "| feedback | True |\n", + "| autopandas | True |\n", + "+------------+-------+" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "text/html": [ + "Connecting to 'default'" + ], + "text/plain": [ + "Connecting to 'default'" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "text/html": [ + "Connecting and switching to connection 'duckdb://'" + ], + "text/plain": [ + "Connecting and switching to connection 'duckdb://'" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "%load_ext sql\n", + "%sql duckdb://" + ] + }, + { + "attachments": {}, + "cell_type": "markdown", + "id": "08358b2d", + "metadata": {}, + "source": [ + "## Create the table\n", + "\n", + "To create the table, use the `explore` attribute and specify the name of the table that was just downloaded." + ] + }, + { + "cell_type": "code", + "execution_count": 4, + "id": "7e6c6c7d", + "metadata": {}, + "outputs": [ + { + "data": { + "text/html": [ + "\n", + " \n", + " \n", + "
\n", + " \n", + " \n", + " " + ], + "text/plain": [ + "" + ] + }, + "execution_count": 4, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "%sqlcmd explore --table \"yellow_tripdata_2021.parquet\"" + ] + }, + { + "cell_type": "markdown", + "id": "0c008e2e-3a38-47ef-9073-3b0379a5b13e", + "metadata": {}, + "source": [ + "## Parametrizing arguments\n", + "\n", + "JupySQL supports variable expansion of arguments in the form of `{{variable}}`. This allows the user to specify arguments with placeholders that can be replaced by variables dynamically." + ] + }, + { + "cell_type": "code", + "execution_count": 8, + "id": "8c603c30-261f-4beb-863b-493d9d441625", + "metadata": {}, + "outputs": [], + "source": [ + "table_name = \"yellow_tripdata_2021.parquet\"" + ] + }, + { + "cell_type": "code", + "execution_count": 10, + "id": "1d2768ba-d2ad-4e09-842e-21a06609e94d", + "metadata": {}, + "outputs": [ + { + "data": { + "text/html": [ + "\n", + " \n", + " \n", + "
\n", + " \n", + " \n", + " " + ], + "text/plain": [ + "" + ] + }, + "execution_count": 10, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "%sqlcmd explore --table {{table_name}}" + ] + } + ], + "metadata": { + "jupytext": { + "notebook_metadata_filter": "myst" + }, + "kernelspec": { + "display_name": "Python 3 (ipykernel)", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.10.13" + }, + "myst": { + "html_meta": { + "description lang=en": "Templatize SQL queries in Jupyter via JupySQL", + "keywords": "jupyter, sql, jupysql, jinja", + "property=og:locale": "en_US" + } + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/doc/user-guide/tables-columns.md b/doc/user-guide/tables-columns.md new file mode 100644 index 000000000..83f001bd5 --- /dev/null +++ b/doc/user-guide/tables-columns.md @@ -0,0 +1,118 @@ +--- +jupytext: + notebook_metadata_filter: myst + text_representation: + extension: .md + format_name: myst + format_version: 0.13 + jupytext_version: 1.14.4 +kernelspec: + display_name: Python 3 (ipykernel) + language: python + name: python3 +myst: + html_meta: + description lang=en: List tables and columns from your database in Jupyter via JupySQL + keywords: jupyter, sql, jupysql + property=og:locale: en_US +--- + +# List tables and columns + +```{note} +This example uses `SQLite` but the same commands work for other databases. +``` + +With JupySQL, you can quickly explore what tables are available in your database and which columns each table has. + ++++ + +## Setup + +```{code-cell} ipython3 +%load_ext sql +%sql sqlite:// +``` + +Let's create some sample tables in the default schema: + +```{code-cell} ipython3 +:tags: [hide-output] + +%%sql +CREATE TABLE coordinates (x INT, y INT) +``` + +```{code-cell} ipython3 +:tags: [hide-output] + +%%sql +CREATE TABLE people (name TEXT, birth_year INT) +``` + +## List tables + ++++ + +Use `%sqlcmd tables` to print the tables for the current connection: + +```{code-cell} ipython3 +%sqlcmd tables +``` + +Pass `--schema/-s` to get tables in a different schema: + +```python +%sqlcmd tables --schema schema +``` + ++++ + + +## List columns + +Use `%sqlcmd columns --table/-t` to get the columns for the given table. + +```{code-cell} ipython3 +%sqlcmd columns --table coordinates +``` + +```{code-cell} ipython3 +%sqlcmd columns -t people +``` + +If the table isn't in the default schema, pass `--schema/-s`. Let's create a new table in a new schema: + +```{code-cell} ipython3 +:tags: [hide-output] + +from sqlalchemy import create_engine +from sql.connection import SQLAlchemyConnection + +conn = SQLAlchemyConnection(engine=create_engine("sqlite:///my.db")) +conn.execute("CREATE TABLE numbers (n FLOAT)") +``` + +```{code-cell} ipython3 +:tags: [hide-output] + +%%sql +ATTACH DATABASE 'my.db' AS some_schema +``` + +Get the columns for the table in the newly created schema: + +```{code-cell} ipython3 +%sqlcmd columns --table numbers --schema some_schema +``` + +JupySQL supports variable expansion of arguments in the form of `{{variable}}`. Let's see an example of parametrizing `table` and `schema`: + +```{code-cell} ipython3 +table = "numbers" +schema = "some_schema" +``` + +```{code-cell} ipython3 +%sqlcmd columns --table {{table}} --schema {{schema}} +``` \ No newline at end of file diff --git a/doc/user-guide/template.md b/doc/user-guide/template.md new file mode 100644 index 000000000..02c727750 --- /dev/null +++ b/doc/user-guide/template.md @@ -0,0 +1,307 @@ +--- +jupytext: + notebook_metadata_filter: myst + text_representation: + extension: .md + format_name: myst + format_version: 0.13 + jupytext_version: 1.14.7 +kernelspec: + display_name: Python 3 (ipykernel) + language: python + name: python3 +myst: + html_meta: + description lang=en: Templatize SQL queries in Jupyter via JupySQL + keywords: jupyter, sql, jupysql, jinja + property=og:locale: en_US +--- + +# Parameterizing SQL queries + +```{versionchanged} 0.7 +JupySQL uses Jinja templates for enabling SQL query parametrization. Queries are parametrized with `{{variable}}`. +``` + +```{note} +The legacy formats of parametrization, namely `{variable}`, and `$variable` from `ipython-sql` have been deprecated. `:variable` is turned off by default but can be enabled with [`%config SqlMagic.named_parameters`](named-parameters) (requires `jupysql>=0.9`). +``` + + +## Parametrization via `{{variable}}` + +JupySQL supports variable expansion in the form of `{{variable}}`. This allows the user to write a query with placeholders that can be replaced by variables dynamically. + +The benefits of using parametrized SQL queries are: + +* They can be reused with different values and for different purposes. +* Such queries can be prepared ahead of time and reused without having to create distinct SQL queries for each scenario. +* Parametrized queries can be used with dynamic data also. + +Let's load some data and connect to the in-memory DuckDB instance: + +```{code-cell} ipython3 +%load_ext sql +%sql duckdb:// +%config SqlMagic.displaylimit = 3 +``` + +```{code-cell} ipython3 +from pathlib import Path +from urllib.request import urlretrieve + +if not Path("penguins.csv").is_file(): + urlretrieve( + "https://raw.githubusercontent.com/mwaskom/seaborn-data/master/penguins.csv", + "penguins.csv", + ) +``` + +The simplest use case is to use a variable to determine which data to filter: + ++++ + +### Data filtering + +```{code-cell} ipython3 +sex = "MALE" +``` + +```{code-cell} ipython3 +%%sql +SELECT * +FROM penguins.csv +WHERE sex = '{{sex}}' +``` + +Note that we have to add quotes around `{{sex}}`, since the literal is replaced. + ++++ + +`{{variable}}` parameters are not limited to `WHERE` clauses, you can use them anywhere: + +```{code-cell} ipython3 +dynamic_limit = 5 +dynamic_column = "island, sex" +``` + +```{code-cell} ipython3 +%sql SELECT {{dynamic_column}} FROM penguins.csv LIMIT {{dynamic_limit}} +``` + +### SQL generation + +```{note} +We use [jinja](https://jinja.palletsprojects.com/en/3.1.x/) to parametrize queries, to learn more about the syntax, check our their docs. +``` + +Since there are no restrictions on where you can use `{{variable}}` you can use it to dynamically generate SQL if you also use advanced control structures. + +Let's look at generating SQL queries using a `{% for %}` loop. First, we'll create a set of unique `sex` values. This is required since the dataset contains samples for which `sex` couldn't be determined (`null`). + +```{code-cell} ipython3 +sex = ("MALE", "FEMALE") +``` + +Then, we'll set a list of islands of interest, and for each island calculate the average `body_mass_g` of all penguins belonging to that island. + +```{code-cell} ipython3 +%%sql --save avg_body_mass +{% set islands = ["Torgersen", "Biscoe", "Dream"] %} +select + sex, + {% for island in islands %} + avg(case when island = '{{island}}' then body_mass_g end) as {{island}}_body_mass_g, + {% endfor %} +from penguins.csv +where sex in {{sex}} +group by sex +``` + +Here's the final compiled query: + +```{code-cell} ipython3 +final = %sqlcmd snippets avg_body_mass +print(final) +``` + +### SQL generation with macros + +If `{% for %}` lops are not enough, you can modularize your code generation even more with macros. + +macros is a construct analogous to functions that promote re-usability. We'll first define a macro for converting a value from `millimetre` to `centimetre`. And then use this macro in the query using variable expansion. + +```{code-cell} ipython3 +%%sql --save convert +{% macro mm_to_cm(column_name, precision=2) %} + ({{ column_name }} / 10)::numeric(16, {{ precision }}) +{% endmacro %} + +select + sex, island, + {{ mm_to_cm('bill_length_mm') }} as bill_length_cm, + {{ mm_to_cm('bill_depth_mm') }} as bill_length_cm, +from penguins.csv +``` + +Let's see the final rendered query: + +```{code-cell} ipython3 +final = %sqlcmd snippets convert +print(final) +``` + +### Using snippets + +You can combine the snippets feature with `{{variable}}`: + +```{code-cell} ipython3 +species = "Adelie" +``` + +```{code-cell} ipython3 +%%sql --save one_species --no-execute +SELECT * FROM penguins.csv +WHERE species = '{{species}}' +``` + +```{code-cell} ipython3 +%%sql +SELECT * +FROM one_species +``` + +```{important} +When storing a snippet with `{{variable}}`, the values are replaced upon saving, so assigning a new value to `variable` won't have any effect. +``` + +```{code-cell} ipython3 +species = "Gentoo" +``` + +```{code-cell} ipython3 +%%sql +SELECT * +FROM one_species +``` + +### Combining Python and `{{variable}}` + +You can combine Python code with the `%sql` magic to execute parametrized queries. + +Let's see how we can create multiple tables, each one containing the penguins for a given `island`. + +```{code-cell} ipython3 +for island in ("Torgersen", "Biscoe", "Dream"): + %sql CREATE TABLE {{island}} AS (SELECT * from penguins.csv WHERE island = '{{island}}') +``` + +```{code-cell} ipython3 +%sqlcmd tables +``` + +Let's verify data in one of the tables: + +```{code-cell} ipython3 +%sql SELECT * FROM Dream; +``` + +```{code-cell} ipython3 +%sql SELECT * FROM Torgersen; +``` + +(named-parameters)= +## Parametrization via `:variable` + +```{versionchanged} 0.10.9 +``` + +There is a second method to parametrize variables via `:variable`. This method has the following limitations + +- Only available for SQLAlchemy connections +- Only works for data filtering parameters (`WHERE`, `IN`, `>=`, etc.) + + +To enable it: + +```{code-cell} ipython3 +%config SqlMagic.named_parameters = "enabled" +``` + +```{code-cell} ipython3 +sex = "MALE" +``` + +```{code-cell} ipython3 +%%sql +SELECT * +FROM penguins.csv +WHERE sex = :sex +``` + +Note that we don't have to quote `:sex`. When using `:variable`, if `variable` is a string, it'll automatically be quoted. + +Here's another example where we use the parameters for an `IN` and a `>=` clauses: + +```{code-cell} ipython3 +one = "Adelie" +another = "Chinstrap" +min_body_mass_g = 4500 +``` + +```{code-cell} ipython3 +%%sql +SELECT * +FROM penguins.csv +WHERE species IN (:one, :another) +AND body_mass_g >= :min_body_mass_g +``` + +Parametrizing other parts of the query like table names or column names won't work. + +```{code-cell} ipython3 +tablename = "penguins.csv" +``` + +```{code-cell} ipython3 +:tags: [raises-exception] + +%%sql +SELECT * +FROM :tablename +``` + +### Using snippets and `:variable` + +Unlike `{{variable}`, `:variable` parameters are evaluated at execution time, meaning you can `--save` a query and the output will change depending on the value of `variable` when the query is executed: + +```{code-cell} ipython3 +sex = "MALE" +``` + +```{code-cell} ipython3 +%%sql --save one_sex +SELECT * +FROM penguins.csv +WHERE sex = :sex +``` + +```{code-cell} ipython3 +sex = "FEMALE" +``` + +```{code-cell} ipython3 +%%sql +SELECT * FROM one_sex +``` + +### Disabling named parameters + +Sometimes, valid SQL can contain instances of `:x` which should not be mistaken as named parameters. +In this case, you may want to disable named parameters: + +```{code-cell} ipython3 +%config SqlMagic.named_parameters = "disabled" +``` + +This can be helpful when executing statements which include JSON or other DB-specific syntax. \ No newline at end of file diff --git a/examples/plot_boxplot.py b/examples/plot_boxplot.py new file mode 100644 index 000000000..b5fe9cd19 --- /dev/null +++ b/examples/plot_boxplot.py @@ -0,0 +1,18 @@ +from pathlib import Path +import urllib.request + +from sqlalchemy import create_engine + +from sql.connection import SQLAlchemyConnection +from sql import plot + + +if not Path("iris.csv").is_file(): + urllib.request.urlretrieve( + "https://raw.githubusercontent.com/plotly/datasets/master/iris-data.csv", + "iris.csv", + ) + +conn = SQLAlchemyConnection(create_engine("duckdb://")) + +plot.boxplot("iris.csv", "petal width", conn=conn) diff --git a/examples/plot_boxplot_custom.py b/examples/plot_boxplot_custom.py new file mode 100644 index 000000000..9e9582ad2 --- /dev/null +++ b/examples/plot_boxplot_custom.py @@ -0,0 +1,22 @@ +from pathlib import Path +import urllib.request + +from sqlalchemy import create_engine + + +from sql.connection import SQLAlchemyConnection +from sql import plot + + +if not Path("iris.csv").is_file(): + urllib.request.urlretrieve( + "https://raw.githubusercontent.com/plotly/datasets/master/iris-data.csv", + "iris.csv", + ) + +conn = SQLAlchemyConnection(create_engine("duckdb://")) + +# returns matplotlib.Axes object +ax = plot.boxplot("iris.csv", "petal width", conn=conn) +ax.set_title("My custom title") +ax.grid() diff --git a/examples/plot_boxplot_horizontal.py b/examples/plot_boxplot_horizontal.py new file mode 100644 index 000000000..55bd919d2 --- /dev/null +++ b/examples/plot_boxplot_horizontal.py @@ -0,0 +1,19 @@ +from pathlib import Path +import urllib.request + +from sqlalchemy import create_engine + +from sql.connection import SQLAlchemyConnection + +from sql import plot + + +if not Path("iris.csv").is_file(): + urllib.request.urlretrieve( + "https://raw.githubusercontent.com/plotly/datasets/master/iris-data.csv", + "iris.csv", + ) + +conn = SQLAlchemyConnection(create_engine("duckdb://")) + +plot.boxplot("iris.csv", "petal width", conn=conn, orient="h") diff --git a/examples/plot_boxplot_many.py b/examples/plot_boxplot_many.py new file mode 100644 index 000000000..cd4519293 --- /dev/null +++ b/examples/plot_boxplot_many.py @@ -0,0 +1,19 @@ +from pathlib import Path +import urllib.request + +from sqlalchemy import create_engine + +from sql.connection import SQLAlchemyConnection + +from sql import plot + + +if not Path("iris.csv").is_file(): + urllib.request.urlretrieve( + "https://raw.githubusercontent.com/plotly/datasets/master/iris-data.csv", + "iris.csv", + ) + +conn = SQLAlchemyConnection(create_engine("duckdb://")) + +plot.boxplot("iris.csv", ["petal width", "sepal width"], conn=conn) diff --git a/examples/plot_histogram.py b/examples/plot_histogram.py new file mode 100644 index 000000000..e993a0765 --- /dev/null +++ b/examples/plot_histogram.py @@ -0,0 +1,16 @@ +import urllib.request + +from sqlalchemy import create_engine + +from sql.connection import SQLAlchemyConnection +from sql import plot + + +urllib.request.urlretrieve( + "https://raw.githubusercontent.com/plotly/datasets/master/iris-data.csv", + "iris.csv", +) + +conn = SQLAlchemyConnection(create_engine("duckdb://")) + +plot.histogram("iris.csv", "petal width", bins=50, conn=conn) diff --git a/examples/plot_histogram_many.py b/examples/plot_histogram_many.py new file mode 100644 index 000000000..7b1c46958 --- /dev/null +++ b/examples/plot_histogram_many.py @@ -0,0 +1,16 @@ +import urllib.request + +from sqlalchemy import create_engine + +from sql.connection import SQLAlchemyConnection +from sql import plot + + +urllib.request.urlretrieve( + "https://raw.githubusercontent.com/plotly/datasets/master/iris-data.csv", + "iris.csv", +) + +conn = SQLAlchemyConnection(create_engine("duckdb://")) + +plot.histogram("iris.csv", ["petal width", "sepal width"], bins=50, conn=conn) diff --git a/examples/run_statements.py b/examples/run_statements.py new file mode 100644 index 000000000..ae4600ecc --- /dev/null +++ b/examples/run_statements.py @@ -0,0 +1,24 @@ +from sql.run import run +from sqlalchemy import create_engine +from sql.connection import SQLAlchemyConnection +from sql.magic import SqlMagic +from IPython.core.interactiveshell import InteractiveShell + +ip = InteractiveShell() + +sqlmagic = SqlMagic(shell=ip) +ip.register_magics(sqlmagic) + +# Modify config options if needed +sqlmagic.feedback = 1 +sqlmagic.autopandas = True + +conn = SQLAlchemyConnection(create_engine("duckdb://")) + +run.run_statements(conn, "CREATE TABLE numbers (num INTEGER)", config=sqlmagic) +run.run_statements(conn, "INSERT INTO numbers values (1)", config=sqlmagic) +run.run_statements(conn, "INSERT INTO numbers values (2)", config=sqlmagic) +run.run_statements(conn, "INSERT INTO numbers values (1)", config=sqlmagic) + +query_result = run.run_statements(conn, "SELECT * FROM numbers", config=sqlmagic) +print(query_result) diff --git a/examples/wordcount.png b/examples/wordcount.png deleted file mode 100644 index 4a1643e2c..000000000 Binary files a/examples/wordcount.png and /dev/null differ diff --git a/examples/writers.ipynb b/examples/writers.ipynb deleted file mode 100644 index 9b41b00ac..000000000 --- a/examples/writers.ipynb +++ /dev/null @@ -1,305 +0,0 @@ -{ - "cells": [ - { - "cell_type": "code", - "execution_count": 2, - "metadata": { - "collapsed": false, - "jupyter": { - "outputs_hidden": false - } - }, - "outputs": [], - "source": [ - "%load_ext sql" - ] - }, - { - "cell_type": "code", - "execution_count": 3, - "metadata": { - "collapsed": false, - "jupyter": { - "outputs_hidden": false - } - }, - "outputs": [], - "source": [ - "%sql sqlite://" - ] - }, - { - "cell_type": "code", - "execution_count": 3, - "metadata": { - "collapsed": false, - "jupyter": { - "outputs_hidden": false - } - }, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - " * sqlite://\n", - "Done.\n", - "1 rows affected.\n", - "1 rows affected.\n" - ] - }, - { - "data": { - "text/plain": [ - "[]" - ] - }, - "execution_count": 3, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "%%sql\n", - "CREATE TABLE writer (first_name, last_name, year_of_death);\n", - "INSERT INTO writer VALUES ('William', 'Shakespeare', 1616);\n", - "INSERT INTO writer VALUES ('Bertold', 'Brecht', 1956);" - ] - }, - { - "cell_type": "code", - "execution_count": 4, - "metadata": { - "collapsed": false, - "jupyter": { - "outputs_hidden": false - } - }, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - " * sqlite://\n", - "Done.\n" - ] - }, - { - "data": { - "text/html": [ - "\n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - "
first_namelast_nameyear_of_death
WilliamShakespeare1616
BertoldBrecht1956
" - ], - "text/plain": [ - "[('William', 'Shakespeare', 1616), ('Bertold', 'Brecht', 1956)]" - ] - }, - "execution_count": 4, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "%sql select * from writer" - ] - }, - { - "cell_type": "code", - "execution_count": 5, - "metadata": { - "collapsed": false, - "jupyter": { - "outputs_hidden": false - } - }, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - " * sqlite://\n", - "Done.\n", - "Returning data to local variable writers\n" - ] - } - ], - "source": [ - "%%sql writers << select first_name, year_of_death\n", - "from writer" - ] - }, - { - "cell_type": "code", - "execution_count": 6, - "metadata": {}, - "outputs": [ - { - "data": { - "text/html": [ - "\n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - "
first_nameyear_of_death
William1616
Bertold1956
" - ], - "text/plain": [ - "[('William', 1616), ('Bertold', 1956)]" - ] - }, - "execution_count": 6, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "writers" - ] - }, - { - "cell_type": "code", - "execution_count": 7, - "metadata": {}, - "outputs": [], - "source": [ - "var = 'last_name'" - ] - }, - { - "cell_type": "code", - "execution_count": 8, - "metadata": {}, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - " * sqlite://\n", - "Done.\n" - ] - }, - { - "data": { - "text/html": [ - "\n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - "
first_namelast_nameyear_of_death
BertoldBrecht1956
" - ], - "text/plain": [ - "[('Bertold', 'Brecht', 1956)]" - ] - }, - "execution_count": 8, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "%sql select * from writer where {var} = 'Brecht'" - ] - }, - { - "cell_type": "code", - "execution_count": 9, - "metadata": {}, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - " * sqlite://\n", - "Done.\n" - ] - }, - { - "data": { - "text/html": [ - "\n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - "
first_namelast_nameyear_of_death
BertoldBrecht1956
" - ], - "text/plain": [ - "[('Bertold', 'Brecht', 1956)]" - ] - }, - "execution_count": 9, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "%%sql select * from writer \n", - "where {var} = 'Brecht'" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [] - } - ], - "metadata": { - "kernelspec": { - "display_name": "Python 3", - "language": "python", - "name": "python3" - }, - "language_info": { - "codemirror_mode": { - "name": "ipython", - "version": 3 - }, - "file_extension": ".py", - "mimetype": "text/x-python", - "name": "python", - "nbconvert_exporter": "python", - "pygments_lexer": "ipython3", - "version": "3.8.0" - } - }, - "nbformat": 4, - "nbformat_minor": 4 -} diff --git a/examples/writers.png b/examples/writers.png deleted file mode 100644 index 30ce59785..000000000 Binary files a/examples/writers.png and /dev/null differ diff --git a/ipython-sql.wpr b/ipython-sql.wpr deleted file mode 100644 index 879c49207..000000000 --- a/ipython-sql.wpr +++ /dev/null @@ -1,13 +0,0 @@ -#!wing -#!version=5.0 -################################################################## -# Wing IDE project file # -################################################################## -[project attributes] -proj.directory-list = [{'dirloc': loc('.'), - 'excludes': (), - 'filter': '*', - 'include_hidden': False, - 'recursive': True, - 'watch_for_changes': True}] -proj.file-type = 'shared' diff --git a/noxfile.py b/noxfile.py new file mode 100644 index 000000000..c425e1b8f --- /dev/null +++ b/noxfile.py @@ -0,0 +1,166 @@ +from pathlib import Path +from os import environ + +import nox + + +# list non-setup sessions here +nox.options.sessions = ["test_postgres"] + +# GitHub actions does not have conda installed +VENV_BACKEND = "conda" if "CI" not in environ else None +DEV_ENV_NAME = "jupysql-env" + + +if VENV_BACKEND == "conda": + CONDA_PREFIX = environ.get("CONDA_PREFIX") + + if CONDA_PREFIX: + nox.options.envdir = str(Path(CONDA_PREFIX).parent) + else: + print("CONDA_PREFIX not found, creating envs in default location...") + + +INTEGRATION_CONDA_DEPENDENCIES = [ + "pyarrow", + "psycopg2", + "pymysql", + "oracledb", + "pip", +] + +INTEGRATION_PIP_DEPENDENCIES = [ + "dockerctx", + "pgspecial==2.0.1", + "pyodbc==4.0.34", + "sqlalchemy-pytds", + "python-tds", + "pyspark>=3.4.1", + "grpcio-status", +] + + +def _install(session, integration): + session.install("--editable", ".[dev]") + + if integration: + session.install(*INTEGRATION_PIP_DEPENDENCIES) + session.install(*INTEGRATION_CONDA_DEPENDENCIES) + + +def _check_sqlalchemy(session, version): + session.run( + "python", + "-c", + ( + "import sqlalchemy; " + f"assert int(sqlalchemy.__version__.split('.')[0]) == {version}" + ), + ) + + +def _run_unit(session, skip_image_tests): + args = [ + "pytest", + "src/tests/", + "--ignore", + "src/tests/integration", + ] + + if skip_image_tests: + args.extend( + [ + "--ignore", + "src/tests/test_ggplot.py", + "--ignore", + "src/tests/test_magic_plot.py", + ] + ) + + session.run(*args) + + +@nox.session( + venv_backend=VENV_BACKEND, + name=DEV_ENV_NAME, + python=environ.get("PYTHON_VERSION", "3.11"), +) +def setup(session): + print("Installing requirements...") + _install(session, integration=False) + + +@nox.session( + venv_backend=VENV_BACKEND, + python=environ.get("PYTHON_VERSION", "3.11"), +) +def test_unit(session): + """Run unit tests (SQLAlchemy 2.x)""" + SKIP_IMAGE_TEST = "--skip-image-tests" in session.posargs + + _install(session, integration=False) + session.install("sqlalchemy>=2") + _check_sqlalchemy(session, version=2) + _run_unit(session, skip_image_tests=SKIP_IMAGE_TEST) + + +@nox.session( + venv_backend=VENV_BACKEND, + python=environ.get("PYTHON_VERSION", "3.11"), +) +def test_unit_sqlalchemy_one(session): + """Run unit tests (SQLAlchemy 1.x)""" + SKIP_IMAGE_TEST = "--skip-image-tests" in session.posargs + + _install(session, integration=False) + session.install("sqlalchemy<2") + _check_sqlalchemy(session, version=1) + _run_unit(session, skip_image_tests=SKIP_IMAGE_TEST) + + +@nox.session( + venv_backend=VENV_BACKEND, + python=environ.get("PYTHON_VERSION", "3.11"), +) +def test_integration_cloud(session): + """ + Run integration tests on cloud databases (currently snowflake and redshift) + (NOTE: the sqlalchemy-snowflake and sqlalchemy-redshift driver only work with + SQLAlchemy 1.x) + This is disabled currently, refer: https://github.com/ploomber/jupysql/issues/984 + If it is required to enable these tests add a job in + .github/workflows/ci.yaml file. + """ + + # TODO: do not require integration test dependencies if only running snowflake + # tests + _install(session, integration=True) + session.install( + "snowflake-sqlalchemy", + "redshift-connector", + "sqlalchemy-redshift", + "clickhouse-sqlalchemy", + ) + session.run( + "pytest", + "src/tests/integration", + "-k", + "snowflake or redshift or clickhouse", + "-v", + ) + + +@nox.session( + venv_backend=VENV_BACKEND, + python=environ.get("PYTHON_VERSION", "3.11"), +) +def test_integration(session): + """Run integration tests (to check compatibility with databases)""" + _install(session, integration=True) + session.run( + "pytest", + "src/tests/integration", + "-k", + "not (snowflake or redshift or clickhouse)", + "-v", + ) diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 000000000..c6591bcac --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,34 @@ +[tool.pytest.ini_options] +addopts = "--pdbcls=IPython.terminal.debugger:Pdb" + +[tool.pkgmt] +github = "ploomber/jupysql" +env_name = "jupysql" +package_name = "sql" + +[tool.pkgmt.check_links] +extensions = ["md", "rst", "py", "ipynb"] +ignore_substrings = [ + "d37ci6vzurychx.cloudfront.net", + "https://bornsql.ca", + "127.0.0.1", + "http://localhost", + "https://localhost", + "platform.ploomber.io", + "https://ourworldindata.org", +] + +[tool.nbqa.addopts] +flake8 = [ + # notebooks allow non-top imports + "--extend-ignore=E402", + # jupysql notebooks might have "undefined name" errors + # due to the << operator + # W503, W504 ignore line break after/before + # binary operator since they are conflicting + "--ignore=F821, W503, W504", +] + +[tool.codespell] +skip = '.git,_build,build,*.drawio,*.ipynb' +ignore-words-list = 'whis' diff --git a/requirements-dev.txt b/requirements-dev.txt deleted file mode 100644 index 60ebd32dc..000000000 --- a/requirements-dev.txt +++ /dev/null @@ -1,9 +0,0 @@ -psycopg2 -pandas -pytest -wheel -twine -readme-renderer -black -isort - diff --git a/requirements.txt b/requirements.txt deleted file mode 100644 index a5f63ff56..000000000 --- a/requirements.txt +++ /dev/null @@ -1,6 +0,0 @@ -prettytable==0.7.2 -ipython>=1.0 -sqlalchemy>=0.6.7 -sqlparse -six -ipython-genutils>=0.1.0 diff --git a/run_tests.sh b/run_tests.sh deleted file mode 100755 index 66502f0ee..000000000 --- a/run_tests.sh +++ /dev/null @@ -1,3 +0,0 @@ -#!/bin/bash -ipython -c "import pytest; pytest.main(['.', '-x', '--pdb'])" -# Insert breakpoints with `import pytest; pytest.set_trace()` diff --git a/scripts/large-table-gen.py b/scripts/large-table-gen.py new file mode 100644 index 000000000..e63b7918d --- /dev/null +++ b/scripts/large-table-gen.py @@ -0,0 +1,8 @@ +"""Renter large-table-template.sql +""" + +from pathlib import Path +from jinja2 import Template + +t = Template(Path("large-table-template.sql").read_text()) +Path("large-table.sql").write_text(t.render()) diff --git a/scripts/large-table-template.sql b/scripts/large-table-template.sql new file mode 100644 index 000000000..176e3ed0c --- /dev/null +++ b/scripts/large-table-template.sql @@ -0,0 +1,15 @@ +-- Template for generating a large table +DROP TABLE IF EXISTS "TrackAll"; + +CREATE TABLE "TrackAll" AS ( + {% for _ in range(1000) %} + SELECT * FROM "Track" + {% if not loop.last %} + UNION ALL + {% endif %} + {% endfor %} + +); + + +SELECT COUNT(*) "TrackAll"; \ No newline at end of file diff --git a/setup.cfg b/setup.cfg new file mode 100644 index 000000000..38ca56882 --- /dev/null +++ b/setup.cfg @@ -0,0 +1,4 @@ +[flake8] +exclude = build/, doc/_build/ +max-line-length = 88 +extend-ignore = E203 \ No newline at end of file diff --git a/setup.py b/setup.py index 5b550ba7c..456ed279e 100644 --- a/setup.py +++ b/setup.py @@ -1,51 +1,111 @@ import os from io import open +import re +import ast from setuptools import find_packages, setup here = os.path.abspath(os.path.dirname(__file__)) -README = open(os.path.join(here, "README.rst"), encoding="utf-8").read() -NEWS = open(os.path.join(here, "NEWS.rst"), encoding="utf-8").read() +README = open(os.path.join(here, "README.md"), encoding="utf-8").read() +_version_re = re.compile(r"__version__\s+=\s+(.*)") -version = "0.4.1" +with open("src/sql/__init__.py", "rb") as f: + VERSION = str( + ast.literal_eval(_version_re.search(f.read().decode("utf-8")).group(1)) + ) install_requires = [ - "prettytable<1", - "ipython>=1.0", - "sqlalchemy>=0.6.7", + "prettytable>=3.12.0", + # IPython dropped support for Python 3.8 + "ipython<=8.12.0; python_version <= '3.8'", + "sqlalchemy", "sqlparse", - "six", "ipython-genutils>=0.1.0", + "jinja2", + "sqlglot>=11.3.7", + 'importlib-metadata;python_version<"3.8"', + # we removed the share notebook button in this version + "jupysql-plugin>=0.4.2", + "ploomber-core>=0.2.7", ] +DEV = [ + "flake8", + "pytest", + # 24/01/24 Pandas 2.2.0 breaking CI: https://github.com/ploomber/jupysql/issues/983 + "pandas<2.2.0", # previously pinned to 2.0.3 + "polars==0.17.2", # 04/18/23 this breaks our CI + "pyarrow", + "invoke", + "pkgmt", + "twine", + # tests + "duckdb<1.1.0", + "duckdb-engine", + "pyodbc", + # sql.plot module tests + "matplotlib==3.7.2", + "black", + # for %%sql --interact + "ipywidgets", + # for running tests for %sqlcmd explore --table + "js2py", + # for monitoring access to files + "psutil", + # for running tests for %sqlcmd connect + "jupyter-server", +] + +# dependencies for running integration tests +INTEGRATION = [ + "dockerctx", + "pyarrow", + "psycopg2-binary", + "pymysql", + "pgspecial==2.0.1", + "pyodbc", + "snowflake-sqlalchemy", + "oracledb", + "sqlalchemy-pytds", + "python-tds", + # redshift + "redshift-connector", + "sqlalchemy-redshift", + "clickhouse-sqlalchemy", + # following two dependencies required for spark + "pyspark", + "grpcio-status", +] setup( - name="ipython-sql", - version=version, - description="RDBMS access via IPython", - long_description=README + "\n\n" + NEWS, - long_description_content_type="text/x-rst", + name="jupysql", + version=VERSION, + description="Better SQL in Jupyter", + long_description=README, + long_description_content_type="text/markdown", classifiers=[ "Development Status :: 3 - Alpha", "Environment :: Console", - "License :: OSI Approved :: MIT License", + "License :: OSI Approved :: Apache Software License", "Topic :: Database", "Topic :: Database :: Front-Ends", "Programming Language :: Python :: 3", - "Programming Language :: Python :: 2", ], - keywords="database ipython postgresql mysql", - author="Catherine Devlin", - author_email="catherine.devlin@gmail.com", - url="https://github.com/catherinedevlin/ipython-sql", + keywords="database ipython postgresql mysql duckdb", + author="Ploomber", + author_email="contact@ploomber.io", + url="https://github.com/ploomber/jupysql", project_urls={ - "Source": "https://github.com/catherinedevlin/ipython-sql", + "Source": "https://github.com/ploomber/jupysql", }, - license="MIT", packages=find_packages("src"), package_dir={"": "src"}, include_package_data=True, zip_safe=False, install_requires=install_requires, + extras_require={ + "dev": DEV, + "integration": DEV + INTEGRATION, + }, ) diff --git a/src/sql/__init__.py b/src/sql/__init__.py index 4ff37c1ee..0f7b37951 100644 --- a/src/sql/__init__.py +++ b/src/sql/__init__.py @@ -1 +1,7 @@ -from .magic import * +from sql.magic import load_ipython_extension + + +__version__ = "0.10.18dev" + + +__all__ = ["load_ipython_extension"] diff --git a/src/sql/_current.py b/src/sql/_current.py new file mode 100644 index 000000000..dae92e6be --- /dev/null +++ b/src/sql/_current.py @@ -0,0 +1,27 @@ +"""Get/set the current SqlMagic instance.""" + +__sql_magic = None + + +def _get_sql_magic(): + """Returns the current SqlMagic instance.""" + if __sql_magic is None: + raise RuntimeError("%sql has not been loaded yet. Run %load_ext sql") + + return __sql_magic + + +def _set_sql_magic(sql_magic): + """Sets the current SqlMagic instance.""" + global __sql_magic + __sql_magic = sql_magic + + +def _config_feedback_all(): + """Returns True if the current feedback level is >=2""" + return _get_sql_magic().feedback >= 2 + + +def _config_feedback_normal_or_more(): + """Returns True if the current feedback level is >=1""" + return _get_sql_magic().feedback >= 1 diff --git a/src/sql/_patch.py b/src/sql/_patch.py new file mode 100644 index 000000000..e0b5f20d7 --- /dev/null +++ b/src/sql/_patch.py @@ -0,0 +1,18 @@ +import sys +import types + + +def show_usage_error(self, exc): + """ + This is a patched version of IPython's InteractiveShell.show_usage_error, + which allows us to pass a custom prefix in the error message. + """ + if hasattr(exc, "error_type"): + print(f"{exc.error_type}: {exc}", file=sys.stderr) + else: + print(f"UsageError: {exc}", file=sys.stderr) + + +def patch_ipython_usage_error(ip): + """Patch IPython so we can customize UsageError's messages""" + ip.show_usage_error = types.MethodType(show_usage_error, ip) diff --git a/src/sql/_testing.py b/src/sql/_testing.py new file mode 100644 index 000000000..5c0418186 --- /dev/null +++ b/src/sql/_testing.py @@ -0,0 +1,559 @@ +import argparse +import os +from contextlib import contextmanager +import sys +import time + +from sqlalchemy.engine import URL +import sqlalchemy +from IPython.core.interactiveshell import InteractiveShell +from traitlets.config import Config + +from ploomber_core.dependencies import requires + +# SQLite and DuckDB do not require Docker, so we make docker packages optional +# in case we want to run those tests + +try: + from dockerctx import new_container +except ModuleNotFoundError: + new_container = None + +try: + import docker +except ModuleNotFoundError: + docker = None + + +TMP_DIR = "tmp" + + +class TestingShell(InteractiveShell): + """ + A custom InteractiveShell that raises exceptions instead of silently suppressing + them. + """ + + def run_cell(self, *args, **kwargs): + result = super().run_cell(*args, **kwargs) + result.raise_error() + return result + + @classmethod + def preconfigured_shell(cls): + c = Config() + + # By default, InteractiveShell will record command's history in a SQLite + # database which leads to "too many open files" error when running tests; + # this setting disables the history recording. + # https://ipython.readthedocs.io/en/stable/config/options/terminal.html#configtrait-HistoryAccessor.enabled + c.HistoryAccessor.enabled = False + ip = cls(config=c) + + # there is some weird bug in ipython that causes this function to hang the + # pytest process when all tests have been executed (an internal call to + # gc.collect() hangs). This is a workaround. + ip.displayhook.flush = lambda: None + + return ip + + +class DatabaseConfigHelper: + @staticmethod + def get_database_config(database): + return databaseConfig[database] + + @staticmethod + def get_database_url(database): + return _get_database_url(database) + + @staticmethod + def get_tmp_dir(): + return TMP_DIR + + +mssql_base = { + "username": "sa", + "password": "Ploomber_App@Root_Password", + "database": "master", + "host": "localhost", + "port": "1433", + "query": { + "driver": "ODBC Driver 18 for SQL Server", + "Encrypt": "yes", + "TrustServerCertificate": "yes", + }, + "docker_ct": { + "name": "MSSQL", + "image": "mcr.microsoft.com/azure-sql-edge", + "ports": {1433: 1433}, + }, + "alias": "MSSQLTest", +} + +mssql_pyobdc = {**mssql_base, "drivername": "mssql+pyodbc"} +mssql_pytds = {**mssql_base, "drivername": "mssql+pytds"} + +databaseConfig = { + "postgreSQL": { + "drivername": "postgresql", + "username": "ploomber_app", + "password": "ploomber_app_password", + "database": "db", + "host": "localhost", + "port": "5432", + "alias": "postgreSQLTest", + "docker_ct": { + "name": "postgres", + "image": "postgres", + "ports": {5432: 5432}, + }, + "query": {}, + }, + "mySQL": { + "drivername": "mysql+pymysql", + "username": "ploomber_app", + "password": "ploomber_app_password", + "root_password": "ploomber_app_root_password", + "database": "db", + "host": "localhost", + "port": "33306", + "alias": "mySQLTest", + "docker_ct": { + "name": "mysql", + "image": "mysql:8.0", + "ports": {3306: 33306}, + }, + "query": {}, + }, + "mariaDB": { + "drivername": "mysql+pymysql", + "username": "ploomber_app", + "password": "ploomber_app_password", + "root_password": "ploomber_app_root_password", + "database": "db", + "host": "localhost", + "port": "33309", + "alias": "mariaDBTest", + "docker_ct": { + "name": "mariadb", + "image": "mariadb:10.4.30", + "ports": {3306: 33309}, + }, + "query": {}, + }, + "SQLite": { + "drivername": "sqlite", + "username": None, + "password": None, + "database": "/{}/db-sqlite".format(TMP_DIR), + "host": None, + "port": None, + "alias": "SQLiteTest", + "query": {}, + }, + "duckDB": { + "drivername": "duckdb", + "username": None, + "password": None, + "database": "/{}/db-duckdb".format(TMP_DIR), + "host": None, + "port": None, + "alias": "duckDBTest", + "query": {}, + }, + "MSSQL": mssql_pyobdc, + "mssql_pytds": mssql_pytds, + "Snowflake": { + "drivername": "snowflake", + "username": os.getenv("SF_USERNAME"), + "password": os.getenv("SF_PASSWORD"), + # database/schema + "database": os.getenv("SF_DATABASE", "JUPYSQL_INTEGRATION_TESTING/GENERAL"), + "host": "lpb17716.us-east-1", + "port": None, + "alias": "snowflakeTest", + "docker_ct": None, + "query": { + "warehouse": "COMPUTE_WH", + "role": "SYSADMIN", + }, + }, + "oracle": { + "drivername": "oracle+oracledb", + "username": "ploomber_app", + "password": "ploomber_app_password", + "admin_password": "ploomber_app_admin_password", + # database/schema + "host": "localhost", + "port": "1521", + "alias": "oracle", + "database": None, + "docker_ct": { + "name": "oracle", + "image": "gvenzl/oracle-free", + "ports": {1521: 1521}, + }, + "query": { + "service_name": "FREEPDB1", + }, + }, + "redshift": { + "drivername": "redshift+redshift_connector", + "username": os.getenv("REDSHIFT_USERNAME"), + "password": os.getenv("REDSHIFT_PASSWORD"), + # database/schema + "database": "dev", + "host": os.getenv("REDSHIFT_HOST"), + "port": 5439, + "alias": "redshift", + "docker_ct": None, + "query": {}, + }, + "spark": { + "alias": "SparkSession", + "drivername": "SparkSession", + }, + "clickhouse": { + "drivername": "clickhouse+native", + "username": "username", + "password": "password", + # database/schema + "host": "localhost", + "port": "9000", + "alias": "clickhouse", + "database": "my_database", + "docker_ct": { + "name": "clickhouse", + "image": "clickhouse/clickhouse-server", + "ports": {9000: 9000}, + }, + "query": {}, + }, +} + + +# SQLAlchmey URL: https://docs.sqlalchemy.org/en/20/core/engines.html#database-urls +def _get_database_url(database): + return URL.create( + drivername=databaseConfig[database]["drivername"], + username=databaseConfig[database]["username"], + password=databaseConfig[database]["password"], + host=databaseConfig[database]["host"], + port=databaseConfig[database]["port"], + database=databaseConfig[database]["database"], + query=databaseConfig[database]["query"], + ).render_as_string(hide_password=False) + + +def database_ready( + database, + timeout=60, + poll_freq=0.5, +): + """Wait until the container is ready to receive connections. + + + :type host: str + :type port: int + :type timeout: float + :type poll_freq: float + """ + errors = [] + + t0 = time.time() + while time.time() - t0 < timeout: + try: + eng = sqlalchemy.create_engine(_get_database_url(database)).connect() + eng.close() + print(f"{database} is initialized successfully") + return True + except ModuleNotFoundError: + raise + except Exception as e: + print(type(e)) + errors.append(str(e)) + + time.sleep(poll_freq) + + # print all the errors so we know what's going on since failing to connect might be + # to some misconfiguration error + errors_ = "\n".join(errors) + print(f"ERRORS: {errors_}") + + return True + + +def get_docker_client(): + return docker.from_env( + version="auto", environment={"DOCKER_HOST": os.getenv("DOCKER_HOST")} + ) + + +@contextmanager +@requires(["docker", "dockerctx"]) +def postgres(is_bypass_init=False, print_credentials=False): + if is_bypass_init: + yield None + return + + db_config = DatabaseConfigHelper.get_database_config("postgreSQL") + + if print_credentials: + print(db_config) + + try: + client = get_docker_client() + container = client.containers.get(db_config["docker_ct"]["name"]) + yield container + except docker.errors.NotFound: + print("Creating new container: postgreSQL") + with new_container( + new_container_name=db_config["docker_ct"]["name"], + image_name=db_config["docker_ct"]["image"], + ports=db_config["docker_ct"]["ports"], + environment={ + "POSTGRES_DB": db_config["database"], + "POSTGRES_USER": db_config["username"], + "POSTGRES_PASSWORD": db_config["password"], + }, + ready_test=lambda: database_ready(database="postgreSQL"), + healthcheck={ + "test": "pg_isready", + "interval": 10000000000, + "timeout": 5000000000, + "retries": 5, + }, + ) as container: + yield container + + +@contextmanager +@requires(["docker", "dockerctx"]) +def mysql(is_bypass_init=False, print_credentials=False): + if is_bypass_init: + yield None + return + + db_config = DatabaseConfigHelper.get_database_config("mySQL") + + if print_credentials: + print(db_config) + + try: + client = get_docker_client() + container = client.containers.get(db_config["docker_ct"]["name"]) + yield container + except docker.errors.NotFound: + print("Creating new container: mysql") + with new_container( + new_container_name=db_config["docker_ct"]["name"], + image_name=db_config["docker_ct"]["image"], + ports=db_config["docker_ct"]["ports"], + environment={ + "MYSQL_DATABASE": db_config["database"], + "MYSQL_USER": db_config["username"], + "MYSQL_PASSWORD": db_config["password"], + "MYSQL_ROOT_PASSWORD": db_config["root_password"], + }, + command="mysqld --default-authentication-plugin=mysql_native_password", + ready_test=lambda: database_ready(database="mySQL"), + healthcheck={ + "test": [ + "CMD", + "mysqladmin", + "ping", + "-h", + "localhost", + "--user=root", + "--password=ploomber_app_root_password", + ], + "timeout": 5000000000, + }, + ) as container: + yield container + + +@contextmanager +@requires(["docker", "dockerctx"]) +def mariadb(is_bypass_init=False, print_credentials=False): + if is_bypass_init: + yield None + return + + db_config = DatabaseConfigHelper.get_database_config("mariaDB") + + if print_credentials: + print(db_config) + + try: + client = get_docker_client() + curr = client.containers.get(db_config["docker_ct"]["name"]) + yield curr + except docker.errors.NotFound: + print("Creating new container: mariaDB") + with new_container( + new_container_name=db_config["docker_ct"]["name"], + image_name=db_config["docker_ct"]["image"], + ports=db_config["docker_ct"]["ports"], + environment={ + "MYSQL_DATABASE": db_config["database"], + "MYSQL_USER": db_config["username"], + "MYSQL_PASSWORD": db_config["password"], + "MYSQL_ROOT_PASSWORD": db_config["root_password"], + }, + command="mysqld --default-authentication-plugin=mysql_native_password", + ready_test=lambda: database_ready(database="mariaDB"), + healthcheck={ + "test": [ + "CMD", + "mysqladmin", + "ping", + "-h", + "localhost", + "--user=root", + "--password=ploomber_app_root_password", + ], + "timeout": 5000000000, + }, + ) as container: + yield container + + +@contextmanager +@requires(["docker", "dockerctx"]) +def mssql(is_bypass_init=False, print_credentials=False): + if is_bypass_init: + yield None + return + + db_config = DatabaseConfigHelper.get_database_config("MSSQL") + + if print_credentials: + print(db_config) + + try: + client = get_docker_client() + curr = client.containers.get(db_config["docker_ct"]["name"]) + yield curr + except docker.errors.NotFound: + print("Creating new container: MSSQL") + with new_container( + new_container_name=db_config["docker_ct"]["name"], + image_name=db_config["docker_ct"]["image"], + ports=db_config["docker_ct"]["ports"], + environment={ + "MSSQL_DATABASE": db_config["database"], + "MSSQL_USER": db_config["username"], + "MSSQL_SA_PASSWORD": db_config["password"], + "ACCEPT_EULA": "Y", + }, + ready_test=lambda: database_ready(database="MSSQL"), + healthcheck={ + "test": "/opt/mssql-tools/bin/sqlcmd " + "-U $DB_USER -P $SA_PASSWORD " + "-Q 'select 1' -b -o /dev/null", + "timeout": 5000000000, + }, + ) as container: + yield container + + +@contextmanager +@requires(["docker", "dockerctx"]) +def oracle(is_bypass_init=False, print_credentials=False): + if is_bypass_init: + yield None + return + + db_config = DatabaseConfigHelper.get_database_config("oracle") + + if print_credentials: + print(db_config) + + try: + client = get_docker_client() + curr = client.containers.get(db_config["docker_ct"]["name"]) + yield curr + except docker.errors.NotFound: + print("Creating new container: oracle") + with new_container( + new_container_name=db_config["docker_ct"]["name"], + image_name=db_config["docker_ct"]["image"], + ports=db_config["docker_ct"]["ports"], + environment={ + "APP_USER": db_config["username"], + "APP_USER_PASSWORD": db_config["password"], + "ORACLE_PASSWORD": db_config["admin_password"], + }, + # Oracle takes more time to initialize + ready_test=lambda: database_ready(database="oracle"), + ) as container: + yield container + + +@contextmanager +@requires(["docker", "dockerctx"]) +def clickhouse(is_bypass_init=False, print_credentials=False): + if is_bypass_init: + yield None + return + + db_config = DatabaseConfigHelper.get_database_config("clickhouse") + + if print_credentials: + print(db_config) + + try: + client = get_docker_client() + curr = client.containers.get(db_config["docker_ct"]["name"]) + yield curr + except docker.errors.NotFound: + print("Creating new container: clickhouse") + with new_container( + new_container_name=db_config["docker_ct"]["name"], + image_name=db_config["docker_ct"]["image"], + ports=db_config["docker_ct"]["ports"], + environment={ + "CLICKHOUSE_USER": db_config["username"], + "CLICKHOUSE_PASSWORD": db_config["password"], + "CLICKHOUSE_DB": db_config["database"], + }, + ready_test=lambda: database_ready(database="clickhouse"), + ) as container: + yield container + + +def main(): + available_databases = [ + "postgres", + "mysql", + "mariadb", + "mssql", + "oracle", + "clickhouse", + ] + + parser = argparse.ArgumentParser(description="Start database containers") + parser.add_argument( + "database", + choices=available_databases, + help="database to start", + ) + + args = parser.parse_args() + fn = globals()[args.database] + + with fn(print_credentials=True): + print("Press CTRL+C to exit") + + try: + while True: + time.sleep(5) + except KeyboardInterrupt: + print("Exit, containers will be killed") + sys.exit() + + +if __name__ == "__main__": + main() diff --git a/src/sql/cmd/__init__.py b/src/sql/cmd/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/sql/cmd/cmd_utils.py b/src/sql/cmd/cmd_utils.py new file mode 100644 index 000000000..0f2dee3a3 --- /dev/null +++ b/src/sql/cmd/cmd_utils.py @@ -0,0 +1,21 @@ +import argparse +import sys +from sql import exceptions + + +class CmdParser(argparse.ArgumentParser): + """ + Subclassing ArgumentParser as it throws a SystemExit + error when it encounters argument validation errors. + + + Now we raise a UsageError in case of argument validation + issues. + """ + + def exit(self, status=0, message=None): + if message: + self._print_message(message, sys.stderr) + + def error(self, message): + raise exceptions.UsageError(message) diff --git a/src/sql/cmd/columns.py b/src/sql/cmd/columns.py new file mode 100644 index 000000000..d15539ab2 --- /dev/null +++ b/src/sql/cmd/columns.py @@ -0,0 +1,38 @@ +from sql import inspect +from sql.util import sanitize_identifier +from sql.cmd.cmd_utils import CmdParser +from sql.util import expand_args, is_rendering_required + + +def columns(others, user_ns): + """ + Implementation of `%sqlcmd columns` + This function takes in a string containing command line arguments, + parses them to extract the name of the table and the schema, and returns + a list of columns for the specified table. It also uses the kernel + namespace for expanding arguments declared as variables. + + Parameters + ---------- + others : str, + A string containing the command line arguments. + + user_ns : dict, + User namespace of IPython kernel + + Returns + ------- + columns: list + information of the columns in the specified table + """ + parser = CmdParser() + + parser.add_argument("-t", "--table", type=str, help="Table name", required=True) + parser.add_argument("-s", "--schema", type=str, help="Schema name", required=False) + + args = parser.parse_args(others) + + if is_rendering_required(" ".join(others)): + expand_args(args, user_ns) + + return inspect.get_columns(name=sanitize_identifier(args.table), schema=args.schema) diff --git a/src/sql/cmd/connect.py b/src/sql/cmd/connect.py new file mode 100644 index 000000000..2a4668055 --- /dev/null +++ b/src/sql/cmd/connect.py @@ -0,0 +1,16 @@ +try: + from jupysql_plugin.widgets import ConnectorWidget +except ModuleNotFoundError: + ConnectorWidget = None + +from ploomber_core.dependencies import requires + + +@requires(["jupysql-plugin", "ipywidgets"]) +def connect(others): + """ + Implementation of `%sqlcmd connect` + """ + + connectorwidget = ConnectorWidget() + return connectorwidget diff --git a/src/sql/cmd/explore.py b/src/sql/cmd/explore.py new file mode 100644 index 000000000..2d144d517 --- /dev/null +++ b/src/sql/cmd/explore.py @@ -0,0 +1,31 @@ +from sql.widgets import TableWidget +from sql.cmd.cmd_utils import CmdParser +from sql.util import expand_args, is_rendering_required + + +def explore(others, user_ns): + """ + Implementation of `%sqlcmd explore` + This function takes in a string containing command line arguments, + parses them to extract the name of the table, and displays an interactive + widget for exploring the contents of the specified table. It also uses the + kernel namespace for expanding arguments declared as variables. + + Parameters + ---------- + others : str, + A string containing the command line arguments. + + user_ns : dict, + User namespace of IPython kernel + + """ + parser = CmdParser() + parser.add_argument("-t", "--table", type=str, help="Table name", required=True) + parser.add_argument("-s", "--schema", type=str, help="Schema name", required=False) + args = parser.parse_args(others) + if is_rendering_required(" ".join(others)): + expand_args(args, user_ns) + + table_widget = TableWidget(args.table, args.schema) + return table_widget diff --git a/src/sql/cmd/profile.py b/src/sql/cmd/profile.py new file mode 100644 index 000000000..600de1072 --- /dev/null +++ b/src/sql/cmd/profile.py @@ -0,0 +1,48 @@ +from sql import inspect +from sql.cmd.cmd_utils import CmdParser +from sql.util import expand_args, is_rendering_required + + +def profile(others, user_ns): + """ + Implementation of `%sqlcmd profile` + This function takes in a string containing command line arguments, + parses them to extract the name of the table, the schema, and the output location. + It then retrieves statistical information about the specified table and either + returns the report or writes it to the specified location. + It also uses the kernel namespace for expanding arguments declared as variables. + + + Parameters + ---------- + others : str, + A string containing the command line arguments. + + user_ns : dict, + User namespace of IPython kernel + + Returns + ------- + report: PrettyTable + statistics of the table + """ + parser = CmdParser() + parser.add_argument("-t", "--table", type=str, help="Table name", required=True) + + parser.add_argument("-s", "--schema", type=str, help="Schema name", required=False) + + parser.add_argument( + "-o", "--output", type=str, help="Store report location", required=False + ) + + args = parser.parse_args(others) + if is_rendering_required(" ".join(others)): + expand_args(args, user_ns) + + report = inspect.get_table_statistics(schema=args.schema, name=args.table) + + if args.output: + with open(args.output, "w") as f: + f.write(report._repr_html_()) + + return report diff --git a/src/sql/cmd/snippets.py b/src/sql/cmd/snippets.py new file mode 100644 index 000000000..496aa6df9 --- /dev/null +++ b/src/sql/cmd/snippets.py @@ -0,0 +1,124 @@ +from sql import util +from sql import store +from sql.exceptions import UsageError +from sql.cmd.cmd_utils import CmdParser +from sql.display import Table, Message +from sql.util import expand_args, is_rendering_required, render_string_using_namespace + + +def _modify_display_msg(key, remaining_keys, dependent_keys=None): + """ + + Parameters + ---------- + key : str, + deleted stored snippet + remaining_keys: list + snippets remaining after key is deleted + dependent_keys: list + snippets dependent on key + + Returns + ------- + msg: str + Formatted message + """ + msg = f"{key} has been deleted.\n" + if dependent_keys: + msg = f"{msg}{', '.join(dependent_keys)} depend on {key}\n" + if remaining_keys: + msg = f"{msg}Stored snippets: {', '.join(remaining_keys)}" + else: + msg = f"{msg}There are no stored snippets" + return msg + + +def snippets(others, user_ns): + """ + Implementation of `%sqlcmd snippets` + This function handles all the arguments related to %sqlcmd snippets, namely + listing stored snippets, and delete/ force delete/ force delete a snippet and + all its dependent snippets. It also uses the kernel namespace for expanding + arguments declared as variables. + + + Parameters + ---------- + others : str, + A string containing the command line arguments. + + user_ns : dict, + User namespace of IPython kernel + """ + parser = CmdParser() + parser.add_argument( + "-d", "--delete", type=str, help="Delete stored snippet", required=False + ) + parser.add_argument( + "-D", + "--delete-force", + type=str, + help="Force delete stored snippet", + required=False, + ) + parser.add_argument( + "-A", + "--delete-force-all", + type=str, + help="Force delete all stored snippets", + required=False, + ) + all_snippets = store.get_all_keys() + if len(others) == 1: + others[0] = render_string_using_namespace(others[0], user_ns) + if others[0] in all_snippets: + return str(store.store[others[0]]) + + base_err_msg = f"'{others[0]}' is not a snippet. " + if len(all_snippets) == 0: + err_msg = "%sThere is no available snippet." + else: + err_msg = "%sAvailable snippets are " f"{util.pretty_print(all_snippets)}." + err_msg = err_msg % (base_err_msg) + + raise UsageError(err_msg) + + args = parser.parse_args(others) + if is_rendering_required(" ".join(others)): + expand_args(args, user_ns) + + SNIPPET_ARGS = [args.delete, args.delete_force, args.delete_force_all] + if SNIPPET_ARGS.count(None) == len(SNIPPET_ARGS): + if len(all_snippets) == 0: + return Message("No snippets stored") + else: + return Table(["Stored snippets"], [[snippet] for snippet in all_snippets]) + + if args.delete: + deps = store.get_key_dependents(args.delete) + if deps: + deps = ", ".join(deps) + raise UsageError( + f"The following tables are dependent on {args.delete}: {deps}.\n" + f"Pass --delete-force to only delete {args.delete}.\n" + f"Pass --delete-force-all to delete {deps} and {args.delete}" + ) + else: + key = args.delete + remaining_keys = store.del_saved_key(key) + return _modify_display_msg(key, remaining_keys) + + elif args.delete_force: + key = args.delete_force + deps = store.get_key_dependents(key) + remaining_keys = store.del_saved_key(key) + for dep in deps: + store.store[dep].remove_snippet_dependency(key) + return _modify_display_msg(key, remaining_keys, deps) + + elif args.delete_force_all: + deps = store.get_key_dependents(args.delete_force_all) + deps.append(args.delete_force_all) + for key in deps: + remaining_keys = store.del_saved_key(key) + return _modify_display_msg(", ".join(deps), remaining_keys) diff --git a/src/sql/cmd/tables.py b/src/sql/cmd/tables.py new file mode 100644 index 000000000..d0b940a40 --- /dev/null +++ b/src/sql/cmd/tables.py @@ -0,0 +1,37 @@ +from sql import inspect +from sql.cmd.cmd_utils import CmdParser +from sql.util import expand_args, is_rendering_required + + +def tables(others, user_ns): + """ + Implementation of `%sqlcmd tables` + + This function takes in a string containing command line arguments, + parses them to extract the schema name, and returns a list of table names + present in the specified schema or in the default schema if none is specified. + It also uses the kernel namespace for expanding arguments declared as variables. + + Parameters + ---------- + others : str, + A string containing the command line arguments. + + user_ns : dict, + User namespace of IPython kernel + + Returns + ------- + table_names: list + list of tables in the schema + + """ + parser = CmdParser() + + parser.add_argument("-s", "--schema", type=str, help="Schema name", required=False) + + args = parser.parse_args(others) + if is_rendering_required(" ".join(others)): + expand_args(args, user_ns) + + return inspect.get_table_names(schema=args.schema) diff --git a/src/sql/cmd/test.py b/src/sql/cmd/test.py new file mode 100644 index 000000000..bde8495b3 --- /dev/null +++ b/src/sql/cmd/test.py @@ -0,0 +1,193 @@ +from sql import exceptions +import sql.connection +from sqlglot import select, condition +from prettytable import PrettyTable +from sql.cmd.cmd_utils import CmdParser +from sql.util import expand_args, is_rendering_required + + +def return_test_results(args, conn, query): + columns = [] + + try: + column_data = conn.execute(query).cursor.description + res = conn.execute(query).fetchall() + for column in column_data: + columns.append(column[0]) + res = [columns, *res] + return res + except Exception as e: + if "column" in str(e): + raise exceptions.UsageError( + f"Referenced column '{args.column}' not found!" + ) from e + + +def run_each_individually(args, conn): + if args.schema: + table_ = f"{args.schema}.{args.table}" + else: + table_ = args.table + base_query = select("*").from_(table_) + + storage = {} + + if args.greater: + where = condition(args.column + "<=" + args.greater) + current_query = base_query.where(where).sql() + + res = return_test_results(args, conn, query=current_query) + + if res is not None: + storage["greater"] = res + if args.greater_or_equal: + where = condition(args.column + "<" + args.greater_or_equal) + + current_query = base_query.where(where).sql() + + res = return_test_results(args, conn, query=current_query) + + if res is not None: + storage["greater_or_equal"] = res + + if args.less_than_or_equal: + where = condition(args.column + ">" + args.less_than_or_equal) + current_query = base_query.where(where).sql() + + res = return_test_results(args, conn, query=current_query) + + if res is not None: + storage["less_than_or_equal"] = res + if args.less_than: + where = condition(args.column + ">=" + args.less_than) + current_query = base_query.where(where).sql() + + res = return_test_results(args, conn, query=current_query) + + if res is not None: + storage["less_than"] = res + if args.no_nulls: + where = condition("{} is NULL".format(args.column)) + current_query = base_query.where(where).sql() + + res = return_test_results(args, conn, query=current_query) + + if res is not None: + storage["null"] = res + + return storage + + +def test(others, user_ns): + """ + Implementation of `%sqlcmd test` + + This function takes in a string containing command line arguments, + parses them to extract the table name, column name, and conditions + to return if those conditions are satisfied in that table + It also uses the kernel namespace for expanding arguments declared as + variables. + + Parameters + ---------- + others : str, + A string containing the command line arguments. + + user_ns : dict, + User namespace of IPython kernel + + Returns + ------- + result: bool + Result of the test + + table: PrettyTable + table with rows because of which the test fails + + + """ + parser = CmdParser() + + parser.add_argument("-t", "--table", type=str, help="Table name", required=True) + parser.add_argument("-s", "--schema", type=str, help="Schema name", required=False) + parser.add_argument("-c", "--column", type=str, help="Column name", required=False) + parser.add_argument( + "-g", + "--greater", + type=str, + help="Greater than a certain number.", + required=False, + ) + parser.add_argument( + "-goe", + "--greater-or-equal", + type=str, + help="Greater or equal than a certain number.", + required=False, + ) + parser.add_argument( + "-l", + "--less-than", + type=str, + help="Less than a certain number.", + required=False, + ) + parser.add_argument( + "-loe", + "--less-than-or-equal", + type=str, + help="Less than or equal to a certain number.", + required=False, + ) + parser.add_argument( + "-nn", + "--no-nulls", + help="Returns rows in specified column that are not null.", + action="store_true", + ) + + args = parser.parse_args(others) + if is_rendering_required(" ".join(others)): + expand_args(args, user_ns) + + COMPARATOR_ARGS = [ + args.greater, + args.greater_or_equal, + args.less_than, + args.less_than_or_equal, + ] + + if args.table and not any(COMPARATOR_ARGS): + raise exceptions.UsageError("Please use a valid comparator.") + + if args.table and any(COMPARATOR_ARGS) and not args.column: + raise exceptions.UsageError("Please pass a column to test.") + + if args.greater and args.greater_or_equal: + return exceptions.UsageError( + "You cannot use both greater and greater " + "than or equal to arguments at the same time." + ) + elif args.less_than and args.less_than_or_equal: + return exceptions.UsageError( + "You cannot use both less and less than " + "or equal to arguments at the same time." + ) + + conn = sql.connection.ConnectionManager.current + result_dict = run_each_individually(args, conn) + + if any(len(rows) > 1 for rows in list(result_dict.values())): + for comparator, rows in result_dict.items(): + if len(rows) > 1: + print(f"\n{comparator}:\n") + _pretty = PrettyTable() + _pretty.field_names = rows[0] + for row in rows[1:]: + _pretty.add_row(row) + print(_pretty) + raise exceptions.UsageError( + "The above values do not match your test requirements." + ) + else: + return True diff --git a/src/sql/column_guesser.py b/src/sql/column_guesser.py index 34d4db89a..12fb2912a 100644 --- a/src/sql/column_guesser.py +++ b/src/sql/column_guesser.py @@ -16,7 +16,7 @@ def __init__(self, *arg, **kwarg): def is_quantity(val): """Is ``val`` a quantity (int, float, datetime, etc) (not str, bool)? - + Relies on presence of __sub__. """ return hasattr(val, "__sub__") @@ -28,16 +28,19 @@ class ColumnGuesserMixin(object): pie: ... y """ + def __init__(self): + self.keys = None + def _build_columns(self): self.columns = [Column() for col in self.keys] for row in self: - for (col_idx, col_val) in enumerate(row): + for col_idx, col_val in enumerate(row): col = self.columns[col_idx] col.append(col_val) if (col_val is not None) and (not is_quantity(col_val)): col.is_quantity = False - for (idx, key_name) in enumerate(self.keys): + for idx, key_name in enumerate(self.keys): self.columns[idx].name = key_name self.x = Column() @@ -73,8 +76,8 @@ def _guess_columns(self): def guess_pie_columns(self, xlabel_sep=" "): """ Assigns x, y, and x labels from the data set for a pie chart. - - Pie charts simply use the last quantity column as + + Pie charts simply use the last quantity column as the pie slice size, and everything else as the pie slice labels. """ @@ -84,7 +87,7 @@ def guess_pie_columns(self, xlabel_sep=" "): def guess_plot_columns(self): """ Assigns ``x`` and ``y`` series from the data set for a plot. - + Plots use: the rightmost quantity column as a Y series optionally, the leftmost quantity column as the X series diff --git a/src/sql/command.py b/src/sql/command.py new file mode 100644 index 000000000..25fb554d7 --- /dev/null +++ b/src/sql/command.py @@ -0,0 +1,156 @@ +from pathlib import Path +from jinja2 import Template + +from sqlalchemy.engine import Engine + +from sql import parse, exceptions +from sql.store import store +from sql.connection import ConnectionManager, is_pep249_compliant, is_spark +from sql.util import validate_nonidentifier_connection + + +class SQLPlotCommand: + def __init__(self, magic, line) -> None: + self.args = parse.magic_args( + magic.execute, line, "sqlplot", allowed_duplicates=["-w", "--with"] + ) + + +class SQLCommand: + """ + Encapsulates the parsing logic (arguments, SQL code, connection string, etc.) + + """ + + def __init__(self, magic, user_ns, line, cell) -> None: + self._line = line + self._cell = cell + + self.args = parse.magic_args( + magic.execute, + line, + "sql", + allowed_duplicates=["-w", "--with", "--append", "--interact"], + ) + + # self.args.line (everything that appears after %sql/%%sql in the first line) + # is split in tokens (delimited by spaces), this checks if we have one arg + one_arg = len(self.args.line) == 1 + + # NOTE: this is only used to determine if what the user passed looks like a + # connection, we can simplify it + if len(self.args.line) > 0 and self.args.line[0] in user_ns: + conn = user_ns[self.args.line[0]] + + is_dbapi_connection_ = is_pep249_compliant(conn) + else: + is_dbapi_connection_ = False + + if ( + one_arg + and self.args.line[0] in user_ns + and ( + isinstance(user_ns[self.args.line[0]], Engine) + or is_dbapi_connection_ + or is_spark(user_ns[self.args.line[0]]) + ) + ): + line_for_command = [] + add_conn = True + else: + line_for_command = self.args.line + add_conn = False + + if one_arg and self.args.line[0] in ConnectionManager.connections: + line_for_command = [] + add_alias = True + else: + add_alias = False + + self.command_text = " ".join(line_for_command) + "\n" + cell + + if self.args.file: + try: + file_contents = Path(self.args.file).read_text() + except FileNotFoundError as e: + raise exceptions.FileNotFoundError(str(e)) from e + + self.command_text = file_contents + "\n" + self.command_text + + self.parsed = parse.parse(self.command_text, magic.dsn_filename) + + self.parsed["sql_original"] = self.parsed["sql"] = self._var_expand( + self.parsed["sql"], user_ns + ) + + if add_conn: + self.parsed["connection"] = user_ns[self.args.line[0]] + + if add_alias: + self.parsed["connection"] = self.args.line[0] + + if self.args.with_: + self.args.with_ = [ + Template(item).render(user_ns) for item in self.args.with_ + ] + final = store.render(self.parsed["sql"], with_=self.args.with_) + self.parsed["sql"] = str(final) + + if ( + one_arg + and self.sql + and not (add_conn or add_alias) + and not (self.args.persist_replace or self.args.persist or self.args.append) + ): + # Apply strip to ensure whitespaces/linebreaks aren't passed + validate_nonidentifier_connection(self.sql.strip().split(" ")[0].strip()) + + @property + def sql(self): + """ + Returns the SQL query to execute, without any other options or arguments + """ + return self.parsed["sql"] + + @property + def sql_original(self): + """ + Returns the raw SQL query. Might be different from `sql` if using --with + """ + return self.parsed["sql_original"] + + @property + def connection(self): + """Returns the connection string""" + return self.parsed["connection"] + + @property + def result_var(self): + """Returns the result_var""" + return self.parsed["result_var"] + + @property + def return_result_var(self): + """Returns the return_result_var""" + return self.parsed["return_result_var"] + + def _var_expand(self, sql, user_ns): + return Template(sql).render(user_ns) + + def __repr__(self) -> str: + return ( + f"{type(self).__name__}(line={self._line!r}, cell={self._cell!r}) -> " + f"({self.sql!r}, {self.sql_original!r})" + ) + + def set_sql_with(self, with_): + """ + Sets the final rendered SQL query using the WITH clause + + Parameters + ---------- + with_ : list + list of all subqueries needed to render the query + """ + final = store.render(self.parsed["sql"], with_) + self.parsed["sql"] = str(final) diff --git a/src/sql/connection.py b/src/sql/connection.py deleted file mode 100644 index e11191be3..000000000 --- a/src/sql/connection.py +++ /dev/null @@ -1,122 +0,0 @@ -import os -import re - -import sqlalchemy - - -class ConnectionError(Exception): - pass - - -def rough_dict_get(dct, sought, default=None): - """ - Like dct.get(sought), but any key containing sought will do. - - If there is a `@` in sought, seek each piece separately. - This lets `me@server` match `me:***@myserver/db` - """ - - sought = sought.split("@") - for (key, val) in dct.items(): - if not any(s.lower() not in key.lower() for s in sought): - return val - return default - - -class Connection(object): - current = None - connections = {} - - @classmethod - def tell_format(cls): - return """Connection info needed in SQLAlchemy format, example: - postgresql://username:password@hostname/dbname - or an existing connection: %s""" % str( - cls.connections.keys() - ) - - def __init__(self, connect_str=None, connect_args={}, creator=None): - try: - if creator: - engine = sqlalchemy.create_engine( - connect_str, connect_args=connect_args, creator=creator - ) - else: - engine = sqlalchemy.create_engine( - connect_str, connect_args=connect_args - ) - except: # TODO: bare except; but what's an ArgumentError? - print(self.tell_format()) - raise - self.dialect = engine.url.get_dialect() - self.metadata = sqlalchemy.MetaData(bind=engine) - self.name = self.assign_name(engine) - self.session = engine.connect() - self.connections[repr(self.metadata.bind.url)] = self - self.connect_args = connect_args - Connection.current = self - - @classmethod - def set(cls, descriptor, displaycon, connect_args={}, creator=None): - "Sets the current database connection" - - if descriptor: - if isinstance(descriptor, Connection): - cls.current = descriptor - else: - existing = rough_dict_get(cls.connections, descriptor) - # http://docs.sqlalchemy.org/en/rel_0_9/core/engines.html#custom-dbapi-connect-arguments - cls.current = existing or Connection(descriptor, connect_args, creator) - else: - - if cls.connections: - if displaycon: - print(cls.connection_list()) - else: - if os.getenv("DATABASE_URL"): - cls.current = Connection( - os.getenv("DATABASE_URL"), connect_args, creator - ) - else: - raise ConnectionError( - "Environment variable $DATABASE_URL not set, and no connect string given." - ) - return cls.current - - @classmethod - def assign_name(cls, engine): - name = "%s@%s" % (engine.url.username or "", engine.url.database) - return name - - @classmethod - def connection_list(cls): - result = [] - for key in sorted(cls.connections): - engine_url = cls.connections[ - key - ].metadata.bind.url # type: sqlalchemy.engine.url.URL - if cls.connections[key] == cls.current: - template = " * {}" - else: - template = " {}" - result.append(template.format(engine_url.__repr__())) - return "\n".join(result) - - @classmethod - def _close(cls, descriptor): - if isinstance(descriptor, Connection): - conn = descriptor - else: - conn = cls.connections.get(descriptor) or cls.connections.get( - descriptor.lower() - ) - if not conn: - raise Exception( - "Could not close connection because it was not found amongst these: %s" - % str(cls.connections.keys()) - ) - cls.connections.pop(str(conn.metadata.bind.url)) - conn.session.close() - - def close(self): - self.__class__._close(self) diff --git a/src/sql/connection/__init__.py b/src/sql/connection/__init__.py new file mode 100644 index 000000000..4d9dfb10a --- /dev/null +++ b/src/sql/connection/__init__.py @@ -0,0 +1,26 @@ +from sql.connection.connection import ( + ConnectionManager, + SQLAlchemyConnection, + DBAPIConnection, + SparkConnectConnection, + is_pep249_compliant, + is_spark, + PLOOMBER_DOCS_LINK_STR, + default_alias_for_engine, + ResultSetCollection, + detect_duckdb_summarize_or_select, +) + + +__all__ = [ + "ConnectionManager", + "SQLAlchemyConnection", + "DBAPIConnection", + "SparkConnectConnection", + "is_pep249_compliant", + "is_spark", + "PLOOMBER_DOCS_LINK_STR", + "default_alias_for_engine", + "ResultSetCollection", + "detect_duckdb_summarize_or_select", +] diff --git a/src/sql/connection/connection.py b/src/sql/connection/connection.py new file mode 100644 index 000000000..6c1c1d525 --- /dev/null +++ b/src/sql/connection/connection.py @@ -0,0 +1,1314 @@ +import warnings +import difflib +import abc +import os +from difflib import get_close_matches +import atexit +from functools import partial + +import sqlalchemy +from sqlalchemy.engine import Engine +from sqlalchemy.exc import ( + NoSuchModuleError, + OperationalError, + StatementError, + PendingRollbackError, + InternalError, + ProgrammingError, +) + +from sql.run.sparkdataframe import handle_spark_dataframe + +from IPython.core.error import UsageError +import sqlglot +import sqlparse +from ploomber_core.exceptions import modify_exceptions + + +from sql.store import store +from sql import exceptions, display +from sql.error_handler import handle_exception +from sql.parse import ( + escape_string_literals_with_colon_prefix, + find_named_parameters, + ConnectionsFile, +) +from sql.warnings import JupySQLQuotedNamedParametersWarning, JupySQLRollbackPerformed +from sql import _current +from sql.connection import error_handling + +BASE_DOC_URL = "https://jupysql.ploomber.io/en/latest" + + +PLOOMBER_DOCS_LINK_STR = f"{BASE_DOC_URL}/connecting.html" + +IS_SQLALCHEMY_ONE = int(sqlalchemy.__version__.split(".")[0]) == 1 + +# Check Full List: https://docs.sqlalchemy.org/en/20/dialects + + +MISSING_PACKAGE_LIST_EXCEPT_MATCHERS = { + # SQLite + "sqlite": "sqlite", + "pysqlcipher3": "pysqlcipher3", + # DuckDB + "duckdb": "duckdb-engine", + # MySQL + MariaDB + "pymysql": "pymysql", + "mysqldb": "mysqlclient", + "mariadb": "mariadb", + "mysql": "mysql-connector-python", + "asyncmy": "asyncmy", + "aiomysql": "aiomysql", + "cymysql": "cymysql", + "pyodbc": "pyodbc", + # PostgreSQL + "psycopg2": "psycopg2", + "psycopg": "psycopg", + "pg8000": "pg8000", + "asyncpg": "asyncpg", + "psycopg2cffi": "psycopg2cffi", + # Oracle + "cx_oracle": "cx_oracle", + "oracledb": "oracledb", + # MSSQL + "pyodbc": "pyodbc", + "pymssql": "pymssql", + # snowflake + "snowflake": "snowflake-sqlalchemy", +} + +BASE_DRIVERS_URL = f"{BASE_DOC_URL}/howto/db-drivers.html" + +DBNAME_2_DOC_LINK = { + "psycopg2": f"{BASE_DRIVERS_URL}#postgresql", + "duckdb": f"{BASE_DRIVERS_URL}#duckdb", +} + +DIALECT_NAME_SQLALCHEMY_TO_SQLGLOT_MAPPING = {"postgresql": "postgres", "mssql": "tsql"} + +# All the DBs and their respective documentation links +DB_DOCS_LINKS = { + "duckdb": f"{BASE_DOC_URL}/integrations/duckdb.html", + "mysql": f"{BASE_DOC_URL}/integrations/mysql.html", + "mssql": f"{BASE_DOC_URL}/integrations/mssql.html", + "mariadb": f"{BASE_DOC_URL}/integrations/mariadb.html", + "clickhouse": f"{BASE_DOC_URL}/integrations/clickhouse.html", + "postgresql": f"{BASE_DOC_URL}/integrations/postgres-connect.html", + "questdb": f"{BASE_DOC_URL}/integrations/questdb.html", +} + + +def extract_module_name_from_ModuleNotFoundError(e): + return e.name + + +def extract_module_name_from_NoSuchModuleError(e): + return str(e).split(":")[-1].split(".")[-1] + + +class ResultSetCollection: + def __init__(self) -> None: + self._result_sets = [] + + def append(self, result): + if result in self._result_sets: + self._result_sets.remove(result) + + self._result_sets.append(result) + + def is_last(self, result): + # if there are no results, return True to prevent triggering + # a query in the database + if not len(self._result_sets): + return True + + return self._result_sets[-1] is result + + def close_all(self): + for r in self._result_sets: + r.close() + + self._result_sets = [] + + def __iter__(self): + return iter(self._result_sets) + + def __len__(self): + return len(self._result_sets) + + +def get_missing_package_suggestion_str(e): + """Provide a better error when a user tries to connect to a database but they're + missing the database driver + """ + suggestion_prefix = "To fix it, " + + module_name = None + + if isinstance(e, ModuleNotFoundError): + module_name = extract_module_name_from_ModuleNotFoundError(e) + elif isinstance(e, NoSuchModuleError): + module_name = extract_module_name_from_NoSuchModuleError(e) + + module_name = module_name.lower() + + error_message = ( + suggestion_prefix + "make sure you are using correct driver name:\n" + "Ref: https://docs.sqlalchemy.org/en/20/core/engines.html#database-urls" + ) + + # Exact match + suggested_package = MISSING_PACKAGE_LIST_EXCEPT_MATCHERS.get(module_name) + + if suggested_package: + error_message = ( + suggestion_prefix + + "run this in your notebook: " + + error_handling.install_command(suggested_package) + ) + else: + # Closely matched + close_matches = difflib.get_close_matches( + module_name, MISSING_PACKAGE_LIST_EXCEPT_MATCHERS.keys() + ) + + if close_matches: + error_message = ( + f'Perhaps you meant to use driver the dialect: "{close_matches[0]}"' + ) + + error_suffix = ( + DBNAME_2_DOC_LINK.get(module_name) + if DBNAME_2_DOC_LINK.get(module_name) + else PLOOMBER_DOCS_LINK_STR + ) + + return error_message + "\n\nFor more details, see: " + error_suffix + + +def rough_dict_get(dct, sought, default=None): + """ + Like dct.get(sought), but any key containing sought will do. + + If there is a `@` in sought, seek each piece separately. + This lets `me@server` match `me:***@myserver/db` + """ + + sought = sought.split("@") + for key, val in dct.items(): + if not any(s.lower() not in key.lower() for s in sought): + return val + return default + + +def _error_invalid_connection_info(e, connect_str): + err = UsageError( + "An error happened while creating the connection: " + f"{e}.{_suggest_fix(env_var=False, connect_str=connect_str)}" + ) + err.modify_exception = True + return err + + +class ConnectionManager: + """A class to manage and create database connections""" + + # all connections + connections = {} + + # the active connection + current = None + + @classmethod + def set( + cls, + descriptor, + displaycon, + connect_args=None, + creator=None, + alias=None, + config=None, + ): + """ + Set the current database connection. This method is called from the magic to + determine which connection to use (either use an existing one or open a new one) + + Parameters + ---------- + descriptor : str or sqlalchemy.engine.Engine or sqlalchemy.engine.Connection + A connection string or an existing connection. It opens a new connection + if needed, otherwise it just assigns the connection as the current + connection. + + alias : str, optional + A name to identify the connection + + config : object, optional + An object with configuration options. Options must be accessible via + attributes. As of 0.9.0, only the autocommit option is needed. + """ + connect_args = connect_args or {} + + if descriptor: + if isinstance(descriptor, SQLAlchemyConnection): + cls.current = descriptor + elif isinstance(descriptor, Engine): + cls.current = SQLAlchemyConnection( + descriptor, config=config, alias=alias + ) + elif is_pep249_compliant(descriptor): + cls.current = DBAPIConnection(descriptor, config=config, alias=alias) + elif is_spark(descriptor): + cls.current = SparkConnectConnection( + descriptor, config=config, alias=alias + ) + else: + existing = rough_dict_get(cls.connections, descriptor) + if existing and existing.alias == alias: + cls.current = existing + elif existing and alias is None: + if ( + _current._config_feedback_normal_or_more() + and cls.current != existing + ): + display.message(f"Switching to connection {descriptor!r}") + cls.current = existing + + # passing the same URL but different alias: create a new connection + elif existing is None or existing.alias != alias: + is_connect_and_switch, is_connect = False, False + if cls.current and cls.current.alias != alias: + is_connect_and_switch = True + else: + is_connect = True + + cls.current = cls.from_connect_str( + connect_str=descriptor, + connect_args=connect_args, + creator=creator, + alias=alias, + config=config, + ) + if _current._config_feedback_normal_or_more(): + identifier = alias or cls.current.url + if is_connect_and_switch: + display.message( + f"Connecting and switching to connection {identifier!r}" + ) + if is_connect: + display.message(f"Connecting to {identifier!r}") + + else: + if cls.connections: + if displaycon and _current._config_feedback_normal_or_more(): + cls.display_current_connection() + elif os.getenv("DATABASE_URL"): + cls.current = cls.from_connect_str( + connect_str=os.getenv("DATABASE_URL"), + connect_args=connect_args, + creator=creator, + alias=alias, + config=config, + ) + else: + raise cls._error_no_connection() + + return cls.current + + @classmethod + def close_all(cls, verbose=False): + """Close all connections""" + connections = ConnectionManager.connections.copy() + for name, conn in connections.items(): + conn.close() + + if verbose: + display.message(f"Closing {name}") + + cls.connections = {} + + @classmethod + def _error_no_connection(cls): + """Error when there isn't any connection""" + err = UsageError("No active connection." + _suggest_fix(env_var=True)) + err.modify_exception = True + return err + + @classmethod + def display_current_connection(cls): + for conn in cls._get_connections(): + if conn["current"]: + alias = conn.get("alias") + if alias: + display.message(f"Running query in {alias!r}") + else: + display.message(f"Running query in {conn['url']!r}") + + @classmethod + def _get_connections(cls): + """ + Return a list of dictionaries + """ + connections = [] + + for key in sorted(cls.connections): + conn = cls.connections[key] + + is_current = conn == cls.current + + connections.append( + { + "current": is_current, + "key": key, + "url": conn.url, + "alias": conn.alias, + "connection": conn, + } + ) + + return connections + + @classmethod + def close_connection_with_descriptor(cls, descriptor): + """Close a connection with the given descriptor""" + if isinstance(descriptor, SQLAlchemyConnection): + conn = descriptor + else: + conn = cls.connections.get(descriptor) or cls.connections.get( + descriptor.lower() + ) + + if not conn: + raise exceptions.RuntimeError( + "Could not close connection because it was not found amongst these: %s" + % str(list(cls.connections.keys())) + ) + + if descriptor in cls.connections: + cls.connections.pop(descriptor) + else: + cls.connections.pop( + str(conn.metadata.bind.url) if IS_SQLALCHEMY_ONE else str(conn.url) + ) + + conn.close() + + @classmethod + def connections_table(cls): + """Returns the current connections as a table""" + connections = cls._get_connections() + + def map_values(d): + d["current"] = "*" if d["current"] else "" + d["alias"] = d["alias"] if d["alias"] else "" + return d + + return display.ConnectionsTable( + headers=["current", "url", "alias"], + rows_maps=[map_values(c) for c in connections], + ) + + @classmethod + def from_connect_str( + cls, connect_str=None, connect_args=None, creator=None, alias=None, config=None + ): + """Creates a new connection from a connection string""" + connect_args = connect_args or {} + + try: + if creator: + engine = sqlalchemy.create_engine( + connect_str, + connect_args=connect_args, + creator=creator, + ) + else: + engine = sqlalchemy.create_engine( + connect_str, + connect_args=connect_args, + ) + except (ModuleNotFoundError, NoSuchModuleError) as e: + suggestion_str = get_missing_package_suggestion_str(e) + raise exceptions.MissingPackageError( + "\n\n".join([str(e), suggestion_str]) + ) from e + except Exception as e: + raise _error_invalid_connection_info(e, connect_str) from e + + connection = SQLAlchemyConnection(engine, alias=alias, config=config) + connection.connect_args = connect_args + + return connection + + @classmethod + def load_default_connection_from_file_if_any(cls, config): + try: + connections_file = ConnectionsFile(path_to_file=config.dsn_filename) + except FileNotFoundError: + return + + default_url = connections_file.get_default_connection_url() + + if default_url is not None: + try: + cls.set( + default_url, + displaycon=False, + alias="default", + config=config, + ) + except Exception as e: + # this is executed during the magic initialization, we don't want + # to raise an exception here because it would prevent the magic + # from being used + display.message_warning( + "WARNING: Cannot start default connection from .ini file:" + f"\n\n{str(e)}" + ) + + +class AbstractConnection(abc.ABC): + """The abstract base class for all connections""" + + def __init__(self, alias): + self.alias = alias + + ConnectionManager.current = self + ConnectionManager.connections[alias] = self + + self._result_sets = ResultSetCollection() + + @abc.abstractproperty + def dialect(self): + """Returns a string with the SQL dialect name""" + pass + + @abc.abstractmethod + def raw_execute(self, query, parameters=None): + """Run the query without any pre-processing""" + pass + + @abc.abstractmethod + def _get_database_information(self): + """ + Get the dialect, driver, and database server version info of current + connection + """ + pass + + @abc.abstractmethod + def to_table(self, table_name, data_frame, if_exists, index, schema=None): + """Create a table from a pandas DataFrame""" + pass + + def close(self): + """Close the connection""" + for rs in self._result_sets: + # this might be None if it didn't run any query + if rs._sqlaproxy is not None: + rs._sqlaproxy.close() + + self._connection.close() + + def _get_sqlglot_dialect(self): + """ + Get the sqlglot dialect, this is similar to the dialect property except it + maps some dialects to their sqlglot equivalent. This method should only be + used for the transpilation process, for any other purposes, use the dialect + property. + + Returns + ------- + str + Available dialect in sqlglot package, see more: + https://github.com/tobymao/sqlglot/blob/main/sqlglot/dialects/dialect.py + """ + connection_info = self._get_database_information() + return DIALECT_NAME_SQLALCHEMY_TO_SQLGLOT_MAPPING.get( + connection_info["dialect"], connection_info["dialect"] + ) + + def _transpile_query(self, query): + """Translate the given SQL clause that's compatible to current connected + dialect by sqlglot + + Parameters + ---------- + query : str + Original SQL clause + + Returns + ------- + str + SQL clause that's compatible to current connected dialect + """ + write_dialect = self._get_sqlglot_dialect() + + # we write queries to be duckdb-compatible so we don't need to transpile + # them. Furthermore, sqlglot does not guarantee roundtrip conversion + # so calling transpile might break queries + if write_dialect == "duckdb": + return query + + try: + return ";\n".join( + [p.sql(dialect=write_dialect) for p in sqlglot.parse(query)] + ) + except Exception: + return query + + def _prepare_query(self, query, with_=None) -> str: + """ + Returns a textual representation of a query based + on the current connection + + Parameters + ---------- + query : str + SQL query + + with_ : string, default None + The key to use in with sql clause + """ + if with_: + query = self._resolve_cte(query, with_) + + query = self._transpile_query(query) + + return query + + def _resolve_cte(self, query, with_): + return str(store.render(query, with_=with_)) + + def execute(self, query, with_=None): + """ + Executes SQL query on a given connection + """ + query_prepared = self._prepare_query(query, with_) + return self.raw_execute(query_prepared) + + def is_use_backtick_template(self): + """Get if the dialect support backtick (`) syntax as identifier + + Returns + ------- + bool + Indicate if the dialect can use backtick identifier in the SQL clause + """ + cur_dialect = self._get_sqlglot_dialect() + if not cur_dialect: + return False + try: + return ( + "`" in sqlglot.Dialect.get_or_raise(cur_dialect).Tokenizer.IDENTIFIERS + ) + except (ValueError, AttributeError, TypeError): + return False + + def get_curr_identifiers(self) -> list: + """ + Returns list of identifiers for current connection + + Default identifiers are : ["", '"'] + """ + identifiers = ["", '"'] + try: + connection_info = self._get_database_information() + if connection_info: + cur_dialect = connection_info["dialect"] + identifiers_ = sqlglot.Dialect.get_or_raise( + cur_dialect + ).Tokenizer.IDENTIFIERS + + identifiers = [*set(identifiers + identifiers_)] + except ValueError: + pass + except AttributeError: + # this might be a DBAPI connection + pass + + return identifiers + + +# some dialects break when commit is used +_COMMIT_BLACKLIST_DIALECTS = ( + "athena", + "bigquery", + "clickhouse", + "ingres", + "mssql", + "teradata", + "vertica", +) + + +# TODO: the autocommit is read only during initialization, if the user changes it +# it won't have any effect +class SQLAlchemyConnection(AbstractConnection): + """Manages connections to databases + + Parameters + ---------- + engine: sqlalchemy.engine.Engine + The SQLAlchemy engine to use + """ + + is_dbapi_connection = False + + def __init__(self, engine, alias=None, config=None): + if IS_SQLALCHEMY_ONE: + self._metadata = sqlalchemy.MetaData(bind=engine) + else: + self._metadata = None + + # this returns a url with the password replaced by *** + self._url = ( + repr(sqlalchemy.MetaData(bind=engine).bind.url) + if IS_SQLALCHEMY_ONE + else repr(engine.url) + ) + + self._connection_sqlalchemy = self._start_sqlalchemy_connection( + engine, self._url + ) + + db_info = self._get_database_information() + self._dialect = db_info["dialect"] + self._driver = db_info["driver"] + + autocommit = True if config is None else config.autocommit + + if autocommit: + success = set_sqlalchemy_isolation_level(self._connection_sqlalchemy) + self._requires_manual_commit = not success + + # TODO: I noticed we don't have any unit tests for this + # even if autocommit is true, we should not use it for some dialects + self._requires_manual_commit = ( + all( + blacklisted_dialect not in str(self._dialect) + for blacklisted_dialect in _COMMIT_BLACKLIST_DIALECTS + ) + and self._requires_manual_commit + ) + else: + self._requires_manual_commit = False + + # TODO: we're no longer using this. I believe this is only used via the + # config.feedback option + self.name = default_alias_for_engine(engine) + + # calling init from AbstractConnection must be the last thing we do as it + # register the connection + super().__init__(alias=alias or self._url) + + @property + def dialect(self): + return self._dialect + + @property + def driver(self): + return self._driver + + def _connection_execute(self, query, parameters=None): + """Call the connection execute method + + Parameters + ---------- + query : str + SQL query + + parameters : dict, default None + Parameters to use in the query (:variable format) + """ + # we do not support multiple statements + if len(sqlparse.split(query)) > 1: + raise NotImplementedError("Only one statement is supported.") + + operation = partial(self._execute_with_parameters, query, parameters) + out = self._execute_with_error_handling(operation) + + if self._requires_manual_commit: + # Calling connection.commit() when using duckdb-engine will yield + # empty results if we commit after a SELECT or SUMMARIZE statement, + # see: https://github.com/Mause/duckdb_engine/issues/734. + if self.dialect == "duckdb": + no_commit = detect_duckdb_summarize_or_select(query) + if no_commit: + return out + + # in sqlalchemy 1.x, connection has no commit attribute + if IS_SQLALCHEMY_ONE: + # TODO: I moved this from run.py where we were catching all exceptions + # because some drivers do not support commits. However, I noticed + # that if I remove the try catch we get this error in SQLite: + # "cannot commit - no transaction is active", we need to investigate + # further, catching generic exceptions is not a good idea + try: + self._connection.execute("commit") + except Exception: + pass + else: + self._connection.commit() + + return out + + def _execute_with_parameters(self, query, parameters): + """Execute the query with the given parameters""" + if parameters == {}: + return self._connection.exec_driver_sql(query) + + parameters = parameters or {} + if IS_SQLALCHEMY_ONE: + out = self._connection.execute(sqlalchemy.text(query), **parameters) + else: + out = self._connection.execute( + sqlalchemy.text(query), parameters=parameters + ) + + return out + + def raw_execute(self, query, parameters=None, with_=None): + """Run the query without any preprocessing + + Parameters + ---------- + query : str + SQL query + + parameters : dict, default None + Parameters to use in the query. They should appear in the query with the + :name format (no quotes around them) + + with_ : list, default None + List of CTEs to use in the query + """ + # mssql with pyodbc does not support multiple open result sets, so we need + # to close them all before issuing a new query + if self.dialect == "mssql" and self.driver == "pyodbc": + self._result_sets.close_all() + + if with_: + query = self._resolve_cte(query, with_) + + query, quoted_named_parameters = escape_string_literals_with_colon_prefix(query) + + if quoted_named_parameters and parameters: + intersection = set(quoted_named_parameters) & set(parameters) + + if intersection: + intersection_ = ", ".join(sorted(intersection)) + warnings.warn( + f"The following variables are defined: {intersection_}. However " + "the parameters are quoted in the query, if you want to use " + "them as named parameters, remove the quotes.", + category=JupySQLQuotedNamedParametersWarning, + ) + + if parameters: + required_parameters = set(sqlalchemy.text(query).compile().params) + available_parameters = set(parameters) + missing_parameters = required_parameters - available_parameters + + if missing_parameters: + raise exceptions.InvalidQueryParameters( + "Cannot execute query because the following " + "variables are undefined: {}".format(", ".join(missing_parameters)) + ) + + return self._connection_execute(query, parameters) + else: + try: + return self._connection_execute(query, parameters) + except StatementError as e: + # add a more helpful message if the users passes :variable but + # the feature isn't enabled + if parameters is None: + named_params = find_named_parameters(query) + + if named_params: + named_params_ = ", ".join(named_params) + e.add_detail( + f"Your query contains named parameters ({named_params_}) " + 'but the named parameters feature is "warn". \nEnable it ' + 'with: %config SqlMagic.named_parameters="enabled" \nor ' + "disable it with: " + '%config SqlMagic.named_parameters="disabled"\n' + "For more info, see the docs: " + "https://jupysql.ploomber.io/en/latest/api/configuration.html#named-parameters" # noqa + ) + elif parameters == {}: + e.add_detail( + 'The named parameters feature is "disabled". ' + 'Enable it with: %config SqlMagic.named_parameters="enabled".\n' + "For more info, see the docs: " + "https://jupysql.ploomber.io/en/latest/api/configuration.html#named-parameters" # noqa + ) + raise + + def _execute_with_error_handling(self, operation): + """Execute a database operation and handle errors + + Parameters + ---------- + operation : callable + A callable that takes no parameters to execute a database operation + """ + rollback_needed = False + + try: + out = operation() + + # this is a generic error but we've seen it in postgres. it helps recover + # from a idle session timeout (happens in psycopg 2 and psycopg 3) + except PendingRollbackError: + warnings.warn( + "Found invalid transaction. JupySQL executed a ROLLBACK operation.", + category=JupySQLRollbackPerformed, + ) + rollback_needed = True + + # postgres error + except InternalError as e: + # message from psycopg 2 and psycopg 3 + message = ( + "current transaction is aborted, " + "commands ignored until end of transaction block" + ) + if type(e.orig).__name__ == "InFailedSqlTransaction" and message in str( + e.orig + ): + warnings.warn( + ( + "Current transaction is aborted. " + "JupySQL executed a ROLLBACK operation." + ), + category=JupySQLRollbackPerformed, + ) + rollback_needed = True + else: + raise + + # postgres error + except OperationalError as e: + # message from psycopg 2 and psycopg 3 + message = "server closed the connection unexpectedly" + + if type(e.orig).__name__ == "OperationalError" and message in str(e.orig): + warnings.warn( + "Server closed connection. JupySQL executed a ROLLBACK operation.", + category=JupySQLRollbackPerformed, + ) + rollback_needed = True + else: + raise + + except ProgrammingError as e: + # error when accessing previously non-existing file with duckdb using + # sqlalchemy 2.x + if "duckdb.InvalidInputException" in str(e) and "please ROLLBACK" in str(e): + rollback_needed = True + else: + raise + + if rollback_needed: + self._connection.rollback() + out = operation() + + return out + + def _get_database_information(self): + dialect = self._connection_sqlalchemy.dialect + + return { + "dialect": getattr(dialect, "name", None), + "driver": getattr(dialect, "driver", None), + # NOTE: this becomes available after calling engine.connect() + "server_version_info": getattr(dialect, "server_version_info", None), + } + + @property + def url(self): + """Returns an obfuscated connection string (password hidden)""" + return self._url + + @property + def connection_sqlalchemy(self): + """Returns the SQLAlchemy connection object""" + return self._connection_sqlalchemy + + @property + def _connection(self): + """Returns the SQLAlchemy connection object""" + return self._connection_sqlalchemy + + def close(self): + super().close() + + # NOTE: in SQLAlchemy 2.x, we need to call engine.dispose() to completely + # close the connection, calling connection.close() is not enough + self._connection.engine.dispose() + + @classmethod + @modify_exceptions + def _start_sqlalchemy_connection(cls, engine, connect_str): + try: + connection = engine.connect() + return connection + except OperationalError as e: + handle_exception(e) + except Exception as e: + raise _error_invalid_connection_info(e, connect_str) from e + + def to_table(self, table_name, data_frame, if_exists, index, schema=None): + """Create a table from a pandas DataFrame""" + operation = partial( + data_frame.to_sql, + table_name, + self.connection_sqlalchemy, + if_exists=if_exists, + index=index, + schema=schema, + ) + + try: + self._execute_with_error_handling(operation) + except ValueError: + raise exceptions.ValueError( + f"Table {table_name!r} already exists. Consider using " + "--persist-replace to drop the table before " + "persisting the data frame" + ) + + display.message_success(f"Success! Persisted {table_name} to the database.") + + +class DBAPIConnection(AbstractConnection): + """A connection object for generic DBAPI connections""" + + is_dbapi_connection = True + + def __init__(self, connection, alias=None, config=None): + # detect if the engine is a native duckdb connection + self._is_duckdb_native = _check_if_duckdb_dbapi_connection(connection) + + self._dialect = "duckdb" if self._is_duckdb_native else None + self._driver = None + + # TODO: implement the dialect blacklist and add unit tests + self._requires_manual_commit = True if config is None else config.autocommit + + self._connection = connection + self._connection_class_name = type(connection).__name__ + + # calling init from AbstractConnection must be the last thing we do as it + # register the connection + super().__init__(alias=alias or self._connection_class_name) + + # TODO: delete this + self.name = self._connection_class_name + + @property + def dialect(self): + return self._dialect + + @property + def driver(self): + return self._driver + + def raw_execute(self, query, parameters=None, with_=None): + """Run the query without any preprocessing + + Parameters + ---------- + query : str + SQL query + + parameters : dict, default None + This parameter is added for consistency with SQLAlchemy connections but + it is not used + """ + # we do not support multiple statements (this might actually work in some + # drivers but we need to add this for consistency with SQLAlchemyConnection) + if len(sqlparse.split(query)) > 1: + raise NotImplementedError("Only one statement is supported.") + + if with_: + query = self._resolve_cte(query, with_) + + cur = self._connection.cursor() + + # NOTE: this is a workaround for duckdb 1.1.0 and higher so we keep the + # existing behavior of being able to query data frames + if self._is_duckdb_native: + try: + cur.execute("SET python_scan_all_frames=true") + except Exception: + pass + + cur.execute(query) + + if self._requires_manual_commit: + self._connection.commit() + + return cur + + def _get_database_information(self): + return { + "dialect": self.dialect, + "driver": self._connection_class_name, + "server_version_info": None, + } + + @property + def url(self): + """Returns None since DBAPI connections don't have a url""" + return None + + @property + def connection_sqlalchemy(self): + """ + Raises NotImplementedError since DBAPI connections don't have a SQLAlchemy + connection object + """ + raise NotImplementedError( + "This feature is only available for SQLAlchemy connections" + ) + + def to_table(self, table_name, data_frame, if_exists, index, schema=None): + raise exceptions.NotImplementedError( + "--persist/--persist-replace is not available for DBAPI connections" + " (only available for SQLAlchemy connections)" + ) + + +class SparkConnectConnection(AbstractConnection): + is_dbapi_connection = False + + def __init__(self, connection, alias=None, config=None): + self._driver = None + + # TODO: implement the dialect blacklist and add unit tests + self._requires_manual_commit = True if config is None else config.autocommit + + self._connection = connection + self._connection_class_name = type(connection).__name__ + + # calling init from AbstractConnection must be the last thing we do as it + # register the connection + super().__init__(alias=alias or self._connection_class_name) + + self.name = self._connection_class_name + + @property + def dialect(self): + """Returns a string with the SQL dialect name""" + return "spark2" + + def raw_execute(self, query, parameters=None): + """Run the query without any pre-processing""" + return handle_spark_dataframe(self._connection.sql(query)) + + def _get_database_information(self): + """ + Get the dialect, driver, and database server version info of current + connection + """ + return { + "dialect": self.dialect, + "driver": self._connection_class_name, + "server_version_info": self._connection.version, + } + + @property + def url(self): + """Returns None since Spark connections don't have a url""" + return None + + @property + def connection_sqlalchemy(self): + """ + Raises NotImplementedError since Spark connections don't have a SQLAlchemy + connection object + """ + raise NotImplementedError( + "This feature is only available for SQLAlchemy connections" + ) + + def to_table(self, table_name, data_frame, if_exists, index, schema=None): + mode = ( + "overwrite" + if if_exists == "replace" + else "append" if if_exists == "append" else "error" + ) + self._connection.createDataFrame(data_frame).write.mode(mode).saveAsTable( + f"{schema}.{table_name}" if schema else table_name + ) + + def close(self): + """Override of the abstract close as SparkSession is usually + shared with pyspark""" + pass + + +def _check_if_duckdb_dbapi_connection(conn): + """Check if the connection is a native duckdb connection""" + # NOTE: duckdb defines df and pl to efficiently convert results to + # pandas.DataFrame and polars.DataFrame respectively + return hasattr(conn, "df") and hasattr(conn, "pl") + + +def _suggest_fix(env_var, connect_str=None): + """ + Returns an error message that we can display to the user + to tell them how to pass the connection string + """ + DEFAULT_PREFIX = "\n\n" + prefix = "" + + if connect_str: + matches = get_close_matches( + connect_str, list(ConnectionManager.connections), n=1 + ) + matches_db = get_close_matches( + connect_str.lower(), list(DB_DOCS_LINKS.keys()), cutoff=0.3, n=1 + ) + + if matches: + prefix = prefix + ( + "\n\nPerhaps you meant to use the existing " + f"connection: %sql {matches[0]!r}?" + ) + + if matches_db: + prefix = prefix + ( + f"\n\nPerhaps you meant to use the {matches_db[0]!r} db \n" + f"To find more information regarding connection: " + f"{DB_DOCS_LINKS[matches_db[0]]}\n\n" + ) + + if not matches and not matches_db: + prefix = DEFAULT_PREFIX + else: + matches = None + matches_db = None + prefix = DEFAULT_PREFIX + + connection_string = ( + "Pass a valid connection string:\n " + "Example: %sql postgresql://username:password@hostname/dbname" + ) + + suffix = "To fix it:" if not matches else "Otherwise, try the following:" + options = [f"{prefix}{suffix}", connection_string] + + keys = list(ConnectionManager.connections.keys()) + + if keys: + keys_ = ",".join(repr(k) for k in keys) + options.append( + f"Pass a connection key (one of: {keys_})" + f"\n Example: %sql {keys[0]!r}" + ) + + if env_var: + options.append("Set the environment variable $DATABASE_URL") + + if len(options) >= 3: + options.insert(-1, "OR") + + options.append(f"For more details, see: {PLOOMBER_DOCS_LINK_STR}") + + return "\n\n".join(options) + + +def is_pep249_compliant(conn): + """ + Checks if given connection object complies with PEP 249 + """ + pep249_methods = [ + "close", + "commit", + # "rollback", + # "cursor", + # PEP 249 doesn't require the connection object to have + # a cursor method strictly + # ref: https://peps.python.org/pep-0249/#id52 + ] + + for method_name in pep249_methods: + # Checking whether the connection object has the method + # and if it is callable + if not hasattr(conn, method_name) or not callable(getattr(conn, method_name)): + return False + + return True + + +def is_spark(conn): + """Check if it is a SparkSession by checking for available methods""" + + sparksession_methods = [ + "table", + "read", + "createDataFrame", + "sql", + "stop", + "catalog", + "version", + ] + for method_name in sparksession_methods: + # Checking whether the connection object has the method + if not hasattr(conn, method_name): + return False + + return True + + +def default_alias_for_engine(engine): + if not engine.url.username: + # keeping this for compatibility + return str(engine.url) + + return f"{engine.url.username}@{engine.url.database}" + + +def set_sqlalchemy_isolation_level(conn): + """ + Sets the autocommit setting for a database connection using SQLAlchemy. + This better handles some edge cases than calling .commit() on the connection but + not all drivers support it. + """ + try: + conn.execution_options(isolation_level="AUTOCOMMIT") + return True + except Exception: + return False + + +def detect_duckdb_summarize_or_select(query): + """ + Checks if the SQL query is a DuckDB SELECT or SUMMARIZE statement. + + Note: + Assumes there is only one SQL statement in the query. + """ + statements = sqlparse.parse(query) + if statements: + if len(statements) > 1: + raise NotImplementedError("Multiple statements are not supported") + stype = statements[0].get_type() + if stype == "SELECT": + return True + elif stype == "UNKNOWN": + # Further analysis is required + sql_stripped = sqlparse.format(query, strip_comments=True) + words = sql_stripped.split() + return len(words) > 0 and ( + words[0].lower() == "from" or words[0].lower() == "summarize" + ) + return False + + +atexit.register(ConnectionManager.close_all, verbose=True) diff --git a/src/sql/connection/error_handling.py b/src/sql/connection/error_handling.py new file mode 100644 index 000000000..1150fffd1 --- /dev/null +++ b/src/sql/connection/error_handling.py @@ -0,0 +1,18 @@ +import shutil + + +_CONDA_INSTALLED = shutil.which("conda") is not None +_PREFER_CONDA = {"psycopg2"} + + +def install_command(package): + # special case for psycopg2 + if package == "psycopg2" and not _CONDA_INSTALLED: + package = "psycopg2-binary" + + if _CONDA_INSTALLED and package in _PREFER_CONDA: + template = "%conda install {package} -c conda-forge --yes --quiet" + else: + template = "%pip install {package} --quiet" + + return template.format(package=package) diff --git a/src/sql/display.py b/src/sql/display.py new file mode 100644 index 000000000..172c88d53 --- /dev/null +++ b/src/sql/display.py @@ -0,0 +1,135 @@ +""" +A module to display confirmation messages and contextual information to the user +""" + +import html + +from prettytable import PrettyTable +from IPython.display import display, HTML +from IPython import get_ipython + + +class Table: + """Provides a txt and html representation of tabular data""" + + TITLE = "" + + def __init__(self, headers, rows) -> None: + self._headers = headers + self._rows = rows + self._table = PrettyTable() + self._table.field_names = headers + + for row in rows: + self._table.add_row(row) + + self._table_html = self._table.get_html_string() + self._table_txt = self._table.get_string() + + def __repr__(self) -> str: + return self.TITLE + "\n" + self._table_txt + + def _repr_html_(self) -> str: + return self.TITLE + "\n" + self._table_html + + +class ConnectionsTable(Table): + TITLE = "Active connections:" + + def __init__(self, headers, rows_maps) -> None: + def get_values(d): + d = {k: v for k, v in d.items() if k not in {"connection", "key"}} + return list(d.values()) + + rows = [get_values(r) for r in rows_maps] + + self._mapping = {} + + for row in rows_maps: + self._mapping[row["key"]] = row["connection"] + + super().__init__(headers=headers, rows=rows) + + def __getitem__(self, key: str): + """ + This method is provided for backwards compatibility. Before + creating ConnectionsTable, `%sql --connections` returned a dictionary, + hence users could retrieve connections using __getitem__. Note that this + was undocumented so we might decide to remove it in the future. + """ + return self._mapping[key] + + def __iter__(self): + """Also provided for backwards compatibility""" + for key in self._mapping: + yield key + + def __len__(self): + """Also provided for backwards compatibility""" + return len(self._mapping) + + +class Message: + """Message for the user""" + + def __init__(self, message, style=None) -> None: + if isinstance(message, str): + self._message = message + elif isinstance(message, list): + self._message = " ".join([str(msg) for msg in message]) + # escape html and replace newlines with
tags so newlines are displayed + self._message_html = html.escape(self._message).replace("\n\n", "
") + self._style = "" or style + + def _repr_html_(self): + return f'{self._message_html}' + + def __repr__(self) -> str: + return self._message + + +class Link: + """Formatting of link depending on the running environment""" + + def __init__(self, text, url): + self.text = text + self.url = url + + def __str__(self): + if get_ipython(): + return f'{self.text}' + else: + return f"{self.text} ({self.url})" + + +def message(message): + """Display a generic message""" + display(Message(message)) + + +def message_success(message): + """Display a success message""" + display(Message(message, style="color: green")) + + +def message_warning(message): + """Display a warning message""" + display( + Message( + message, + style="background-color:#fff3cd;color:#d39e00", + ) + ) + + +def message_html(message): + """Display a message with link""" + if get_ipython(): + display(HTML(str(Message(message)))) + else: + display(Message(message)) + + +def table(headers, rows): + """Display a table""" + display(Table(headers, rows)) diff --git a/src/sql/error_handler.py b/src/sql/error_handler.py new file mode 100644 index 000000000..cd1dfb3cd --- /dev/null +++ b/src/sql/error_handler.py @@ -0,0 +1,115 @@ +from sql import display +from sql import util +from sql.store import get_all_keys +from sql.exceptions import RuntimeError, TableNotFoundError + + +ORIGINAL_ERROR = "\nOriginal error message from DB driver:\n" +CTE_MSG = ( + "If using snippets, you may pass the --with argument explicitly.\n" + "For more details please refer: " + "https://jupysql.ploomber.io/en/latest/compose.html#with-argument" +) +POSTGRES_MSG = """\nLooks like you have run into some issues. + Review our DB connection via URL strings guide: + https://jupysql.ploomber.io/en/latest/connecting.html . + Using Ubuntu? Check out this guide: " + https://help.ubuntu.com/community/PostgreSQL#fe_sendauth:_ + no_password_supplied\n""" + + +def _snippet_typo_error_message(query): + """Function to generate message for possible + snippets if snippet name in user query is a + typo + """ + if query: + tables = util.extract_tables_from_query(query) + for table in tables: + suggestions = util.find_close_match(table, get_all_keys()) + err_message = f"There is no table with name {table!r}." + if len(suggestions) > 0: + # If snippet is found in suggestions, this snippet + # must not be misspelled (a different table name is) + # so we don't show this message. + if table in suggestions: + continue + suggestions_message = util.get_suggestions_message(suggestions) + return f"{err_message}{suggestions_message}" + return "" + + +def _detailed_message_with_error_type(error, query): + """Function to generate descriptive error message. + Currently it handles syntax error messages, table not found messages + and password issue when connecting to postgres + """ + original_error = str(error) + syntax_error_substrings = [ + "syntax error", + "error in your sql syntax", + "incorrect syntax", + "invalid sql", + "syntax_error", + ] + not_found_substrings = [ + r"(\btable with name\b).+(\bdoes not exist\b)", + r"(\btable\b).+(\bdoes not exist\b)", + r"(\bobject\b).+(\bdoes not exist\b)", + r"(\brelation\b).+(\bdoes not exist\b)", + r"(\btable\b).+(\bdoesn't exist\b)", + "not found", + "could not find", + "no such table", + ] + if util.if_substring_exists(original_error.lower(), syntax_error_substrings): + return f"{CTE_MSG}\n\n{ORIGINAL_ERROR}{original_error}\n", RuntimeError + elif util.if_substring_exists(original_error.lower(), not_found_substrings): + typo_err_msg = _snippet_typo_error_message(query) + if typo_err_msg: + return ( + f"{CTE_MSG}\n\n{typo_err_msg}\n\n" + f"{ORIGINAL_ERROR}{original_error}\n", + TableNotFoundError, + ) + else: + return ( + f"{CTE_MSG}\n\n{ORIGINAL_ERROR}{original_error}\n", + RuntimeError, + ) + elif "fe_sendauth: no password supplied" in original_error: + return f"{POSTGRES_MSG}\n{ORIGINAL_ERROR}{original_error}\n", RuntimeError + return None, None + + +def _display_error_msg_with_trace(error, message): + """Displays the detailed error message and prints + original stack trace as well.""" + if message is not None: + display.message(message) + error.modify_exception = True + raise error + + +def _raise_error(error, message, error_type): + """Raise specific error from the detailed message. If detailed + message is None reraise original error""" + if message is not None: + raise error_type(message) from error + else: + raise RuntimeError(str(error)) from error + + +def handle_exception(error, query=None, short_error=True): + """ + This function is the entry point for detecting error type + and handling it accordingly. + """ + if util.is_sqlalchemy_error(error) or util.is_non_sqlalchemy_error(error): + detailed_message, error_type = _detailed_message_with_error_type(error, query) + if short_error: + _raise_error(error, detailed_message, error_type) + else: + _display_error_msg_with_trace(error, detailed_message) + else: + raise error diff --git a/src/sql/exceptions.py b/src/sql/exceptions.py new file mode 100644 index 000000000..2f804dad2 --- /dev/null +++ b/src/sql/exceptions.py @@ -0,0 +1,55 @@ +""" +In most scenarios, users don't care about the full Python traceback because it's +irrelevant to them (they run SQL, not Python code). Hence, when raising errors, +we only display the error message. This is possible via IPython.core.error.UsageError: +IPython/Jupyter automatically detect this error and hide the traceback. +Unfortunately, IPython.core.error.UsageError isn't the most appropriate error type for +all scenarios, so we define our own error types here. The main caveat is that due to a +bug in IPython (https://github.com/ipython/ipython/issues/14024), subclassing +IPython.core.error.UsageError doesn't work, so `exception_factory` is a workaround +to create new errors that are IPython.core.error.UsageError but with a different name. + +""" + +from IPython.core import error + + +def exception_factory(name): + def _error(message): + exc = error.UsageError(message) + exc.error_type = name + # this attribute will allow the @modify_exceptions decorator to add the + # community link + exc.modify_exception = True + return exc + + return _error + + +# raise it when there's an issue with the user's input in a magic. e.g., missing an +# argument +UsageError = exception_factory("UsageError") + +# raise it when a user wants to use a feature that requires an optional dependency +MissingPackageError = exception_factory("MissingPackageError") + +# the following exceptions should be called instead of the Python built-in ones, +# for guidelines on when to use them: +# https://docs.python.org/3/library/exceptions.html#bltin-exceptions +TypeError = exception_factory("TypeError") +RuntimeError = exception_factory("RuntimeError") +ValueError = exception_factory("ValueError") +KeyError = exception_factory("KeyError") +FileNotFoundError = exception_factory("FileNotFoundError") +NotImplementedError = exception_factory("NotImplementedError") + +# The following are internal exceptions that should not be raised directly + +# raised internally when the user chooses a table that doesn't exist +TableNotFoundError = exception_factory("TableNotFoundError") + +# raise it when there is an error in parsing the configuration file +ConfigurationError = exception_factory("ConfigurationError") + + +InvalidQueryParameters = exception_factory("InvalidQueryParameters") diff --git a/src/sql/ggplot/__init__.py b/src/sql/ggplot/__init__.py new file mode 100644 index 000000000..73b4a212c --- /dev/null +++ b/src/sql/ggplot/__init__.py @@ -0,0 +1,7 @@ +from sql.ggplot.ggplot import ggplot +from sql.ggplot.aes import aes +from sql.ggplot.geom import geom_boxplot, geom_histogram +from sql.ggplot.facet_wrap import facet_wrap + + +__all__ = ["ggplot", "aes", "geom_boxplot", "geom_histogram", "facet_wrap"] diff --git a/src/sql/ggplot/aes.py b/src/sql/ggplot/aes.py new file mode 100644 index 000000000..43a9b3d10 --- /dev/null +++ b/src/sql/ggplot/aes.py @@ -0,0 +1,20 @@ +class aes: + """ + Aesthetic mappings + + Parameters + ---------- + x: str | list + x aesthetic mapping + + fill : str + The inner color of a shape + + color : str, default 'None' + The edge color of a shape + """ + + def __init__(self, x=None, fill=None, color=None): + self.x = x + self.fill = fill + self.color = color diff --git a/src/sql/ggplot/facet_wrap.py b/src/sql/ggplot/facet_wrap.py new file mode 100644 index 000000000..e6c0d422f --- /dev/null +++ b/src/sql/ggplot/facet_wrap.py @@ -0,0 +1,49 @@ +from jinja2 import Template +import math +import sql.connection +from sql.util import enclose_table_with_double_quotations + + +class facet: + + def get_facet_values(self, table, column, with_): + conn = sql.connection.ConnectionManager.current + table = enclose_table_with_double_quotations(table, conn) + template = Template( + """ + SELECT + distinct ({{column}}) + FROM {{table}} + ORDER BY {{column}} + """ + ) + query = template.render(table=table, column=column) + + values = conn.execute(query, with_).fetchall() + # Added to make histogram more inclusive to NULLs + # Filter out NULL values + # If value[0] is NULL we skip it + + values = [value for value in values if value[0] is not None] + n_plots = len(values) + n_cols = len(values) if len(values) < 3 else 3 + n_rows = math.ceil(n_plots / n_cols) + return values, n_rows, n_cols + + +class facet_wrap(facet): + """ + Splits a plot into a matrix of panels + + Parameters + ---------- + facet : str + Column to groupby and plot on different panels. + """ + + def __init__(self, facet: str, legend=True): + self.facet = facet + self.legend = legend + + def __add__(self, other): + return other.__add__(other) diff --git a/src/sql/ggplot/geom/__init__.py b/src/sql/ggplot/geom/__init__.py new file mode 100644 index 000000000..b0fad87e0 --- /dev/null +++ b/src/sql/ggplot/geom/__init__.py @@ -0,0 +1,4 @@ +from sql.ggplot.geom.geom_boxplot import geom_boxplot +from sql.ggplot.geom.geom_histogram import geom_histogram + +__all__ = ["geom_boxplot", "geom_histogram"] diff --git a/src/sql/ggplot/geom/geom.py b/src/sql/ggplot/geom/geom.py new file mode 100644 index 000000000..175f7d65c --- /dev/null +++ b/src/sql/ggplot/geom/geom.py @@ -0,0 +1,23 @@ +from abc import abstractmethod + + +class geom: + """ + Base class of all geom + """ + + def __init__(self): + pass + + def __add__(self, gg): + return gg + + def __radd__(self, gg): + return gg + self + + @abstractmethod + def draw(self, gg): + """ + Draws plot + """ + pass diff --git a/src/sql/ggplot/geom/geom_boxplot.py b/src/sql/ggplot/geom/geom_boxplot.py new file mode 100644 index 000000000..16a36135a --- /dev/null +++ b/src/sql/ggplot/geom/geom_boxplot.py @@ -0,0 +1,22 @@ +from sql import plot +from sql.ggplot.geom.geom import geom + + +class geom_boxplot(geom): + """ + Boxplot + """ + + def __init__(self): + pass + + def draw(self, gg, ax=None): + plot.boxplot( + table=gg.table, + column=gg.mapping.x, + conn=gg.conn, + with_=gg.with_, + ax=ax or gg.axs[0], + ) + + return gg diff --git a/src/sql/ggplot/geom/geom_histogram.py b/src/sql/ggplot/geom/geom_histogram.py new file mode 100644 index 000000000..5cf353f3e --- /dev/null +++ b/src/sql/ggplot/geom/geom_histogram.py @@ -0,0 +1,54 @@ +from sql import plot +from sql.ggplot.geom.geom import geom + + +class geom_histogram(geom): + """ + Histogram plot + + Parameters + ---------- + bins: int + Number of bins + + fill : str + Create a stacked graph which is a combination of + 'x' and 'fill' + + cmap : str, default 'viridis + Apply a color map to the stacked graph + + breaks : list + Divide bins with custom intervals + + binwidth : int or float + Width of each bin + """ + + def __init__( + self, bins=None, fill=None, cmap=None, breaks=None, binwidth=None, **kwargs + ): + self.bins = bins + self.fill = fill + self.cmap = cmap + self.breaks = breaks + self.binwidth = binwidth + super().__init__(**kwargs) + + def draw(self, gg, ax=None, facet=None): + plot.histogram( + table=gg.table, + column=gg.mapping.x, + cmap=self.cmap, + bins=self.bins, + conn=gg.conn, + with_=gg.with_, + category=self.fill, + color=gg.mapping.fill, + edgecolor=gg.mapping.color, + facet=facet, + ax=ax or gg.axs[0], + breaks=self.breaks, + binwidth=self.binwidth, + ) + return gg diff --git a/src/sql/ggplot/ggplot.py b/src/sql/ggplot/ggplot.py new file mode 100644 index 000000000..1a6fd7b29 --- /dev/null +++ b/src/sql/ggplot/ggplot.py @@ -0,0 +1,87 @@ +from sql.ggplot.aes import aes +from sql.ggplot.geom.geom import geom +from sql.ggplot.facet_wrap import facet_wrap +import matplotlib as mpl +import matplotlib.pyplot as plt +from ploomber_core.dependencies import requires + + +def _expand_to_multipanel_ax(figure, ax_to_clear=None): + figure.subplots_adjust(hspace=0.7, wspace=0.5) + if ax_to_clear: + ax_to_clear.remove() + + +def _create_single_panel_ax(): + figure, ax = plt.subplots() + axs = [ax] + return figure, axs + + +@requires(["matplotlib"]) +class ggplot: + """ + Create a new ggplot + """ + + figure: mpl.figure.Figure + axs: list + + def __init__(self, table, mapping: aes = None, conn=None, with_=None) -> None: + self.table = table + self.with_ = [with_] if with_ else None + self.mapping = mapping if mapping is not None else aes() + self.conn = conn + + figure, axs = _create_single_panel_ax() + + self.axs = axs + self.figure = figure + + def __add__(self, other) -> "ggplot": + """ + Add to ggplot + """ + self._draw(other) + + return self + + def __iadd__(self, other): + return other.__add__(self) + + def _draw(self, other) -> mpl.figure.Figure: + """ + Draws plot + """ + if isinstance(other, geom): + self.geom = other + other.draw(self) + + if isinstance(other, facet_wrap): + _expand_to_multipanel_ax(self.figure, ax_to_clear=self.axs[0]) + + values, n_rows, n_cols = other.get_facet_values( + self.table, other.facet, with_=self.with_ + ) + + for i, value in enumerate(values): + ax_ = self.figure.add_subplot(n_rows, n_cols, i + 1) + facet_key_val = {"key": other.facet, "value": value[0]} + self.geom.draw(self, ax_, facet_key_val) + handles, labels = ax_.get_legend_handles_labels() + ax_.set_title(value[0]) + ax_.tick_params(axis="both", labelsize=7) + # reverses legend order so alphabetically first goes on top + ax_.legend(handles[::-1], labels[::-1], prop={"size": 10}) + if other.legend is False: + plt.legend("", frameon=False) + self.axs.append(ax_) + + return self.figure + + def get_base(self, object) -> str: + """ + Returns the base class of an object + """ + for base in object.__class__.__bases__: + return base.__name__ diff --git a/src/sql/inspect.py b/src/sql/inspect.py new file mode 100644 index 000000000..250678930 --- /dev/null +++ b/src/sql/inspect.py @@ -0,0 +1,670 @@ +from sqlalchemy import inspect +from prettytable import PrettyTable +from ploomber_core.exceptions import modify_exceptions +from sql.connection import ConnectionManager +from sql import exceptions +import math +from sql import util +from sql.store import get_all_keys +from IPython.core.display import HTML +import uuid + + +def _get_inspector(conn): + if conn: + return inspect(conn) + + if not ConnectionManager.current: + raise exceptions.RuntimeError("No active connection") + else: + return inspect(ConnectionManager.current.connection_sqlalchemy) + + +class DatabaseInspection: + def __repr__(self) -> str: + return self._table_txt + + def _repr_html_(self) -> str: + return self._table_html + + +class Tables(DatabaseInspection): + """ + Displays the tables in a database + """ + + def __init__(self, schema=None, conn=None) -> None: + inspector = _get_inspector(conn) + + self._table = PrettyTable() + self._table.field_names = ["Name"] + + for row in inspector.get_table_names(schema=schema): + self._table.add_row([row]) + + self._table_html = self._table.get_html_string() + self._table_txt = self._table.get_string() + + +def _add_missing_keys(keys, mapping): + """ + Return a copy of `mapping` with all the missing `keys`, setting the + value as an empty string + """ + return {key: mapping.get(key, "") for key in keys} + + +# we're assuming there's one row that contains all keys, I tested this and worked fine +# my initial implementation just took all keys that appeared in "rows" but then order +# isn't preserved, which is important for user experience +def _get_row_with_most_keys(rows): + """ + Get the row with the maximum length from the nested lists in `rows` + """ + if not rows: + return list() + + max_idx, max_ = None, 0 + + for idx, row in enumerate(rows): + if len(row) > max_: + max_idx = idx + max_ = len(row) + + if max_idx is None: + return list() + + return list(rows[max_idx]) + + +def _is_numeric(value): + """Check if a column has numeric and not categorical datatype""" + try: + if isinstance(value, bool): + return False + float(value) # Try to convert the value to float + return True + except (TypeError, ValueError): + return False + + +def _is_numeric_as_str(column, value): + """Check if a column contains numerical data stored as `str`""" + try: + if isinstance(value, str) and _is_numeric(value): + return True + return False + except ValueError: + pass + + +def _generate_column_styles( + column_indices, unique_id, background_color="#FFFFCC", text_color="black" +): + """ + Generate CSS styles to change the background-color of all columns + with data-type mismatch. + + Parameters + ---------- + column_indices (list): List of column indices with data-type mismatch. + unique_id (str): Unique ID for the current table. + background_color (str, optional): Background color for the mismatched columns. + text_color (str, optional): Text color for the mismatched columns. + + Returns: + str: HTML style tags containing the CSS styles for the mismatched columns. + """ + + styles = "" + for index in column_indices: + styles = f"""{styles} + #profile-table-{unique_id} td:nth-child({index + 1}) {{ + background-color: {background_color}; + color: {text_color}; + }} + """ + return f"" + + +def _generate_message(column_indices, columns): + """Generate a message indicating all columns with a datatype mismatch""" + message = "Columns " + for c in column_indices: + col = columns[c - 1] + message = f"{message}{col}" + message = ( + f"{message} have a datatype mismatch -> numeric values stored as a string." + ) + message = f"{message}
Cannot calculate mean/min/max/std/percentiles" + return message + + +def _assign_column_specific_stats(col_stats, is_numeric): + """ + Assign NaN values to categorical/numerical specific statistic. + + Parameters + ---------- + col_stats (dict): Dictionary containing column statistics. + is_numeric (bool): Flag indicating whether the column is numeric or not. + + Returns: + dict: Updated col_stats dictionary. + """ + categorical_stats = ["top", "freq"] + numerical_stats = ["mean", "min", "max", "std", "25%", "50%", "75%"] + + if is_numeric: + for stat in categorical_stats: + col_stats[stat] = math.nan + else: + for stat in numerical_stats: + col_stats[stat] = math.nan + + return col_stats + + +@modify_exceptions +class Columns(DatabaseInspection): + """ + Represents the columns in a database table + """ + + def __init__(self, name, schema, conn=None) -> None: + is_table_exists(name, schema) + + inspector = _get_inspector(conn) + + # this returns a list of dictionaries. e.g., + # [{"name": "column_a", "type": "INT"} + # {"name": "column_b", "type": "FLOAT"}] + if not schema and "." in name: + schema, name = name.split(".") + columns = inspector.get_columns(name, schema) or [] + + self._table = PrettyTable() + self._table.field_names = _get_row_with_most_keys(columns) + + for row in columns: + self._table.add_row( + list(_add_missing_keys(self._table.field_names, row).values()) + ) + + self._table_html = self._table.get_html_string() + self._table_txt = self._table.get_string() + + +@modify_exceptions +class TableDescription(DatabaseInspection): + """ + Generates descriptive statistics. + + -------------------------------------- + Descriptive statistics are: + + Count - Number of all not None values + + Mean - Mean of the values + + Max - Maximum of the values in the object. + + Min - Minimum of the values in the object. + + STD - Standard deviation of the observations + + 25h, 50h and 75h percentiles + + Unique - Number of not None unique values + + Top - The most frequent value + + Freq - Frequency of the top value + + ------------------------------------------ + Following statistics will be calculated for :- + + Categorical columns - [Count, Unique, Top, Freq] + + Numerical columns - [Count, Unique, Mean, Max, Min, + STD, 25h, 50h and 75h percentiles] + + """ + + def __init__(self, table_name, schema=None) -> None: + is_table_exists(table_name, schema) + + if schema: + table_name = f"{schema}.{table_name}" + + conn = ConnectionManager.current + + columns_query_result = conn.raw_execute(f"SELECT * FROM {table_name} WHERE 1=0") + if ConnectionManager.current.is_dbapi_connection: + columns = [i[0] for i in columns_query_result.description] + else: + columns = columns_query_result.keys() + + table_stats = dict({}) + columns_to_include_in_report = set() + columns_with_styles = [] + message_check = False + + for i, column in enumerate(columns): + table_stats[column] = dict() + + # check the datatype of a column + try: + result = ConnectionManager.current.raw_execute( + f"""SELECT {column} FROM {table_name} LIMIT 1""" + ).fetchone() + + value = result[0] + is_numeric = isinstance(value, (int, float)) or ( + isinstance(value, str) and _is_numeric(value) + ) + except ValueError: + is_numeric = True + + if _is_numeric_as_str(column, value): + columns_with_styles.append(i + 1) + message_check = True + # Note: index is reserved word in sqlite + try: + result_col_freq_values = ConnectionManager.current.raw_execute( + f"""SELECT DISTINCT {column} as top, + COUNT({column}) as frequency FROM {table_name} + GROUP BY top ORDER BY frequency Desc""", + ).fetchall() + + table_stats[column]["freq"] = result_col_freq_values[0][1] + table_stats[column]["top"] = result_col_freq_values[0][0] + + columns_to_include_in_report.update(["freq", "top"]) + + except Exception: + pass + + try: + # get all non None values, min, max and avg. + result_value_values = ConnectionManager.current.raw_execute( + f""" + SELECT MIN({column}) AS min, + MAX({column}) AS max, + COUNT({column}) AS count + FROM {table_name} + WHERE {column} IS NOT NULL + """, + ).fetchall() + + columns_to_include_in_report.update(["count", "min", "max"]) + table_stats[column]["count"] = result_value_values[0][2] + + table_stats[column]["min"] = round(result_value_values[0][0], 4) + table_stats[column]["max"] = round(result_value_values[0][1], 4) + + columns_to_include_in_report.update(["count", "min", "max"]) + + except Exception: + pass + + try: + # get unique values + result_value_values = ConnectionManager.current.raw_execute( + f""" + SELECT + COUNT(DISTINCT {column}) AS unique_count + FROM {table_name} + WHERE {column} IS NOT NULL + """, + ).fetchall() + table_stats[column]["unique"] = result_value_values[0][0] + columns_to_include_in_report.update(["unique"]) + except Exception: + pass + + try: + results_avg = ConnectionManager.current.raw_execute( + f""" + SELECT AVG({column}) AS avg + FROM {table_name} + WHERE {column} IS NOT NULL + """, + ).fetchall() + + columns_to_include_in_report.update(["mean"]) + table_stats[column]["mean"] = format(float(results_avg[0][0]), ".4f") + + except Exception: + table_stats[column]["mean"] = math.nan + + # These keys are numeric and work only on duckdb + special_numeric_keys = ["std", "25%", "50%", "75%"] + + try: + # Note: stddev_pop and PERCENTILE_DISC will work only on DuckDB + result = ConnectionManager.current.raw_execute( + f""" + SELECT + stddev_pop({column}) as key_std, + percentile_disc(0.25) WITHIN GROUP + (ORDER BY {column}) as key_25, + percentile_disc(0.50) WITHIN GROUP + (ORDER BY {column}) as key_50, + percentile_disc(0.75) WITHIN GROUP + (ORDER BY {column}) as key_75 + FROM {table_name} + """, + ).fetchall() + + columns_to_include_in_report.update(special_numeric_keys) + for i, key in enumerate(special_numeric_keys): + # r_key = f'key_{key.replace("%", "")}' + table_stats[column][key] = format(float(result[0][i]), ".4f") + + except TypeError: + # for non numeric values + for key in special_numeric_keys: + table_stats[column][key] = math.nan + + except Exception as e: + # We tried to apply numeric function on + # non numeric value, i.e: DateTime + if "duckdb.BinderException" or "add explicit type casts" in str(e): + for key in special_numeric_keys: + table_stats[column][key] = math.nan + + # Failed to run sql command/func (e.g stddev_pop). + # We ignore the cell stats for such case. + pass + + table_stats[column] = _assign_column_specific_stats( + table_stats[column], is_numeric + ) + + self._table = PrettyTable() + self._table.field_names = [" "] + list(table_stats.keys()) + + custom_order = [ + "count", + "unique", + "top", + "freq", + "mean", + "std", + "min", + "25%", + "50%", + "75%", + "max", + ] + + for row in custom_order: + if row.lower() in [r.lower() for r in columns_to_include_in_report]: + values = [row] + for column in table_stats: + if row in table_stats[column]: + value = table_stats[column][row] + else: + value = "" + # value = util.convert_to_scientific(value) + values.append(value) + + self._table.add_row(values) + + unique_id = str(uuid.uuid4()).replace("-", "") + column_styles = _generate_column_styles(columns_with_styles, unique_id) + + if message_check: + message_content = _generate_message(columns_with_styles, list(columns)) + warning_background = "#FFFFCC" + warning_title = "Warning: " + else: + message_content = "" + warning_background = "white" + warning_title = "" + + current = ConnectionManager.current + database = current.dialect + db_driver = current._get_database_information()["driver"] + + if database and "duckdb" in database: + db_message = "" + else: + db_message = f"""Following statistics are not available in + {db_driver}: STD, 25%, 50%, 75%""" + + db_html = ( + f"
" + f" {db_message}" + "
" + ) + + message_html = ( + f"
" + f"{warning_title} {message_content}" + "
" + ) + + # Inject css to html to make first column sticky + sticky_column_css = """""" + self._table_html = HTML( + db_html + + sticky_column_css + + column_styles + + self._table.get_html_string( + attributes={"id": f"profile-table-{unique_id}"} + ) + + message_html + ).__html__() + + self._table_txt = self._table.get_string() + + +def get_table_names(schema=None): + """Get table names for a given connection""" + return Tables(schema) + + +def get_columns(name, schema=None): + """Get column names for a given connection""" + return Columns(name, schema) + + +def get_table_statistics(name, schema=None): + """Get table statistics for a given connection. + + For all data types the results will include `count`, `mean`, `std`, `min` + `max`, `25`, `50` and `75` percentiles. It will also include `unique`, `top` + and `freq` statistics. + """ + return TableDescription(name, schema=schema) + + +def get_schema_names(conn=None): + """Get list of schema names for a given connection""" + inspector = _get_inspector(conn) + return inspector.get_schema_names() + + +def support_only_sql_alchemy_connection(command): + """ + Throws a sql.exceptions.RuntimeError if connection is not SQLAlchemy + """ + if ConnectionManager.current.is_dbapi_connection: + raise exceptions.RuntimeError( + f"{command} is only supported with SQLAlchemy " + "connections, not with DBAPI connections" + ) + + +def _is_table_exists(table: str, conn) -> bool: + """ + Runs a SQL query to check if table exists + """ + if not conn: + conn = ConnectionManager.current + + identifiers = conn.get_curr_identifiers() + + for iden in identifiers: + if isinstance(iden, tuple): + query = "SELECT * FROM {0}{1}{2} WHERE 1=0".format(iden[0], table, iden[1]) + else: + query = "SELECT * FROM {0}{1}{0} WHERE 1=0".format(iden, table) + try: + conn.execute(query) + return True + except Exception: + pass + + return False + + +def _get_list_of_existing_tables() -> list: + """ + Returns a list of table names for a given connection + """ + tables = [] + tables_rows = get_table_names()._table + for row in tables_rows: + table_name = row.get_string(fields=["Name"], border=False, header=False).strip() + + tables.append(table_name) + return tables + + +def is_table_exists( + table: str, + schema: str = None, + ignore_error: bool = False, + conn=None, +) -> bool: + """ + Checks if a given table exists for a given connection + + Parameters + ---------- + table: str + Table name + + schema: str, default None + Schema name + + ignore_error: bool, default False + Avoid raising a ValueError + """ + if table is None: + if ignore_error: + return False + else: + raise exceptions.UsageError("Table cannot be None") + if not ConnectionManager.current: + raise exceptions.RuntimeError("No active connection") + if not conn: + conn = ConnectionManager.current + + table = util.strip_multiple_chars(table, "\"'") + + if schema: + table_ = f"{schema}.{table}" + else: + table_ = table + + _is_exist = _is_table_exists(table_, conn) + + if not _is_exist: + if not ignore_error: + try_find_suggestions = not conn.is_dbapi_connection + expected = [] + existing_schemas = [] + existing_tables = [] + + if try_find_suggestions: + existing_schemas = get_schema_names() + + if schema and schema not in existing_schemas: + expected = existing_schemas + invalid_input = schema + else: + if try_find_suggestions: + existing_tables = _get_list_of_existing_tables() + + expected = existing_tables + invalid_input = table + + if schema: + err_message = ( + f"There is no table with name {table!r} in schema {schema!r}" + ) + else: + err_message = ( + f"There is no table with name {table!r} in the default schema" + ) + + if table not in get_all_keys(): + suggestions = util.find_close_match(invalid_input, expected) + suggestions_store = util.find_close_match(invalid_input, get_all_keys()) + suggestions.extend(suggestions_store) + suggestions_message = util.get_suggestions_message(suggestions) + if suggestions_message: + err_message = f"{err_message}{suggestions_message}" + raise exceptions.TableNotFoundError(err_message) + + return _is_exist + + +def fetch_sql_with_pagination( + table, offset, n_rows, sort_column=None, sort_order=None +) -> tuple: + """ + Returns next n_rows and columns from table starting at the offset + + Parameters + ---------- + table : str + Table name + + offset : int + Specifies the number of rows to skip before + it starts to return rows from the query expression. + + n_rows : int + Number of rows to return. + + sort_column : str, default None + Sort by column + + sort_order : 'DESC' or 'ASC', default None + Order list + """ + is_table_exists(table) + + order_by = "" if not sort_column else f"ORDER BY {sort_column} {sort_order}" + + query = f""" + SELECT * FROM {table} {order_by} + OFFSET {offset} ROWS FETCH NEXT {n_rows} ROWS ONLY""" + + rows = ConnectionManager.current.execute(query).fetchall() + + columns = ConnectionManager.current.raw_execute( + f"SELECT * FROM {table} WHERE 1=0" + ).keys() + + return rows, columns diff --git a/src/sql/magic.py b/src/sql/magic.py index f2c3c3207..6d8d32f70 100644 --- a/src/sql/magic.py +++ b/src/sql/magic.py @@ -1,93 +1,265 @@ import json import re -from string import Formatter +from pathlib import Path +import sqlparse + +try: + from ipywidgets import interact +except ModuleNotFoundError: + interact = None +from ploomber_core.exceptions import modify_exceptions from IPython.core.magic import ( Magics, cell_magic, line_magic, magics_class, needs_local_scope, + no_var_expand, ) from IPython.core.magic_arguments import argument, magic_arguments, parse_argstring -from IPython.display import display_javascript -from sqlalchemy.exc import OperationalError, ProgrammingError, DatabaseError +from sqlalchemy.exc import ( + OperationalError, + ProgrammingError, + DatabaseError, + StatementError, +) +from traitlets.config.configurable import Configurable +from traitlets import Bool, Int, TraitError, Unicode, Dict, observe, validate +from sql.traits import Parameters +import warnings +import shlex import sql.connection import sql.parse -import sql.run +from sql.run.run import run_statements +from sql.parse import _option_strings_from_parser +from sql import display, exceptions +from sql.store import store +from sql.command import SQLCommand +from sql.magic_plot import SqlPlotMagic +from sql.magic_cmd import SqlCmdMagic +from sql._patch import patch_ipython_usage_error +from sql import util +from sql.error_handler import handle_exception +from sql._current import _set_sql_magic + + +from ploomber_core.dependencies import check_installed + -try: - from traitlets.config.configurable import Configurable - from traitlets import Bool, Int, Unicode -except ImportError: - from IPython.config.configurable import Configurable - from IPython.utils.traitlets import Bool, Int, Unicode try: from pandas.core.frame import DataFrame, Series -except ImportError: +except ModuleNotFoundError: DataFrame = None Series = None +SUPPORT_INTERACTIVE_WIDGETS = ["Checkbox", "Text", "IntSlider", ""] +IF_NOT_SELECT_MESSAGE = "The query is not a SELECT type query and as \ +snippets only work with SELECT queries," +IF_SELECT_MESSAGE = "JupySQL does not support snippet expansion within CTEs yet," + + +@magics_class +class RenderMagic(Magics): + """ + %sqlrender magic which prints composed queries + """ + + @line_magic + @magic_arguments() + # TODO: only accept one arg + @argument("line", default="", nargs="*", type=str) + @argument( + "-w", + "--with", + type=str, + help="Use a saved query", + action="append", + dest="with_", + ) + def sqlrender(self, line): + args = parse_argstring(self.sqlrender, line) + warnings.warn( + "\n'%sqlrender' will be deprecated soon, " + f"please use '%sqlcmd snippets {args.line[0]}' instead. " + "\n\nFor documentation, follow this link : " + "https://jupysql.ploomber.io/en/latest/api/magic-snippets.html#id1", + FutureWarning, + ) + return str(store[args.line[0]]) + + @magics_class class SqlMagic(Magics, Configurable): """Runs SQL statement on a database, specified by SQLAlchemy connect string. Provides the %%sql magic.""" - displaycon = Bool(True, config=True, help="Show connection string after execute") + autocommit = Bool(default_value=True, config=True, help="Set autocommit mode") autolimit = Int( - 0, + default_value=0, config=True, allow_none=True, help="Automatically limit the size of the returned result sets", ) - style = Unicode( - "DEFAULT", + autopandas = Bool( + default_value=False, config=True, - help="Set the table printing style to any of prettytable's defined styles (currently DEFAULT, MSWORD_FRIENDLY, PLAIN_COLUMNS, RANDOM)", + help="Return Pandas DataFrames instead of regular result sets", ) - short_errors = Bool( - True, + autopolars = Bool( + default_value=False, config=True, - help="Don't display the full traceback on SQL Programming Error", + help="Return Polars DataFrames instead of regular result sets", ) - displaylimit = Int( - None, + column_local_vars = Bool( + default_value=False, config=True, - allow_none=True, - help="Automatically limit the number of rows displayed (full result set is still stored)", + help="Return data into local variables from column names", ) - autopandas = Bool( - False, - config=True, - help="Return Pandas DataFrames instead of regular result sets", + displaycon = Bool( + default_value=True, config=True, help="Show connection string after execution" ) - column_local_vars = Bool( - False, config=True, help="Return data into local variables from column names" + displaylimit = Int( + default_value=10, + config=True, + allow_none=True, + help=( + "Automatically limit the number of rows " + "displayed (full result set is still stored)" + ), ) - feedback = Bool(True, config=True, help="Print number of rows affected by DML") dsn_filename = Unicode( - "odbc.ini", + default_value=str(Path("~/.jupysql/connections.ini").expanduser()), config=True, help="Path to DSN file. " "When the first argument is of the form [section], " "a sqlalchemy connection string is formed from the " "matching section in the DSN file.", ) - autocommit = Bool(True, config=True, help="Set autocommit mode") + feedback = Int( + default_value=1, + config=True, + help="Verbosity level. 0=minimal, 1=normal, 2=all", + ) + lazy_execution = Bool( + default_value=False, + config=True, + help="Whether to evaluate using ResultSet which will " + "cause the plan to execute or just return a lazily " + "executed plan allowing validating schemas, " + "without expensive compute." + "Currently only supported for Spark Connection.", + ) + named_parameters = Parameters( + default_value="warn", + config=True, + help=( + "Allow named parameters in queries " + "(i.e., 'SELECT * FROM foo WHERE bar = :bar')" + ), + ) + polars_dataframe_kwargs = Dict( + default_value={}, + config=True, + help=( + "Polars DataFrame constructor keyword arguments" + "(e.g. infer_schema_length, nan_to_null, schema_overrides, etc)" + ), + ) + short_errors = Bool( + default_value=True, + config=True, + help="Don't display the full traceback on SQL Programming Error", + ) + style = Unicode( + default_value="DEFAULT", + config=True, + help=( + "Set the table printing style to any of prettytable's " + "defined styles (currently DEFAULT, MSWORD_FRIENDLY, PLAIN_COLUMNS, " + "RANDOM, SINGLE_BORDER, DOUBLE_BORDER, MARKDOWN )" + ), + ) def __init__(self, shell): + self._store = store + Configurable.__init__(self, config=shell.config) Magics.__init__(self, shell=shell) # Add ourself to the list of module configurable via %config self.shell.configurables.append(self) + @validate("dsn_filename") + def _valid_dsn_filename(self, proposal): + path = Path(proposal["value"]).expanduser() + return str(path) + + # To verify displaylimit is valid positive integer + # If: + # None -> We treat it as 0 (no limit) + # Positive Integer -> Pass + # Negative Integer -> raise Error + @validate("displaylimit") + def _valid_displaylimit(self, proposal): + if proposal["value"] is None: + display.message("displaylimit: Value None will be treated as 0 (no limit)") + return 0 + try: + value = int(proposal["value"]) + if value < 0: + raise TraitError( + "{}: displaylimit cannot be a negative integer".format(value) + ) + return value + except ValueError: + raise TraitError("{}: displaylimit is not an integer".format(value)) + + @observe("autopandas", "autopolars") + def _mutex_autopandas_autopolars(self, change): + # When enabling autopandas or autopolars, automatically disable the + # other one in case it was enabled and print a warning + if change["new"]: + other = "autopolars" if change["name"] == "autopandas" else "autopandas" + if getattr(self, other): + setattr(self, other, False) + display.message( + f"Disabled '{other}' since '{change['name']}' was enabled." + ) + + def check_random_arguments(self, line="", cell=""): + # check only for cell magic + if cell != "": + tokens = shlex.split(line, posix=False) + arguments = [] + + # Iterate through the tokens to separate arguments and SQL code + # If the token starts with "--", it is an argument + breakLoop = False + for token in tokens: + if token.startswith("--") or token.startswith("-"): + arguments.append(token) + breakLoop = True + else: + if breakLoop: + break + + declared_argument = _option_strings_from_parser(SqlMagic.execute.parser) + for check_argument in arguments: + if check_argument not in declared_argument: + raise exceptions.UsageError( + "Unrecognized argument(s): {}".format(check_argument) + ) + + @no_var_expand @needs_local_scope @line_magic("sql") @cell_magic("sql") + @line_magic("jupysql") + @cell_magic("jupysql") @magic_arguments() @argument("line", default="", nargs="*", type=str, help="sql") @argument( @@ -109,10 +281,25 @@ def __init__(self, shell): action="store_true", help="create a table name in the database from the named DataFrame", ) + @argument( + "-P", + "--persist-replace", + action="store_true", + help="replace the DataFrame if it exists, otherwise perform --persist", + ) + @argument( + "-n", + "--no-index", + action="store_true", + help="Do not store Data Frame index when persisting", + ) @argument( "--append", action="store_true", - help="create, or append to, a table name in the database from the named DataFrame", + help=( + "create, or append to, a table name in the database from the " + "named DataFrame" + ), ) @argument( "-a", @@ -121,8 +308,37 @@ def __init__(self, shell): help="specify dictionary of connection arguments to pass to SQL driver", ) @argument("-f", "--file", type=str, help="Run SQL from file at this path") - def execute(self, line="", cell="", local_ns={}): - """Runs SQL statement against a database, specified by SQLAlchemy connect string. + @argument("-S", "--save", type=str, help="Save this query for later use") + @argument( + "-w", + "--with", + type=str, + help="Use a saved query", + action="append", + dest="with_", + ) + @argument( + "-N", + "--no-execute", + action="store_true", + help="Do not execute query (use it with --save)", + ) + @argument( + "-A", + "--alias", + type=str, + help="Assign an alias to the connection", + ) + @argument( + "--interact", + type=str, + action="append", + help="Interactive mode", + ) + def execute(self, line="", cell="", local_ns=None): + """ + Runs SQL statement against a database, specified by + SQLAlchemy connect string. If no database connection has been established, first word should be a SQLAlchemy connection string, or the user@db name @@ -146,30 +362,130 @@ def execute(self, line="", cell="", local_ns={}): mysql+pymysql://me:mypw@localhost/mydb """ - # Parse variables (words wrapped in {}) for %%sql magic (for %sql this is done automatically) - cell = self.shell.var_expand(cell) - line = sql.parse.without_sql_comment(parser=self.execute.parser, line=line) - args = parse_argstring(self.execute, line) - if args.connections: - return sql.connection.Connection.connections - elif args.close: - return sql.connection.Connection._close(args.close) + return self._execute( + line=line, cell=cell, local_ns=local_ns, is_interactive_mode=False + ) + + @modify_exceptions + def _execute(self, line, cell, local_ns, is_interactive_mode=False): + """ + This function implements the cell logic; we create this private + method so we can control how the function is called. Otherwise, + decorating ``SqlMagic.execute`` will break when adding the ``@log_call`` + decorator with ``payload=True`` + + NOTE: telemetry has been removed, we can remove this function + """ + + def interactive_execute_wrapper(**kwargs): + for key, value in kwargs.items(): + local_ns[key] = value + return self._execute(line, cell, local_ns, is_interactive_mode=True) + + # line is the text after the magic, cell is the cell's body + + # Examples + + # %sql {line} + # note that line magic has no body + + # %%sql {line} + # {cell} + + self.check_random_arguments(line, cell) + + if local_ns is None: + local_ns = {} # save globals and locals so they can be referenced in bind vars user_ns = self.shell.user_ns.copy() user_ns.update(local_ns) - command_text = " ".join(args.line) + "\n" + cell + command = SQLCommand(self, user_ns, line, cell) + # args.line: contains the line after the magic with all options removed + + args = command.args + + if util.is_rendering_required(line): + util.expand_args(args, user_ns) + + if args.section and args.alias: + raise exceptions.UsageError( + "Cannot use --section with --alias since the section name " + "is automatically set as the connection alias" + ) + + is_cte = command.sql_original.strip().lower().startswith("with ") + + # only expand CTE if this is not a CTE itself + if not is_cte: + if args.with_: + with_ = args.with_ + else: + with_ = self._store.infer_dependencies(command.sql_original, args.save) + if with_: + query_type = get_query_type(command.sql_original) + + if query_type != "SELECT": + display.message_warning( + f"Your query is using the following snippets: \ +{', '.join(with_)}. {IF_NOT_SELECT_MESSAGE} CTE generation is disabled" + ) + else: + command.set_sql_with(with_) + display.message( + f"Generating CTE with stored snippets: \ +{util.pretty_print(with_)}" + ) + else: + with_ = None + else: + query_type = get_query_type(command.sql_original) + if args.with_: + raise exceptions.UsageError( + "Cannot use --with with CTEs, remove --with and re-run the cell" + ) + + dependencies = self._store.infer_dependencies( + command.sql_original, args.save + ) - if args.file: - with open(args.file, "r") as infile: - command_text = infile.read() + "\n" + command_text + if dependencies: + if query_type != "SELECT": + display_message = IF_NOT_SELECT_MESSAGE + else: + display_message = IF_SELECT_MESSAGE + display.message_warning( + f"Your query is using one or more of the following snippets: \ +{', '.join(dependencies)}. {display_message}\ + CTE generation is disabled" + ) + with_ = None + + # Create the interactive slider + if args.interact and not is_interactive_mode: + check_installed(["ipywidgets"], "--interactive argument") + interactive_dict = {} + for key in args.interact: + interactive_dict[key] = local_ns[key] + display.message( + "Interactive mode, please interact with below " + "widget(s) to control the variable" + ) + interact(interactive_execute_wrapper, **interactive_dict) + return + + if args.connections: + return sql.connection.ConnectionManager.connections_table() + elif args.close: + return sql.connection.ConnectionManager.close_connection_with_descriptor( + args.close + ) - parsed = sql.parse.parse(command_text, self) + connect_arg = command.connection - connect_str = parsed["connection"] if args.section: - connect_str = sql.parse.connection_from_dsn_section(args.section, self) + connect_arg = sql.parse.connection_str_from_dsn_section(args.section, self) if args.connection_arguments: try: @@ -183,36 +499,83 @@ def execute(self, line="", cell="", local_ns={}): raw_args = raw_args[1:-1] args.connection_arguments = json.loads(raw_args) except Exception as e: - print(e) - raise e + raise exceptions.ValueError(str(e)) from e else: args.connection_arguments = {} if args.creator: args.creator = user_ns[args.creator] - try: - conn = sql.connection.Connection.set( - connect_str, - displaycon=self.displaycon, - connect_args=args.connection_arguments, - creator=args.creator, + # this creates a new connection or use an existing one + # depending on the connect_arg value + conn = sql.connection.ConnectionManager.set( + connect_arg, + displaycon=self.displaycon, + connect_args=args.connection_arguments, + creator=args.creator, + alias=args.section if args.section else args.alias, + config=self, + ) + + if args.persist_replace and args.append: + raise exceptions.UsageError( + """You cannot simultaneously persist and append data to a dataframe; + please choose to utilize either one or the other.""" + ) + if args.persist and args.persist_replace: + warnings.warn("Please use either --persist or --persist-replace") + return self._persist_dataframe( + command.sql, + conn, + user_ns, + append=False, + index=not args.no_index, + replace=True, + ) + elif args.persist: + return self._persist_dataframe( + command.sql, conn, user_ns, append=False, index=not args.no_index + ) + elif args.persist_replace: + return self._persist_dataframe( + command.sql, + conn, + user_ns, + append=False, + index=not args.no_index, + replace=True, ) - except Exception as e: - print(e) - print(sql.connection.Connection.tell_format()) - return None - - if args.persist: - return self._persist_dataframe(parsed["sql"], conn, user_ns, append=False) - if args.append: - return self._persist_dataframe(parsed["sql"], conn, user_ns, append=True) + return self._persist_dataframe( + command.sql, conn, user_ns, append=True, index=not args.no_index + ) + + if not command.sql: + return - if not parsed["sql"]: + # store the query if needed + if args.save: + if "-" in args.save: + warnings.warn( + "Using hyphens will be deprecated soon, " + "please use " + + str(args.save.replace("-", "_")) + + " instead for the save argument.", + FutureWarning, + ) + self._store.store(args.save, command.sql_original, with_=with_) + + if args.no_execute: + display.message("Skipping execution...") return + parameters = None + if self.named_parameters == "disabled": + parameters = {} + elif self.named_parameters == "enabled": + parameters = user_ns + try: - result = sql.run.run(conn, parsed["sql"], self, user_ns) + result = run_statements(conn, command.sql, self, parameters=parameters) if ( result is not None @@ -222,14 +585,14 @@ def execute(self, line="", cell="", local_ns={}): # Instead of returning values, set variables directly in the # users namespace. Variable names given by column names - if self.autopandas: + if self.autopandas or self.autopolars: keys = result.keys() else: keys = result.keys result = result.dict() if self.feedback: - print( + display.message( "Returning data to local variables [{}]".format(", ".join(keys)) ) @@ -237,58 +600,188 @@ def execute(self, line="", cell="", local_ns={}): return None else: - - if parsed["result_var"]: - result_var = parsed["result_var"] - print("Returning data to local variable {}".format(result_var)) - self.shell.user_ns.update({result_var: result}) + if command.result_var: + self.shell.user_ns.update({command.result_var: result}) + if command.return_result_var: + return result return None # Return results into the default ipython _ variable return result # JA: added DatabaseError for MySQL - except (ProgrammingError, OperationalError, DatabaseError) as e: + except ( + ProgrammingError, + OperationalError, + DatabaseError, + # raised when they query has :parameters but no parameters are given + StatementError, + ) as e: # Sqlite apparently return all errors as OperationalError :/ - if self.short_errors: - print(e) - else: - raise + handle_exception(e, command.sql, self.short_errors) + except Exception as e: + # Handle non SQLAlchemy errors + handle_exception(e, command.sql, self.short_errors) legal_sql_identifier = re.compile(r"^[A-Za-z0-9#_$]+") - def _persist_dataframe(self, raw, conn, user_ns, append=False): + @modify_exceptions + def _persist_dataframe( + self, raw, conn, user_ns, append=False, index=True, replace=False + ): """Implements PERSIST, which writes a DataFrame to the RDBMS""" if not DataFrame: - raise ImportError("Must `pip install pandas` to use DataFrames") + raise exceptions.MissingPackageError( + "You must install pandas to persist results: pip install pandas" + ) frame_name = raw.strip(";") - # Get the DataFrame from the user namespace + # user may pass schema.dataframe (required for certain DBs + # like Trino) + schema_name = None + if "." in frame_name: + schema_frame = frame_name.split(".") + schema_name = schema_frame[0] + frame_name = schema_frame[1] + + # invalid identifier + if not frame_name.isidentifier(): + raise exceptions.UsageError( + f"Expected {frame_name!r} to be a pd.DataFrame but it's" + " not a valid identifier" + ) + + # missing argument if not frame_name: - raise SyntaxError("Syntax: %sql --persist ") - try: - frame = eval(frame_name, user_ns) - except SyntaxError: - raise SyntaxError("Syntax: %sql --persist ") + raise exceptions.UsageError( + "Missing argument: %sql --persist " + ) + + # undefined variable + if frame_name not in user_ns: + raise exceptions.UsageError( + f"Expected {frame_name!r} to be a pd.DataFrame but it's undefined" + ) + + frame = user_ns[frame_name] + if not isinstance(frame, DataFrame) and not isinstance(frame, Series): - raise TypeError("%s is not a Pandas DataFrame or Series" % frame_name) + raise exceptions.TypeError( + f"{frame_name!r} is not a Pandas DataFrame or Series" + ) # Make a suitable name for the resulting database table table_name = frame_name.lower() table_name = self.legal_sql_identifier.search(table_name).group(0) - if_exists = "append" if append else "fail" - frame.to_sql(table_name, conn.session.engine, if_exists=if_exists) - return "Persisted %s" % table_name + if replace: + if_exists = "replace" + elif append: + if_exists = "append" + else: + if_exists = "fail" + + conn.to_table( + table_name=table_name, + data_frame=frame, + if_exists=if_exists, + index=index, + schema=schema_name, + ) + + +def get_query_type(command: str): + """ + Returns the query type of the original sql command + """ + parsed = sqlparse.parse(command) + query_type = parsed[0].get_type() if parsed else None + if query_type == "UNKNOWN": + return None + return query_type + + +def set_configs(ip, file_path, alternate_path): + """Set user defined SqlMagic configuration settings""" + sql = ip.find_cell_magic("sql").__self__ + user_configs, loaded_from = util.get_user_configs(file_path, alternate_path) + default_configs = util.get_default_configs(sql) + table_rows = [] + + success = False + if user_configs: + for config, value in user_configs.items(): + if config in default_configs.keys(): + default_type = type(default_configs[config]) + if isinstance(value, default_type): + setattr(sql, config, value) + table_rows.append([config, value]) + success = True + else: + display.message( + f"'{value}' is an invalid value for '{config}'. " + f"Please use {default_type.__name__} value instead." + ) + else: + util.find_close_match_config(config, default_configs.keys()) + if success: + if loaded_from is not None: + display.message(f"Loading configurations from {loaded_from}.") + else: + display.message("Loading default configurations.") + + return table_rows + + +def load_SqlMagic_configs(ip): + """Loads saved SqlMagic configs in pyproject.toml or ~/.jupysql/config""" + + file_path = util.find_path_from_root("pyproject.toml") + alternate_path = Path("~/.jupysql/config").expanduser() + + table_rows = [] + try: + table_rows = set_configs(ip, file_path, alternate_path) + except Exception as e: + if type(e).__name__ == "TomlDecodeError": + display.message_warning( + f"Could not load configuration file at {file_path}" + f"{(' or ' + str(alternate_path)) if alternate_path else ''}" + " (default configuration will be used).\nPlease " + f"check that it is valid TOML: {e}" + ) + return + if type(e).__name__ == "ModuleNotFoundError": + display.message( + "The 'toml' package isn't installed. To load settings from " + "pyproject.toml or ~/.jupysql/config, install with: " + "pip install toml" + ) + return + else: + raise + + if table_rows: + display.message("Settings changed:") + display.table(["Config", "value"], table_rows) def load_ipython_extension(ip): - """Load the extension in IPython.""" + """Load the magics, this function is executed when the user runs: %load_ext sql""" + sql_magic = SqlMagic(ip) + _set_sql_magic(sql_magic) + ip.register_magics(sql_magic) + + load_SqlMagic_configs(ip) + + # start the default connection if the user has one in their config file + sql.connection.ConnectionManager.load_default_connection_from_file_if_any( + config=sql_magic + ) - # this fails in both Firefox and Chrome for OS X. - # I get the error: TypeError: IPython.CodeCell.config_defaults is undefined + ip.register_magics(RenderMagic) + ip.register_magics(SqlPlotMagic) + ip.register_magics(SqlCmdMagic) - # js = "IPython.CodeCell.config_defaults.highlight_modes['magic_sql'] = {'reg':[/^%%sql/]};" - # display_javascript(js, raw=True) - ip.register_magics(SqlMagic) + patch_ipython_usage_error(ip) diff --git a/src/sql/magic_cmd.py b/src/sql/magic_cmd.py new file mode 100644 index 000000000..da6d370e2 --- /dev/null +++ b/src/sql/magic_cmd.py @@ -0,0 +1,135 @@ +import sys +import argparse +import shlex + +from IPython.core.magic import Magics, line_magic, magics_class, no_var_expand +from IPython.core.magic_arguments import argument, magic_arguments +from sql.inspect import support_only_sql_alchemy_connection +from sql.cmd.tables import tables +from sql.cmd.columns import columns +from sql.cmd.test import test +from sql.cmd.profile import profile +from sql.cmd.explore import explore +from sql.cmd.snippets import snippets +from sql.cmd.connect import connect +from sql.connection import ConnectionManager +from sql.util import check_duplicate_arguments + +try: + from traitlets.config.configurable import Configurable +except ModuleNotFoundError: + from IPython.config.configurable import Configurable +from sql import exceptions + + +class CmdParser(argparse.ArgumentParser): + def exit(self, status=0, message=None): + if message: + self._print_message(message, sys.stderr) + + def error(self, message): + raise exceptions.UsageError(message) + + +@magics_class +class SqlCmdMagic(Magics, Configurable): + """%sqlcmd magic""" + + @no_var_expand + @line_magic("sqlcmd") + @magic_arguments() + @argument("line", type=str, help="Command name") + def _validate_execute_inputs(self, line): + """ + Function to validate %sqlcmd inputs. + Raises UsageError in case of an invalid input, executes command otherwise. + """ + + # We rely on SQLAlchemy when inspecting tables + + AVAILABLE_SQLCMD_COMMANDS = [ + "tables", + "columns", + "test", + "profile", + "explore", + "snippets", + "connect", + ] + COMMANDS_CONNECTION_REQUIRED = [ + "tables", + "columns", + "test", + "profile", + "explore", + ] + COMMANDS_SQLALCHEMY_ONLY = ["tables", "columns", "test", "explore"] + + VALID_COMMANDS_MSG = ( + f"Missing argument for %sqlcmd. " + f"Valid commands are: {', '.join(AVAILABLE_SQLCMD_COMMANDS)}" + ) + + if line == "": + raise exceptions.UsageError(VALID_COMMANDS_MSG) + else: + # directly use shlex since SqlCmdMagic does not use magic_args from parse.py + split = shlex.split(line, posix=False) + command, others = split[0].strip(), split[1:] + if others: + check_duplicate_arguments( + self.execute, + "sqlcmd", + split, + disallowed_aliases={ + "-t": "--table", + "-s": "--schema", + "-o": "--output", + }, + ) + + if command in AVAILABLE_SQLCMD_COMMANDS: + if ( + command in COMMANDS_CONNECTION_REQUIRED + and not ConnectionManager.current + ): + raise exceptions.RuntimeError( + f"Cannot execute %sqlcmd {command} because there " + "is no active connection. Connect to a database " + "and try again." + ) + + if command in COMMANDS_SQLALCHEMY_ONLY: + support_only_sql_alchemy_connection(f"%sqlcmd {command}") + + return self.execute(command, others) + else: + raise exceptions.UsageError( + f"%sqlcmd has no command: {command!r}. " + "Valid commands are: {}".format( + ", ".join(AVAILABLE_SQLCMD_COMMANDS) + ) + ) + + @argument("cmd_name", default="", type=str, help="Command name") + @argument("others", default="", type=str, help="Other tags") + def execute(self, cmd_name="", others="", cell="", local_ns=None): + """ + Command + """ + + router = { + "tables": tables, + "columns": columns, + "test": test, + "profile": profile, + "explore": explore, + "snippets": snippets, + "connect": connect, + } + + cmd = router.get(cmd_name) + if cmd_name == "connect": + return cmd(others) + else: + return cmd(others, self.shell.user_ns.copy()) diff --git a/src/sql/magic_plot.py b/src/sql/magic_plot.py new file mode 100644 index 000000000..92c1aecad --- /dev/null +++ b/src/sql/magic_plot.py @@ -0,0 +1,161 @@ +from IPython.core.magic import Magics, line_magic, magics_class, no_var_expand +from IPython.core.magic_arguments import argument, magic_arguments +from ploomber_core.exceptions import modify_exceptions + +try: + from traitlets.config.configurable import Configurable +except ModuleNotFoundError: + from IPython.config.configurable import Configurable + + +from sql import plot +from sql.command import SQLPlotCommand +from sql import util +from sql.inspect import is_table_exists +from sql.store import is_saved_snippet + +SUPPORTED_PLOTS = ["histogram", "boxplot", "bar", "pie"] + + +@magics_class +class SqlPlotMagic(Magics, Configurable): + """%sqlplot magic""" + + @no_var_expand + @line_magic("sqlplot") + @magic_arguments() + @argument( + "plot_name", + type=str, + help="Plot name", + choices=["histogram", "hist", "boxplot", "box", "bar", "pie"], + ) + @argument("-t", "--table", type=str, help="Table to use", required=True) + @argument("-s", "--schema", type=str, help="Schema to use", required=False) + @argument( + "-c", "--column", type=str, nargs="+", help="Column(s) to use", required=True + ) + @argument( + "-b", + "--bins", + type=int, + default=50, + help="Histogram bins", + ) + @argument( + "-o", + "--orient", + type=str, + default="v", + help="Boxplot orientation (v/h)", + ) + @argument( + "-w", + "--with", + type=str, + help="Use a saved query", + action="append", + dest="with_", + ) + @argument( + "-S", + "--show-numbers", + action="store_true", + help="Show number of observations", + ) + @argument( + "-B", + "--breaks", + type=float, + nargs="+", + help="Histogram breaks", + ) + @argument( + "-W", + "--binwidth", + type=float, + help="Histogram binwidth", + ) + @modify_exceptions + def execute(self, line="", cell="", local_ns=None): + """ + Plot magic + """ + + cmd = SQLPlotCommand(self, line) + + if util.is_rendering_required(line): + util.expand_args(cmd.args, self.shell.user_ns.copy()) + + if len(cmd.args.column) == 1: + column = cmd.args.column[0] + else: + column = cmd.args.column + + column = util.sanitize_identifier(column) + table = util.sanitize_identifier(cmd.args.table) + schema = cmd.args.schema + if schema: + schema = util.sanitize_identifier(schema) + + if cmd.args.with_: + with_ = cmd.args.with_ + else: + with_ = self._check_table_exists(table, schema) + + if cmd.args.plot_name in {"box", "boxplot"}: + return plot.boxplot( + table=table, + column=column, + with_=with_, + orient=cmd.args.orient, + conn=None, + schema=schema, + ) + elif cmd.args.plot_name in {"hist", "histogram"}: + # to avoid passing bins default value when breaks or binwidth is specified + bin_specified = " --bins " in line or " -b " in line + breaks_specified = " --breaks " in line or " -B " in line + binwidth_specified = " --binwidth " in line or " -W " in line + bins = cmd.args.bins + if not bin_specified and any([breaks_specified, binwidth_specified]): + bins = None + + return plot.histogram( + table=table, + column=column, + bins=bins, + with_=with_, + conn=None, + breaks=cmd.args.breaks, + binwidth=cmd.args.binwidth, + schema=schema, + ) + elif cmd.args.plot_name in {"bar"}: + return plot.bar( + table=table, + column=column, + with_=with_, + orient=cmd.args.orient, + show_num=cmd.args.show_numbers, + conn=None, + schema=schema, + ) + elif cmd.args.plot_name in {"pie"}: + return plot.pie( + table=table, + column=column, + with_=with_, + show_num=cmd.args.show_numbers, + conn=None, + schema=schema, + ) + + @staticmethod + def _check_table_exists(table, schema=None): + with_ = None + if is_saved_snippet(table): + with_ = [table] + else: + is_table_exists(table, schema) + return with_ diff --git a/src/sql/parse.py b/src/sql/parse.py index 29d1ca547..16abfd138 100644 --- a/src/sql/parse.py +++ b/src/sql/parse.py @@ -1,90 +1,248 @@ -import itertools -import json import re +import itertools import shlex from os.path import expandvars +from pathlib import Path +import configparser +import warnings +import ast -import six -from six.moves import configparser as CP from sqlalchemy.engine.url import URL +from sql import exceptions +from sql.util import check_duplicate_arguments + +# Keywords used to identify the beginning of SQL queries +# in split_args_and_sql(). Should cover all cases but can +# be edited to include special keywords. +SQL_COMMANDS = [ + "select", + "from", + "with", + "pivot", + "create", + "update", + "delete", + "insert", + "alter", + "drop", + "describe", +] + + +def _parse_config_section(section): + """Return a given configuration section as a dictionary of keys and values + + If the section contains `query` as key, its value is evaluated such + that a `"{...}"` string is also converted to a dictionary. + + Parameters + ---------- + section : list[tuple[str,str]] + The section object as returned by ConfigParser.items() + """ + url_args = dict(section) + + if "query" in url_args: + url_args["query"] = ast.literal_eval(url_args["query"]) + + return url_args + + +class ConnectionsFile: + def __init__(self, path_to_file) -> None: + self.parser = configparser.ConfigParser() + dsn_file = Path(path_to_file) + + cfg_content = dsn_file.read_text() + self.parser.read_string(cfg_content) + + def get_default_connection_url(self): + try: + section = self.parser.items("default") + except configparser.NoSectionError: + return None + + url = URL.create(**_parse_config_section(section)) + return str(url.render_as_string(hide_password=False)) + + +def connection_str_from_dsn_section(section, config): + """Return a SQLAlchemy connection string from a section in a DSN file + + Parameters + ---------- + section : str + The section name in the DSN file + + config : Config + The config object, must have a dsn_filename attribute + """ + parser = configparser.ConfigParser() + dsn_file = Path(config.dsn_filename) + + try: + cfg_content = dsn_file.read_text() + except FileNotFoundError as e: + raise exceptions.FileNotFoundError( + f"%config SqlMagic.dsn_filename ({str(config.dsn_filename)!r}) not found." + " Ensure the file exists or change the configuration: " + "%config SqlMagic.dsn_filename = 'path/to/file.ini'" + ) from e + + try: + parser.read_string(cfg_content) + except configparser.Error as e: + raise exceptions.RuntimeError( + "An error happened when loading " + "your %config SqlMagic.dsn_filename " + f"({config.dsn_filename!r})\n{type(e).__name__}: {e}" + ) from e -def connection_from_dsn_section(section, config): - parser = CP.ConfigParser() - parser.read(config.dsn_filename) - cfg_dict = dict(parser.items(section)) - return str(URL(**cfg_dict)) + try: + cfg = parser.items(section) + except configparser.NoSectionError as e: + raise exceptions.KeyError( + f"The section {section!r} does not exist in the " + f"connections file {config.dsn_filename!r}" + ) from e + try: + url = URL.create(**_parse_config_section(cfg)) + except TypeError as e: + if "unexpected keyword argument" in str(e): + raise exceptions.TypeError( + f"%config SqlMagic.dsn_filename ({config.dsn_filename!r}) is invalid. " + "It must only contain the following keys: drivername, username, " + "password, host, port, database, query" + ) from e + else: + raise -def _connection_string(s, config): + return str(url.render_as_string(hide_password=False)) + + +def _connection_string(arg, path_to_file): + """ + Given a string, return a SQLAlchemy connection string if possible. + + Scenarios: + + - If the string is a valid URL, return it + - If the string is a valid section in the DSN file return the connection string + - Otherwise return an empty string + + Parameters + ---------- + arg : str + The string to parse + + path_to_file : str + The path to the DSN file + """ + # for environment variables + arg = expandvars(arg) + + # if it's a URL, return it + if "@" in arg or "://" in arg: + return arg + + # if it's a section in the DSN file, return the connection string + if arg.startswith("[") and arg.endswith("]"): + section = arg.lstrip("[").rstrip("]") + parser = configparser.ConfigParser() + parser.read(path_to_file) + cfg = parser.items(section) + url = URL.create(**_parse_config_section(cfg)) + url_ = str(url.render_as_string(hide_password=False)) + + warnings.warn( + "Starting connections with: %sql [section_name] is deprecated " + "and will be removed in a future release. " + "Please use: %sql --section section_name instead.", + category=FutureWarning, + ) + + return url_ - s = expandvars(s) # for environment variables - if "@" in s or "://" in s: - return s - if s.startswith("[") and s.endswith("]"): - section = s.lstrip("[").rstrip("]") - parser = CP.ConfigParser() - parser.read(config.dsn_filename) - cfg_dict = dict(parser.items(section)) - return str(URL(**cfg_dict)) return "" -def parse(cell, config): +def parse(arg, path_to_file): """Extract connection info and result variable from SQL - - Please don't add any more syntax requiring - special parsing. + + Please don't add any more syntax requiring + special parsing. Instead, add @arguments to SqlMagic.execute. - - We're grandfathering the + + We're grandfathering the connection string and `<<` operator in. - """ - result = {"connection": "", "sql": "", "result_var": None} + Parameters + ---------- + arg : str + The string to parse + + path_to_file : str + The path to the DSN file + """ + result = { + "connection": "", + "sql": "", + "result_var": None, + "return_result_var": False, + } - pieces = cell.split(None, 1) + pieces = arg.split(None, 1) if not pieces: return result - result["connection"] = _connection_string(pieces[0], config) + + result["connection"] = _connection_string(pieces[0], path_to_file) + if result["connection"]: if len(pieces) == 1: return result - cell = pieces[1] + arg = pieces[1] - pieces = cell.split(None, 2) - if len(pieces) > 1 and pieces[1] == "<<": - result["result_var"] = pieces[0] - if len(pieces) == 2: - return result - cell = pieces[2] + pointer = arg.find("<<") + if pointer != -1: + left = arg[:pointer].replace(" ", "").replace("\n", "") + right = arg[pointer + 2 :].strip(" ") - result["sql"] = cell + if "=" in left: + result["result_var"] = left[:-1] + result["return_result_var"] = True + else: + result["result_var"] = left + + result["sql"] = right + else: + result["sql"] = arg return result def _option_strings_from_parser(parser): - """Extracts the expected option strings (-a, --append, etc) from argparse parser + """Extracts the expected option strings (-a, --append, etc) from argparse parser Thanks Martijn Pieters https://stackoverflow.com/questions/28881456/how-can-i-list-all-registered-arguments-from-an-argumentparser-instance :param parser: [description] - :type parser: IPython.core.magic_arguments.MagicArgumentParser + :type parser: IPython.core.magic_arguments.MagicArgumentParser """ opts = [a.option_strings for a in parser._actions] return list(itertools.chain.from_iterable(opts)) def without_sql_comment(parser, line): - """Strips -- comment from a line + """Strips -- comment from a line - The argparser unfortunately expects -- to precede an option, - but in SQL that delineates a comment. So this removes comments + The argparser unfortunately expects -- to precede an option, + but in SQL that delineates a comment. So this removes comments so a line can safely be fed to the argparser. - :param line: A line of SQL, possibly mixed with option strings - :type line: str + :param line: A line of SQL, possibly mixed with option strings + :type line: str """ args = _option_strings_from_parser(parser) @@ -93,3 +251,151 @@ def without_sql_comment(parser, line): shlex.split(line, posix=False), ) return " ".join(result) + + +def split_args_and_sql(line): + """Separates line into args and sql query + + The argparser expects - to precede an argument, but postgreSQL + and duckDB allow for -> and ->> to be used as JSON operators. + This function splits the line into two - args and sql. + This way we can only pass the args into the argparser, and + add in the sql later. + + Parameters + ---------- + line: str + A line of SQL, preceded by option/argument strings + + Returns + ------- + arg_line: str + Portion of input line that contains only arguments + + sql_line: str + Portion of input line that contains only SQL query/statements + """ + arg_line, sql_line = line, "" + + # When queries include filenames, they may include SQL keywords + # ex. 'penguins_selected'.csv contains "select" + # In these cases, splitting the query leads to parsing errors. + # So we ignore any filenames by removing text between double quotes "" + # and single quotes '' below. + # Note: This won't affect the query because we are only modifying the + # text we use to check for SQL commands. Any splitting is done + # on the original line which includes filenames. + line_no_filenames = re.sub(r"('.*')", "", line) # 'file.csv' --> '' + line_no_filenames = re.sub(r'(".*")', "", line_no_filenames) # "file.csv" --> "" + + # Now that filenames are removed, check the line for any SQL commands + # If any SQL commands are found in the line, we split the line into args and sql. + # Note: lines without SQL commands will not be split + # ex. %sql duckdb:// or %sqlplot boxplot --table data.csv + if not any(cmd in line_no_filenames.lower() for cmd in SQL_COMMANDS): + return arg_line, sql_line + + # Identify beginning of sql query using keywords + split_idx = -1 + for token in line.split(): + if token.lower() in SQL_COMMANDS: + # Found index at which to split line + split_idx = line.find(token) + break + + # Split line into args and sql, beginning at sql keyword + if split_idx != -1: + arg_line, sql_line = line[:split_idx], line[split_idx:] + + return arg_line, sql_line + + +def magic_args(magic_execute, line, cmd_from, allowed_duplicates=None): + """ + Returns the parsed arguments from the line as parsed by magic_execute + """ + allowed_duplicates = allowed_duplicates or [] + line = without_sql_comment(parser=magic_execute.parser, line=line) + arg_line, sql_line = split_args_and_sql(line) + + args = shlex.split(arg_line, posix=False) + + if len(args) > 1: + check_duplicate_arguments(magic_execute, cmd_from, args, allowed_duplicates) + + parsed = magic_execute.parser.parse_args(args) + + if sql_line: + if parsed.line != "": + parsed.line.extend(shlex.split(sql_line, posix=False)) + else: + parsed.line = shlex.split(sql_line, posix=False) + + return parsed + + +def escape_string_literals_with_colon_prefix(query): + """ + Given a query, replaces all occurrences of ':variable' with '\:variable' and + ":variable" with "\:variable" so that the query can be passed to sqlalchemy.text + without the literals being interpreted as bind parameters. Also calls + escape_string_slicing_with_colon_prefix(). It doesn't replace + the occurrences of :variable (without quotes) + """ # noqa + + # Define the regular expression pattern for valid Python identifiers + identifier_pattern = r"\b[a-zA-Z_][a-zA-Z0-9_]*\b" + + double_quoted_variable_pattern = r'(?= {{loval}} +) AS _whislo +""" + ) + + query = template.render(table=table, column=column, loval=loval) + + values = conn.execute(query, with_).fetchone() + keys = ["N", "wisklo_min"] + return {k: float(v) for k, v in zip(keys, values)} + + +def _percentile(conn, table, column, pct, with_=None): + if not conn: + conn = sql.connection.ConnectionManager.current.connection + template = Template( + """ +SELECT +percentile_disc({{pct}}) WITHIN GROUP (ORDER BY "{{column}}") AS pct, +FROM {{table}} +""" + ) + query = template.render(table=table, column=column, pct=pct) + + values = conn.execute(query, with_).fetchone()[0] + return values + + +def _between(conn, table, column, whislo, whishi, with_=None): + template = Template( + """ +SELECT "{{column}}" +FROM {{table}} +WHERE "{{column}}" < {{whislo}} +OR "{{column}}" > {{whishi}} +""" + ) + query = template.render(table=table, column=column, whislo=whislo, whishi=whishi) + + results = [float(n[0]) for n in conn.execute(query, with_).fetchall()] + return results + + +# https://github.com/matplotlib/matplotlib/blob/b5ac96a8980fdb9e59c9fb649e0714d776e26701/lib/matplotlib/cbook/__init__.py +@modify_exceptions +def _boxplot_stats(conn, table, column, whis=1.5, autorange=False, with_=None): + """Compute statistics required to create a boxplot""" + if not conn: + conn = sql.connection.ConnectionManager.current + + def _compute_conf_interval(N, med, iqr): + notch_min = med - 1.57 * iqr / np.sqrt(N) + notch_max = med + 1.57 * iqr / np.sqrt(N) + + return notch_min, notch_max + + stats = dict() + + # arithmetic mean + s_stats = _summary_stats(conn, table, column, with_=with_) + + stats["mean"] = s_stats["mean"] + q1, med, q3 = s_stats["q1"], s_stats["med"], s_stats["q3"] + N = s_stats["N"] + + # interquartile range + stats["iqr"] = q3 - q1 + + if stats["iqr"] == 0 and autorange: + whis = (0, 100) + + # conf. interval around median + stats["cilo"], stats["cihi"] = _compute_conf_interval(N, med, stats["iqr"]) + + # lowest/highest non-outliers + if np.iterable(whis) and not isinstance(whis, str): + loval, hival = _percentile(conn, table, column, whis, with_=with_) + + elif np.isreal(whis): + loval = q1 - whis * stats["iqr"] + hival = q3 + whis * stats["iqr"] + else: + raise ValueError("whis must be a float or list of percentiles") + + # get high extreme + wiskhi_d = _whishi(conn, table, column, hival, with_=with_) + + if wiskhi_d["N"] == 0 or wiskhi_d["wiskhi_max"] < q3: + stats["whishi"] = q3 + else: + stats["whishi"] = wiskhi_d["wiskhi_max"] + + # get low extreme + wisklo_d = _whislo(conn, table, column, loval, with_=with_) + + if wisklo_d["N"] == 0 or wisklo_d["wisklo_min"] > q1: + stats["whislo"] = q1 + else: + stats["whislo"] = wisklo_d["wisklo_min"] + + # compute a single array of outliers + stats["fliers"] = np.array( + _between(conn, table, column, stats["whislo"], stats["whishi"], with_=with_) + ) + + # add in the remaining stats + stats["q1"], stats["med"], stats["q3"] = q1, med, q3 + + bxpstats = {k: v for k, v in stats.items()} + + return bxpstats + + +# https://github.com/matplotlib/matplotlib/blob/ddc260ce5a53958839c244c0ef0565160aeec174/lib/matplotlib/axes/_axes.py#L3915 +@requires(["matplotlib"]) +def boxplot(table, column, *, orient="v", with_=None, conn=None, ax=None, schema=None): + """Plot boxplot + + Parameters + ---------- + table : str + Table name where the data is located + + column : str, list + Column(s) to plot + + orient : str {"h", "v"}, default="v" + Boxplot orientation (vertical/horizontal) + + conn : connection, default=None + Database connection. If None, it uses the current connection + + Notes + ----- + .. versionchanged:: 0.5.2 + Added ``with_``, and ``orient`` arguments. Added plot title and axis labels. + Allowing to pass lists in ``column``. Function returns a ``matplotlib.Axes`` + object. + + .. versionadded:: 0.4.4 + + Returns + ------- + ax : matplotlib.Axes + Generated plot + + Examples + -------- + .. plot:: ../examples/plot_boxplot.py + + **Customize plot:** + + .. plot:: ../examples/plot_boxplot_custom.py + + **Horizontal boxplot:** + + .. plot:: ../examples/plot_boxplot_horizontal.py + + **Plot multiple columns from the same table:** + + .. plot:: ../examples/plot_boxplot_many.py + """ + if not conn: + conn = sql.connection.ConnectionManager.current + + _table = enclose_table_with_double_quotations(table, conn) + if schema: + _table = f'"{schema}"."{_table}"' + + ax = ax or plt.gca() + vert = orient == "v" + + set_ticklabels = ax.set_xticklabels if vert else ax.set_yticklabels + set_label = ax.set_ylabel if vert else ax.set_xlabel + + if isinstance(column, str): + stats = [_boxplot_stats(conn, _table, column, with_=with_)] + ax.bxp(stats, vert=vert) + ax.set_title(f"{column!r} from {table!r}") + set_label(column) + set_ticklabels([column]) + else: + stats = [_boxplot_stats(conn, _table, col, with_=with_) for col in column] + ax.bxp(stats, vert=vert) + ax.set_title(f"Boxplot from {table!r}") + set_ticklabels(column) + + return ax + + +def _min_max(conn, table, column, with_=None, use_backticks=False): + if not conn: + conn = sql.connection.ConnectionManager.current + template_ = """ +SELECT + MIN("{{column}}"), + MAX("{{column}}") +FROM {{table}} +""" + if use_backticks: + template_ = template_.replace('"', "`") + table = table.replace('"', "`") + template = Template(template_) + query = template.render(table=table, column=column) + min_, max_ = conn.execute(query, with_).fetchone() + return min_, max_ + + +def _get_bar_width(ax, bins, bin_size, binwidth): + """ + Return a single bar width based on number of bins + or a list of bar widths if `breaks` is given. + If bins values are str, calculate value based on figure size. + + Parameters + ---------- + ax : matplotlib.Axes + Generated plot + + bins : tuple + Contains bins' midpoints as float + + bin_size : int or list or None + Calculated bin_size from the _histogram function + or from consecutive differences in `breaks` + + binwidth : int or float or None + Specified binwidth from a user + + Returns + ------- + width : float + A single bar width + """ + if _are_numeric_values(bin_size) or isinstance(bin_size, list): + width = bin_size + elif _are_numeric_values(binwidth): + width = binwidth + else: + fig = plt.gcf() + bbox = ax.get_window_extent() + width_inch = bbox.width / fig.dpi + width = width_inch / len(bins) + + return width + + +@requires(["matplotlib"]) +def histogram( + table, + column, + bins, + with_=None, + conn=None, + category=None, + cmap=None, + color=None, + edgecolor=None, + ax=None, + facet=None, + breaks=None, + binwidth=None, + schema=None, +): + """Plot histogram + + Parameters + ---------- + table : str + Table name where the data is located + + column : str, list + Column(s) to plot + + bins : int + Number of bins + + conn : connection, default=None + Database connection. If None, it uses the current connection + + Notes + ----- + .. versionchanged:: 0.5.2 + Added plot title and axis labels. Allowing to pass lists in ``column``. + Function returns a ``matplotlib.Axes`` object. + + .. versionchanged:: 0.7.9 + Added support for NULL values, additional filter query with new logic. + Skips the rows with NULL in the column, does not raise ValueError + + Returns + ------- + ax : matplotlib.Axes + Generated plot + + Examples + -------- + .. plot:: ../examples/plot_histogram.py + + **Plot multiple columns from the same table**: + + .. plot:: ../examples/plot_histogram_many.py + """ + if not conn: + conn = sql.connection.ConnectionManager.current + if isinstance(breaks, list): + if len(breaks) < 2: + raise exceptions.ValueError( + f"Breaks given : {breaks}. When using breaks, please ensure " + "to specify at least two points." + ) + if not all([b2 > b1 for b1, b2 in zip(breaks[:-1], breaks[1:])]): + raise exceptions.ValueError( + f"Breaks given : {breaks}. When using breaks, please ensure that " + "breaks are strictly increasing." + ) + + if _are_numeric_values(binwidth): + if binwidth <= 0: + raise exceptions.ValueError( + f"Binwidth given : {binwidth}. When using binwidth, please ensure to " + "pass a positive value." + ) + binwidth = float(binwidth) + elif binwidth is not None: + raise exceptions.ValueError( + f"Binwidth given : {binwidth}. When using binwidth, please ensure to " + "pass a numeric value." + ) + + validate_mutually_exclusive_args( + ["bins", "breaks", "binwidth"], [bins, breaks, binwidth] + ) + + _table = enclose_table_with_double_quotations(table, conn) + if schema: + _table = f'"{schema}"."{_table}"' + + ax = ax or plt.gca() + + if category: + if isinstance(column, list): + if len(column) > 1: + raise ValueError( + f"""Columns given : {column}. + When using a stacked histogram, + please ensure that you specify only one column.""" + ) + else: + column = " ".join(column) + + if column is None or len(column) == 0: + raise ValueError("Column name has not been specified") + + bin_, height, bin_size = _histogram( + _table, + column, + bins, + with_=with_, + conn=conn, + breaks=breaks, + binwidth=binwidth, + ) + width = _get_bar_width(ax, bin_, bin_size, binwidth) + data = _histogram_stacked( + _table, + column, + category, + bin_, + bin_size, + with_=with_, + conn=conn, + facet=facet, + breaks=breaks, + binwidth=binwidth, + ) + cmap = plt.get_cmap(cmap or "viridis") + norm = Normalize(vmin=0, vmax=len(data)) + + bottom = np.zeros(len(bin_)) + for i, values in enumerate(data): + values_ = values[1:] + + if isinstance(color, list): + color_ = color[0] + if len(color) > 1: + warnings.warn( + "If you want to colorize each bar with multiple " + "colors please use cmap attribute instead " + "of 'fill'", + UserWarning, + ) + else: + color_ = color or cmap(norm(i + 1)) + + if isinstance(edgecolor, list): + edgecolor_ = edgecolor[0] + else: + edgecolor_ = edgecolor or "None" + + ax.bar( + bin_, + values_, + align="center", + label=values[0], + width=width, + bottom=bottom, + edgecolor=edgecolor_, + color=color_, + ) + bottom += values_ + + ax.set_title(f"Histogram from {table!r}") + # reverses legend order so alphabetically first goes on top + handles, labels = ax.get_legend_handles_labels() + ax.legend(handles[::-1], labels[::-1]) + elif isinstance(column, str): + bin_, height, bin_size = _histogram( + _table, + column, + bins, + with_=with_, + conn=conn, + facet=facet, + breaks=breaks, + binwidth=binwidth, + ) + width = _get_bar_width(ax, bin_, bin_size, binwidth) + + ax.bar( + bin_, + height, + align="center", + width=width, + color=color, + edgecolor=edgecolor or "None", + label=column, + ) + ax.set_title(f"{column!r} from {table!r}") + ax.set_xlabel(column) + + else: + if breaks and len(column) > 1: + raise exceptions.UsageError( + "Multiple columns don't support breaks. Please use bins instead." + ) + for i, col in enumerate(column): + bin_, height, bin_size = _histogram( + _table, + col, + bins, + with_=with_, + conn=conn, + facet=facet, + breaks=breaks, + binwidth=binwidth, + ) + width = _get_bar_width(ax, bin_, bin_size, binwidth) + + if isinstance(color, list): + color_ = color[i] + else: + color_ = color + + if isinstance(edgecolor, list): + edgecolor_ = edgecolor[i] + else: + edgecolor_ = edgecolor or "None" + + ax.bar( + bin_, + height, + align="center", + width=width, + alpha=0.5, + label=col, + color=color_, + edgecolor=edgecolor_, + ) + ax.set_title(f"Histogram from {table!r}") + ax.legend() + + ax.set_ylabel("Count") + + return ax + + +@modify_exceptions +def _histogram( + table, column, bins, with_=None, conn=None, facet=None, breaks=None, binwidth=None +): + """Compute bins and heights""" + if not conn: + conn = sql.connection.ConnectionManager.current + use_backticks = conn.is_use_backtick_template() + + # Snowflake will use UPPERCASE in the table and column name + column = to_upper_if_snowflake_conn(conn, column) + table = to_upper_if_snowflake_conn(conn, table) + # FIXME: we're computing all the with elements twice + min_, max_ = _min_max(conn, table, column, with_=with_, use_backticks=use_backticks) + + # Define all relevant filters here + filter_query_1 = f'"{column}" IS NOT NULL' + + filter_query_2 = f"{facet['key']} == '{facet['value']}'" if facet else None + + filter_query = _filter_aggregate(filter_query_1, filter_query_2) + + bin_size = None + + if _are_numeric_values(min_, max_): + if breaks: + if min_ > breaks[-1]: + raise exceptions.UsageError( + f"All break points are lower than the min data point of {min_}." + ) + elif max_ < breaks[0]: + raise exceptions.UsageError( + f"All break points are higher than the max data point of {max_}." + ) + + cases, bin_size = [], [] + for b_start, b_end in zip(breaks[:-1], breaks[1:]): + case = f"WHEN {{{{column}}}} > {b_start} AND {{{{column}}}} <= {b_end} \ + THEN {(b_start+b_end)/2}" + cases.append(case) + bin_size.append(b_end - b_start) + cases[0] = cases[0].replace(">", ">=", 1) + bin_midpoints = [ + (b_start + b_end) / 2 for b_start, b_end in zip(breaks[:-1], breaks[1:]) + ] + all_bins = " union ".join([f"select {mid} as bin" for mid in bin_midpoints]) + + # Group data based on the intervals in breaks + # Left join is used to ensure count=0 + template_ = ( + "select all_bins.bin, coalesce(count_table.count, 0) as count " + f"from ({all_bins}) as all_bins " + "left join (" + f"select case {' '.join(cases)} end as bin, " + "count(*) as count " + "from {{table}} " + "{{filter_query}} " + "group by bin) " + "as count_table on all_bins.bin = count_table.bin " + "order by all_bins.bin;" + ) + + breaks_filter_query = ( + f'"{column}" >= {breaks[0]} and "{column}" <= {breaks[-1]}' + ) + filter_query = _filter_aggregate( + filter_query_1, filter_query_2, breaks_filter_query + ) + + if use_backticks: + template_ = template_.replace('"', "`") + table = table.replace('"', "`") + + template = Template(template_) + + query = template.render( + table=table, column=column, filter_query=filter_query + ) + elif not binwidth and not isinstance(bins, int): + raise ValueError( + f"bins are '{bins}'. Please specify a valid number of bins." + ) + else: + # Use bins - 1 instead of bins and round half down instead of floor + # to mimic right-closed histogram intervals in R ggplot + range_ = max_ - min_ + if binwidth: + bin_size = binwidth + if binwidth > range_: + message( + f"Specified binwidth {binwidth} is larger than " + f"the range {range_}. Please choose a smaller binwidth." + ) + else: + bin_size = range_ / (bins - 1) + template_ = """ + select + ceiling("{{column}}"/{{bin_size}} - 0.5)*{{bin_size}} as bin, + count(*) as count + from {{table}} + {{filter_query}} + group by bin + order by bin; + """ + + if use_backticks: + template_ = template_.replace('"', "`") + table = table.replace('"', "`") + + template = Template(template_) + + query = template.render( + table=table, column=column, bin_size=bin_size, filter_query=filter_query + ) + else: + template_ = """ + select + "{{column}}" as col, count ("{{column}}") + from {{table}} + {{filter_query}} + group by col + order by col; + """ + + if use_backticks: + template_ = template_.replace('"', "`") + table = table.replace('"', "`") + + template = Template(template_) + + query = template.render(table=table, column=column, filter_query=filter_query) + + data = conn.execute(query, with_).fetchall() + + bin_, height = zip(*data) + + return bin_, height, bin_size + + +@modify_exceptions +def _histogram_stacked( + table, + column, + category, + bins, + bin_size, + with_=None, + conn=None, + facet=None, + breaks=None, + binwidth=None, +): + """Compute the corresponding heights of each bin based on the category""" + if not conn: + conn = sql.connection.ConnectionManager.current + + cases = [] + if breaks: + breaks_filter_query = ( + f'"{column}" >= {breaks[0]} and "{column}" <= {breaks[-1]}' + ) + for b_start, b_end in zip(breaks[:-1], breaks[1:]): + case = f'SUM(CASE WHEN {column} > {b_start} AND {column} <= {b_end} \ + THEN 1 ELSE 0 END) AS "{(b_start+b_end)/2}",' + cases.append(case) + cases[0] = cases[0].replace(">", ">=", 1) + else: + if binwidth: + bin_size = binwidth + tolerance = bin_size / 1000 # Use to avoid floating point error + for bin in bins: + # Use round half down instead of floor to mimic + # right-closed histogram intervals in R ggplot + case = ( + f"SUM(CASE WHEN ABS(CEILING({column}/{bin_size} - 0.5)*{bin_size} " + f"- {bin}) <= {tolerance} THEN 1 ELSE 0 END) AS '{bin}'," + ) + cases.append(case) + + cases = " ".join(cases) + + filter_query_1 = f'"{column}" IS NOT NULL' + + filter_query_2 = f"{facet['key']} == '{facet['value']}'" if facet else None + + if breaks: + filter_query = _filter_aggregate( + filter_query_1, filter_query_2, breaks_filter_query + ) + else: + filter_query = _filter_aggregate(filter_query_1, filter_query_2) + + template = Template( + """ + SELECT {{category}}, + {{cases}} + FROM {{table}} + {{filter_query}} + GROUP BY {{category}} + ORDER BY {{category}} DESC; + """ + ) + query = template.render( + table=table, + column=column, + bin_size=bin_size, + category=category, + filter_query=filter_query, + cases=cases, + ) + + data = conn.execute(query, with_).fetchall() + + return data + + +@modify_exceptions +def _filter_aggregate(*filter_queries): + """Return a single filter query based on multiple queries. + + Parameters: + ---------- + *filter_queries (str): + Variable length argument list of filter queries. + Filter query is string with a filtering condition in SQL + (e.g., "age > 25"). + (e.g., "column is NULL"). + + Notes + ----- + .. versionadded:: 0.7.9 + + Returns: + ----- + final_filter (str): + A string that represents a SQL WHERE clause + + """ + final_filter = "" + for idx, query in enumerate(filter_queries): + if query is not None: + if idx == 0: + final_filter = f"{final_filter}WHERE {query}" + continue + final_filter = f"{final_filter} AND {query}" + return final_filter + + +@modify_exceptions +def _bar(table, column, with_=None, conn=None): + """get x and height for bar plot""" + if not conn: + conn = sql.connection.ConnectionManager.current + use_backticks = conn.is_use_backtick_template() + + if isinstance(column, list): + if len(column) > 2: + raise exceptions.UsageError( + f"Passed columns: {column}\n" + "Bar chart currently supports, either a single column" + " on which group by and count is applied or" + " two columns: labels and size" + ) + + x_ = column[0] + height_ = column[1] + + display.message(f"Removing NULLs, if there exists any from {x_} and {height_}") + template_ = """ + select "{{x_}}" as x, + "{{height_}}" as height + from {{table}} + where "{{x_}}" is not null + and "{{height_}}" is not null; + """ + + xlabel = x_ + ylabel = height_ + + if use_backticks: + template_ = template_.replace('"', "`") + table = table.replace('"', "`") + + template = Template(template_) + query = template.render(table=table, x_=x_, height_=height_) + + else: + display.message(f"Removing NULLs, if there exists any from {column}") + template_ = """ + select "{{column}}" as x, + count("{{column}}") as height + from {{table}} + where "{{column}}" is not null + group by "{{column}}"; + """ + + xlabel = column + ylabel = "Count" + + if use_backticks: + template_ = template_.replace('"', "`") + table = table.replace('"', "`") + + template = Template(template_) + query = template.render(table=table, column=column) + + data = conn.execute(query, with_).fetchall() + + x, height = zip(*data) + + if x[0] is None: + raise ValueError("Data contains NULLs") + + return x, height, xlabel, ylabel + + +@requires(["matplotlib"]) +def bar( + table, + column, + show_num=False, + orient="v", + with_=None, + conn=None, + cmap=None, + color=None, + edgecolor=None, + ax=None, + schema=None, +): + """Plot Bar Chart + + Parameters + ---------- + table : str + Table name where the data is located + + column : str + Column(s) to plot + + show_num: bool + Show numbers on top of plot + + orient : str, default='v' + Orientation of the plot. 'v' for vertical and 'h' for horizontal + + conn : connection, default=None + Database connection. If None, it uses the current connection + + Notes + ----- + + .. versionadded:: 0.7.6 + + Returns + ------- + ax : matplotlib.Axes + Generated plot + + """ + + if not conn: + conn = sql.connection.ConnectionManager.current + + _table = enclose_table_with_double_quotations(table, conn) + if schema: + _table = f'"{schema}"."{_table}"' + + ax = ax or plt.gca() + + if column is None: + raise exceptions.UsageError("Column name has not been specified") + + x, height_, xlabel, ylabel = _bar(_table, column, with_=with_, conn=conn) + + if color and cmap: + # raise a userwarning + warnings.warn( + "Both color and cmap are given. cmap will be ignored", UserWarning + ) + + if (not color) and cmap: + cmap = plt.get_cmap(cmap) + norm = Normalize(vmin=0, vmax=len(x)) + color = [cmap(norm(i)) for i in range(len(x))] + + if orient == "h": + ax.barh( + x, + height_, + align="center", + edgecolor=edgecolor, + color=color, + ) + ax.set_xlabel(ylabel) + ax.set_ylabel(xlabel) + else: + ax.bar( + x, + height_, + align="center", + edgecolor=edgecolor, + color=color, + ) + ax.set_ylabel(ylabel) + ax.set_xlabel(xlabel) + + if show_num: + if orient == "v": + for i, v in enumerate(height_): + ax.text( + i, + v, + str(v), + color="black", + fontweight="bold", + ha="center", + va="bottom", + ) + else: + for i, v in enumerate(height_): + ax.text( + v, + i, + str(v), + color="black", + fontweight="bold", + ha="left", + va="center", + ) + + ax.set_title(table) + + return ax + + +@modify_exceptions +def _pie(table, column, with_=None, conn=None): + """get x and height for pie chart""" + if not conn: + conn = sql.connection.ConnectionManager.current + use_backticks = conn.is_use_backtick_template() + + if isinstance(column, list): + if len(column) > 2: + raise exceptions.UsageError( + f"Passed columns: {column}\n" + "Pie chart currently supports, either a single column" + " on which group by and count is applied or" + " two columns: labels and size" + ) + + labels_ = column[0] + size_ = column[1] + + display.message( + f"Removing NULLs, if there exists any from {labels_} and {size_}" + ) + template_ = """ + select "{{labels_}}" as labels, + "{{size_}}" as size + from {{table}} + where "{{labels_}}" is not null + and "{{size_}}" is not null; + """ + if use_backticks: + template_ = template_.replace('"', "`") + table = table.replace('"', "`") + + template = Template(template_) + query = template.render(table=table, labels_=labels_, size_=size_) + + else: + display.message(f"Removing NULLs, if there exists any from {column}") + template_ = """ + select "{{column}}" as x, + count("{{column}}") as height + from {{table}} + where "{{column}}" is not null + group by "{{column}}"; + """ + if use_backticks: + template_ = template_.replace('"', "`") + table = table.replace('"', "`") + + template = Template(template_) + query = template.render(table=table, column=column) + + data = conn.execute(query, with_).fetchall() + + labels, size = zip(*data) + + if labels[0] is None: + raise ValueError("Data contains NULLs") + + return labels, size + + +@requires(["matplotlib"]) +def pie( + table, + column, + show_num=False, + with_=None, + conn=None, + cmap=None, + color=None, + ax=None, + schema=None, +): + """Plot Pie Chart + + Parameters + ---------- + table : str + Table name where the data is located + + column : str + Column(s) to plot + + show_num: bool + Show numbers on top of plot + + conn : connection, default=None + Database connection. If None, it uses the current connection + + Notes + ----- + + .. versionadded:: 0.7.6 + + Returns + ------- + ax : matplotlib.Axes + Generated plot + """ + + if not conn: + conn = sql.connection.ConnectionManager.current + + _table = enclose_table_with_double_quotations(table, conn) + if schema: + _table = f'"{schema}"."{_table}"' + + ax = ax or plt.gca() + + if column is None: + raise exceptions.UsageError("Column name has not been specified") + + labels, size_ = _pie(_table, column, with_=with_, conn=conn) + + if color and cmap: + # raise a userwarning + warnings.warn( + "Both color and cmap are given. cmap will be ignored", UserWarning + ) + + if (not color) and cmap: + cmap = plt.get_cmap(cmap) + norm = Normalize(vmin=0, vmax=len(labels)) + color = [cmap(norm(i)) for i in range(len(labels))] + + if show_num: + ax.pie( + size_, + labels=labels, + colors=color, + autopct="%1.2f%%", + ) + else: + ax.pie( + size_, + labels=labels, + colors=color, + ) + + ax.set_title(table) + + return ax diff --git a/src/sql/run.py b/src/sql/run.py deleted file mode 100644 index f4b03ddcd..000000000 --- a/src/sql/run.py +++ /dev/null @@ -1,402 +0,0 @@ -import codecs -import csv -import operator -import os.path -import re -from functools import reduce - -import prettytable -import six -import sqlalchemy -import sqlparse - -from .column_guesser import ColumnGuesserMixin - -try: - from pgspecial.main import PGSpecial -except ImportError: - PGSpecial = None - - -def unduplicate_field_names(field_names): - """Append a number to duplicate field names to make them unique. """ - res = [] - for k in field_names: - if k in res: - i = 1 - while k + "_" + str(i) in res: - i += 1 - k += "_" + str(i) - res.append(k) - return res - - -class UnicodeWriter(object): - """ - A CSV writer which will write rows to CSV file "f", - which is encoded in the given encoding. - """ - - def __init__(self, f, dialect=csv.excel, encoding="utf-8", **kwds): - # Redirect output to a queue - self.queue = six.StringIO() - self.writer = csv.writer(self.queue, dialect=dialect, **kwds) - self.stream = f - self.encoder = codecs.getincrementalencoder(encoding)() - - def writerow(self, row): - if six.PY2: - _row = [s.encode("utf-8") if hasattr(s, "encode") else s for s in row] - else: - _row = row - self.writer.writerow(_row) - # Fetch UTF-8 output from the queue ... - data = self.queue.getvalue() - if six.PY2: - data = data.decode("utf-8") - # ... and re-encode it into the target encoding - data = self.encoder.encode(data) - # write to the target stream - self.stream.write(data) - # empty queue - self.queue.truncate(0) - self.queue.seek(0) - - def writerows(self, rows): - for row in rows: - self.writerow(row) - - -class CsvResultDescriptor(object): - """Provides IPython Notebook-friendly output for the feedback after a ``.csv`` called.""" - - def __init__(self, file_path): - self.file_path = file_path - - def __repr__(self): - return "CSV results at %s" % os.path.join(os.path.abspath("."), self.file_path) - - def _repr_html_(self): - return 'CSV results' % os.path.join( - ".", "files", self.file_path - ) - - -def _nonbreaking_spaces(match_obj): - """ - Make spaces visible in HTML by replacing all `` `` with `` `` - - Call with a ``re`` match object. Retain group 1, replace group 2 - with nonbreaking speaces. - """ - spaces = " " * len(match_obj.group(2)) - return "%s%s" % (match_obj.group(1), spaces) - - -_cell_with_spaces_pattern = re.compile(r"()( {2,})") - - -class ResultSet(list, ColumnGuesserMixin): - """ - Results of a SQL query. - - Can access rows listwise, or by string value of leftmost column. - """ - - def __init__(self, sqlaproxy, sql, config): - self.keys = sqlaproxy.keys() - self.sql = sql - self.config = config - self.limit = config.autolimit - style_name = config.style - self.style = prettytable.__dict__[style_name.upper()] - if sqlaproxy.returns_rows: - if self.limit: - list.__init__(self, sqlaproxy.fetchmany(size=self.limit)) - else: - list.__init__(self, sqlaproxy.fetchall()) - self.field_names = unduplicate_field_names(self.keys) - self.pretty = PrettyTable(self.field_names, style=self.style) - # self.pretty.set_style(self.style) - else: - list.__init__(self, []) - self.pretty = None - - def _repr_html_(self): - _cell_with_spaces_pattern = re.compile(r"()( {2,})") - if self.pretty: - self.pretty.add_rows(self) - result = self.pretty.get_html_string() - result = _cell_with_spaces_pattern.sub(_nonbreaking_spaces, result) - if self.config.displaylimit and len(self) > self.config.displaylimit: - result = ( - '%s\n%d rows, truncated to displaylimit of %d' - % (result, len(self), self.config.displaylimit) - ) - return result - else: - return None - - def __str__(self, *arg, **kwarg): - self.pretty.add_rows(self) - return str(self.pretty or "") - - def __getitem__(self, key): - """ - Access by integer (row position within result set) - or by string (value of leftmost column) - """ - try: - return list.__getitem__(self, key) - except TypeError: - result = [row for row in self if row[0] == key] - if not result: - raise KeyError(key) - if len(result) > 1: - raise KeyError('%d results for "%s"' % (len(result), key)) - return result[0] - - def dict(self): - """Returns a single dict built from the result set - - Keys are column names; values are a tuple""" - return dict(zip(self.keys, zip(*self))) - - def dicts(self): - "Iterator yielding a dict for each row" - for row in self: - yield dict(zip(self.keys, row)) - - def DataFrame(self): - "Returns a Pandas DataFrame instance built from the result set." - import pandas as pd - - frame = pd.DataFrame(self, columns=(self and self.keys) or []) - return frame - - def pie(self, key_word_sep=" ", title=None, **kwargs): - """Generates a pylab pie chart from the result set. - - ``matplotlib`` must be installed, and in an - IPython Notebook, inlining must be on:: - - %%matplotlib inline - - Values (pie slice sizes) are taken from the - rightmost column (numerical values required). - All other columns are used to label the pie slices. - - Parameters - ---------- - key_word_sep: string used to separate column values - from each other in pie labels - title: Plot title, defaults to name of value column - - Any additional keyword arguments will be passsed - through to ``matplotlib.pylab.pie``. - """ - self.guess_pie_columns(xlabel_sep=key_word_sep) - import matplotlib.pylab as plt - - pie = plt.pie(self.ys[0], labels=self.xlabels, **kwargs) - plt.title(title or self.ys[0].name) - return pie - - def plot(self, title=None, **kwargs): - """Generates a pylab plot from the result set. - - ``matplotlib`` must be installed, and in an - IPython Notebook, inlining must be on:: - - %%matplotlib inline - - The first and last columns are taken as the X and Y - values. Any columns between are ignored. - - Parameters - ---------- - title: Plot title, defaults to names of Y value columns - - Any additional keyword arguments will be passsed - through to ``matplotlib.pylab.plot``. - """ - import matplotlib.pylab as plt - - self.guess_plot_columns() - self.x = self.x or range(len(self.ys[0])) - coords = reduce(operator.add, [(self.x, y) for y in self.ys]) - plot = plt.plot(*coords, **kwargs) - if hasattr(self.x, "name"): - plt.xlabel(self.x.name) - ylabel = ", ".join(y.name for y in self.ys) - plt.title(title or ylabel) - plt.ylabel(ylabel) - return plot - - def bar(self, key_word_sep=" ", title=None, **kwargs): - """Generates a pylab bar plot from the result set. - - ``matplotlib`` must be installed, and in an - IPython Notebook, inlining must be on:: - - %%matplotlib inline - - The last quantitative column is taken as the Y values; - all other columns are combined to label the X axis. - - Parameters - ---------- - title: Plot title, defaults to names of Y value columns - key_word_sep: string used to separate column values - from each other in labels - - Any additional keyword arguments will be passsed - through to ``matplotlib.pylab.bar``. - """ - import matplotlib.pylab as plt - - self.guess_pie_columns(xlabel_sep=key_word_sep) - plot = plt.bar(range(len(self.ys[0])), self.ys[0], **kwargs) - if self.xlabels: - plt.xticks(range(len(self.xlabels)), self.xlabels, rotation=45) - plt.xlabel(self.xlabel) - plt.ylabel(self.ys[0].name) - return plot - - def csv(self, filename=None, **format_params): - """Generate results in comma-separated form. Write to ``filename`` if given. - Any other parameters will be passed on to csv.writer.""" - if not self.pretty: - return None # no results - self.pretty.add_rows(self) - if filename: - encoding = format_params.get("encoding", "utf-8") - if six.PY2: - outfile = open(filename, "wb") - else: - outfile = open(filename, "w", newline="", encoding=encoding) - else: - outfile = six.StringIO() - writer = UnicodeWriter(outfile, **format_params) - writer.writerow(self.field_names) - for row in self: - writer.writerow(row) - if filename: - outfile.close() - return CsvResultDescriptor(filename) - else: - return outfile.getvalue() - - -def interpret_rowcount(rowcount): - if rowcount < 0: - result = "Done." - else: - result = "%d rows affected." % rowcount - return result - - -class FakeResultProxy(object): - """A fake class that pretends to behave like the ResultProxy from - SqlAlchemy. - """ - - def __init__(self, cursor, headers): - if cursor is None: - cursor = [] - headers = [] - if isinstance(cursor, list): - self.from_list(source_list=cursor) - else: - self.fetchall = cursor.fetchall - self.fetchmany = cursor.fetchmany - self.rowcount = cursor.rowcount - self.keys = lambda: headers - self.returns_rows = True - - def from_list(self, source_list): - "Simulates SQLA ResultProxy from a list." - - self.fetchall = lambda: source_list - self.rowcount = len(source_list) - - def fetchmany(size): - pos = 0 - while pos < len(source_list): - yield source_list[pos : pos + size] - pos += size - - self.fetchmany = fetchmany - - -# some dialects have autocommit -# specific dialects break when commit is used: - -_COMMIT_BLACKLIST_DIALECTS = ("athena", "bigquery", "clickhouse", "ingres", "mssql", "teradata", "vertica") - - -def _commit(conn, config): - """Issues a commit, if appropriate for current config and dialect""" - - _should_commit = config.autocommit and all( - dialect not in str(conn.dialect) for dialect in _COMMIT_BLACKLIST_DIALECTS - ) - - if _should_commit: - try: - conn.session.execute("commit") - except sqlalchemy.exc.OperationalError: - pass # not all engines can commit - - -def run(conn, sql, config, user_namespace): - if sql.strip(): - for statement in sqlparse.split(sql): - first_word = sql.strip().split()[0].lower() - if first_word == "begin": - raise Exception("ipython_sql does not support transactions") - if first_word.startswith("\\") and \ - ("postgres" in str(conn.dialect) or \ - "redshift" in str(conn.dialect)): - if not PGSpecial: - raise ImportError("pgspecial not installed") - pgspecial = PGSpecial() - _, cur, headers, _ = pgspecial.execute( - conn.session.connection.cursor(), statement - )[0] - result = FakeResultProxy(cur, headers) - else: - txt = sqlalchemy.sql.text(statement) - result = conn.session.execute(txt, user_namespace) - _commit(conn=conn, config=config) - if result and config.feedback: - print(interpret_rowcount(result.rowcount)) - resultset = ResultSet(result, statement, config) - if config.autopandas: - return resultset.DataFrame() - else: - return resultset - # returning only last result, intentionally - else: - return "Connected: %s" % conn.name - - -class PrettyTable(prettytable.PrettyTable): - def __init__(self, *args, **kwargs): - self.row_count = 0 - self.displaylimit = None - return super(PrettyTable, self).__init__(*args, **kwargs) - - def add_rows(self, data): - if self.row_count and (data.config.displaylimit == self.displaylimit): - return # correct number of rows already present - self.clear_rows() - self.displaylimit = data.config.displaylimit - if self.displaylimit == 0: - self.displaylimit = None # TODO: remove this to make 0 really 0 - if self.displaylimit in (None, 0): - self.row_count = len(data) - else: - self.row_count = min(len(data), self.displaylimit) - for row in data[: self.displaylimit]: - self.add_row(row) diff --git a/src/sql/run/__init__.py b/src/sql/run/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/sql/run/csv.py b/src/sql/run/csv.py new file mode 100644 index 000000000..fc553ee7d --- /dev/null +++ b/src/sql/run/csv.py @@ -0,0 +1,51 @@ +import os.path +import codecs +import csv +from io import StringIO + + +class CSVWriter: + """ + A CSV writer which will write rows to CSV file "f", + which is encoded in the given encoding. + """ + + def __init__(self, f, dialect=csv.excel, encoding="utf-8", **kwds): + # Redirect output to a queue + self.queue = StringIO() + self.writer = csv.writer(self.queue, dialect=dialect, **kwds) + self.stream = f + self.encoder = codecs.getincrementalencoder(encoding)() + + def writerow(self, row): + _row = row + self.writer.writerow(_row) + # Fetch UTF-8 output from the queue ... + data = self.queue.getvalue() + # write to the target stream + self.stream.write(data) + # empty queue + self.queue.truncate(0) + self.queue.seek(0) + + def writerows(self, rows): + for row in rows: + self.writerow(row) + + +class CSVResultDescriptor: + """ + Provides IPython Notebook-friendly output for the + feedback after a ``.csv`` called. + """ + + def __init__(self, file_path): + self.file_path = file_path + + def __repr__(self): + return "CSV results at %s" % os.path.join(os.path.abspath("."), self.file_path) + + def _repr_html_(self): + return 'CSV results' % os.path.join( + ".", "files", self.file_path + ) diff --git a/src/sql/run/pgspecial.py b/src/sql/run/pgspecial.py new file mode 100644 index 000000000..5bc2c5bf4 --- /dev/null +++ b/src/sql/run/pgspecial.py @@ -0,0 +1,55 @@ +try: + from pgspecial.main import PGSpecial +except ModuleNotFoundError: + PGSpecial = None + +from sql import exceptions + + +def handle_postgres_special(conn, statement): + """Execute a PostgreSQL special statement using PGSpecial module.""" + if not PGSpecial: + raise exceptions.MissingPackageError("pgspecial not installed") + + pgspecial = PGSpecial() + # TODO: support for raw psycopg2 connections + _, cur, headers, _ = pgspecial.execute( + conn.connection_sqlalchemy.connection.cursor(), statement + )[0] + return FakeResultProxy(cur, headers) + + +class FakeResultProxy(object): + """A fake class that pretends to behave like the ResultProxy from + SqlAlchemy. + """ + + def __init__(self, cursor, headers): + if cursor is None: + cursor = [] + headers = [] + if isinstance(cursor, list): + self.from_list(source_list=cursor) + else: + self.fetchall = cursor.fetchall + self.fetchmany = cursor.fetchmany + self.rowcount = cursor.rowcount + self.keys = lambda: headers + self.returns_rows = True + + def from_list(self, source_list): + "Simulates SQLA ResultProxy from a list." + + self.fetchall = lambda: source_list + self.rowcount = len(source_list) + + def fetchmany(size): + pos = 0 + while pos < len(source_list): + yield source_list[pos : pos + size] + pos += size + + self.fetchmany = fetchmany + + def close(self): + pass diff --git a/src/sql/run/resultset.py b/src/sql/run/resultset.py new file mode 100644 index 000000000..1ba76db81 --- /dev/null +++ b/src/sql/run/resultset.py @@ -0,0 +1,559 @@ +import re +import operator +from functools import reduce +from io import StringIO +from html import unescape +from collections.abc import Iterable + +import prettytable +import warnings + +from sql.column_guesser import ColumnGuesserMixin +from sql.run.csv import CSVWriter, CSVResultDescriptor +from sql.run.table import CustomPrettyTable +from sql._current import _config_feedback_all + +from sql.exceptions import RuntimeError + + +class ResultSet(ColumnGuesserMixin): + """ + Results of a SQL query. Fetches rows lazily (only the necessary rows to show the + preview based on the current configuration) + """ + + def __init__(self, sqlaproxy, config, statement=None, conn=None): + self._closed = False + self._config = config + self._statement = statement + self._sqlaproxy = sqlaproxy + self._conn = conn + self._dialect = conn._get_sqlglot_dialect() + self._keys = None + self._field_names = None + self._results = [] + # https://peps.python.org/pep-0249/#description + self._is_dbapi_results = hasattr(sqlaproxy, "description") + + # note that calling this will fetch the keys + self._pretty_table = self._init_table() + + self._mark_fetching_as_done = False + + if self._config.autolimit == 1: + # if autolimit is 1, we only want to fetch one row + self.fetchmany(size=1) + self._done_fetching() + else: + # in all other cases, 2 allows us to know if there are more rows + # for example when creating a table, the results contains one row, in + # such case, fetching 2 rows will tell us that there are no more rows + # and can set the _mark_fetching_as_done flag to True + self.fetchmany(size=2) + + self._finished_init = True + + if conn: + conn._result_sets.append(self) + + @property + def sqlaproxy(self): + conn = self._conn + + # mssql with pyodbc does not support multiple open result sets, so we need + # to close them all. when running this, we might've already closed the results + # so we need to check for that and re-open the results if needed + if conn.dialect == "mssql" and conn.driver == "pyodbc" and self._closed: + self._conn._result_sets.close_all() + self._sqlaproxy = self._conn.raw_execute(self._statement) + self._sqlaproxy.fetchmany(size=len(self._results)) + self._conn._result_sets.append(self) + + # there is a problem when using duckdb + sqlalchemy: duckdb-engine doesn't + # create separate cursors, so whenever we have >1 ResultSet, the old ones + # become outdated and fetching their results will return the results from + # the last ResultSet. To fix this, we have to re-issue the query + is_last_result = self._conn._result_sets.is_last(self) + + is_duckdb_sqlalchemy = ( + self._dialect == "duckdb" and not self._conn.is_dbapi_connection + ) + + if ( + # skip this if we're initializing the object (we're running __init__) + hasattr(self, "_finished_init") + # this only applies to duckdb + sqlalchemy with outdated results + and is_duckdb_sqlalchemy + and not is_last_result + ): + self._sqlaproxy = self._conn.raw_execute(self._statement) + self._sqlaproxy.fetchmany(size=len(self._results)) + + # ensure we make his result set the last one + self._conn._result_sets.append(self) + + return self._sqlaproxy + + def _extend_results(self, elements): + """Store the DB fetched results into the internal list of results""" + to_add = self._config.displaylimit - len(self._results) + self._results.extend(elements) + self._pretty_table.add_rows( + elements if self._config.displaylimit == 0 else elements[:to_add] + ) + + def mark_fetching_as_done(self): + self._mark_fetching_as_done = True + # NOTE: don't close the connection here (self.sqlaproxy.close()), + # because we need to keep it open for the next query + + def _done_fetching(self): + return self._mark_fetching_as_done + + @property + def field_names(self): + if self._field_names is None: + self._field_names = unduplicate_field_names(self.keys) + + return self._field_names + + @property + def keys(self): + """ + Return the keys of the results (the column names) + """ + if self._keys is not None: + return self._keys + + if not self._is_dbapi_results: + try: + self._keys = self.sqlaproxy.keys() + # sqlite with sqlalchemy raises sqlalchemy.exc.ResourceClosedError, + # psycopg2 raises psycopg2.ProgrammingError error when running a script + # that doesn't return rows e.g, 'CREATE TABLE' but others don't + # (e.g., duckdb), so here we catch all + except Exception: + self._keys = [] + return self._keys + + elif isinstance(self.sqlaproxy.description, Iterable): + self._keys = [i[0] for i in self.sqlaproxy.description] + else: + self._keys = [] + + return self._keys + + def _repr_html_(self): + self.fetch_for_repr_if_needed() + result = self._pretty_table.get_html_string() + return self._add_footer(result, html=True) + + def _add_footer(self, result, *, html): + if _config_feedback_all(): + data_frame_footer = ( + ( + "\n" + "ResultSet: to convert to pandas, call " + ".DataFrame() or to polars, call " + ".PolarsDataFrame()
" + ) + if html + else ( + "\nResultSet: to convert to pandas, call .DataFrame() " + "or to polars, call .PolarsDataFrame()" + ) + ) + + result = f"{result}{data_frame_footer}" + + # to create clickable links + result = unescape(result) + _cell_with_spaces_pattern = re.compile(r"()( {2,})") + result = _cell_with_spaces_pattern.sub(_nonbreaking_spaces, result) + + if self._config.displaylimit != 0 and not self._done_fetching(): + displaylimit_footer = ( + ( + '\n' + 'Truncated to ' + f"displaylimit of {self._config.displaylimit}." + ) + if html + else f"\nTruncated to displaylimit of {self._config.displaylimit}." + ) + + result = f"{result}{displaylimit_footer}" + + return result + + def __len__(self): + self.fetchall() + + return len(self._results) + + def __iter__(self): + self.fetchall() + + for result in self._results: + yield result + + def __str__(self): + self.fetch_for_repr_if_needed() + result = str(self._pretty_table) + return self._add_footer(result, html=False) + + def __repr__(self) -> str: + return str(self) + + def __eq__(self, another: object) -> bool: + return self._results == another + + def __getitem__(self, key): + """ + Access by integer (row position within result set) + or by string (value of leftmost column) + """ + try: + return self._results[key] + except TypeError: + result = [row for row in self if row[0] == key] + if not result: + raise KeyError(key) + if len(result) > 1: + raise KeyError('%d results for "%s"' % (len(result), key)) + return result[0] + + def __getattr__(self, attr): + err_msg = ( + f"'{attr}' is not a valid operation, you can convert this " + "into a pandas data frame by calling '.DataFrame()' or a " + "polars data frame by calling '.PolarsDataFrame()'" + ) + raise AttributeError(err_msg) + + def dict(self): + """Returns a single dict built from the result set + + Keys are column names; values are a tuple""" + return dict(zip(self.keys, zip(*self))) + + def dicts(self): + "Iterator yielding a dict for each row" + for row in self: + yield dict(zip(self.keys, row)) + + def DataFrame(self): + """Returns a Pandas DataFrame instance built from the result set.""" + import pandas as pd + + return _convert_to_data_frame(self, "df", pd.DataFrame) + + def PolarsDataFrame(self, **polars_dataframe_kwargs): + """Returns a Polars DataFrame instance built from the result set.""" + import polars as pl + + polars_dataframe_kwargs["schema"] = self.keys + return _convert_to_data_frame(self, "pl", pl.DataFrame, polars_dataframe_kwargs) + + def pie(self, key_word_sep=" ", title=None, **kwargs): + """Generates a pylab pie chart from the result set. + + ``matplotlib`` must be installed, and in an + IPython Notebook, inlining must be on:: + + %%matplotlib inline + + Values (pie slice sizes) are taken from the + rightmost column (numerical values required). + All other columns are used to label the pie slices. + + Parameters + ---------- + key_word_sep: string used to separate column values + from each other in pie labels + title: Plot title, defaults to name of value column + + Any additional keyword arguments will be passed + through to ``matplotlib.pylab.pie``. + """ + warnings.warn( + ( + ".pie() is deprecated and will be removed in a future version. " + "Use %sqlplot pie instead. " + "For more help, find us at https://ploomber.io/community " + ), + UserWarning, + ) + + self.guess_pie_columns(xlabel_sep=key_word_sep) + import matplotlib.pylab as plt + + ax = plt.gca() + + ax.pie(self.ys[0], labels=self.xlabels, **kwargs) + ax.set_title(title or self.ys[0].name) + return ax + + def plot(self, title=None, **kwargs): + """Generates a pylab plot from the result set. + + ``matplotlib`` must be installed, and in an + IPython Notebook, inlining must be on:: + + %%matplotlib inline + + The first and last columns are taken as the X and Y + values. Any columns between are ignored. + + Parameters + ---------- + title: Plot title, defaults to names of Y value columns + + Any additional keyword arguments will be passed + through to ``matplotlib.pylab.plot``. + """ + warnings.warn( + ( + ".plot() is deprecated and will be removed in a future version. " + "For more help, find us at https://ploomber.io/community " + ), + UserWarning, + ) + + import matplotlib.pylab as plt + + self.guess_plot_columns() + self.x = self.x or range(len(self.ys[0])) + + ax = plt.gca() + + coords = reduce(operator.add, [(self.x, y) for y in self.ys]) + ax.plot(*coords, **kwargs) + + if hasattr(self.x, "name"): + ax.set_xlabel(self.x.name) + + ylabel = ", ".join(y.name for y in self.ys) + + ax.set_title(title or ylabel) + ax.set_ylabel(ylabel) + + return ax + + def bar(self, key_word_sep=" ", title=None, **kwargs): + """Generates a pylab bar plot from the result set. + + ``matplotlib`` must be installed, and in an + IPython Notebook, inlining must be on:: + + %%matplotlib inline + + The last quantitative column is taken as the Y values; + all other columns are combined to label the X axis. + + Parameters + ---------- + title: Plot title, defaults to names of Y value columns + key_word_sep: string used to separate column values + from each other in labels + + Any additional keyword arguments will be passed + through to ``matplotlib.pylab.bar``. + """ + warnings.warn( + ( + ".bar() is deprecated and will be removed in a future version. " + "Use %sqlplot bar instead. " + "For more help, find us at https://ploomber.io/community " + ), + UserWarning, + ) + + import matplotlib.pylab as plt + + ax = plt.gca() + + self.guess_pie_columns(xlabel_sep=key_word_sep) + ax.bar(range(len(self.ys[0])), self.ys[0], **kwargs) + + if self.xlabels: + ax.set_xticks(range(len(self.xlabels)), self.xlabels, rotation=45) + + ax.set_xlabel(self.xlabel) + ax.set_ylabel(self.ys[0].name) + return ax + + def csv(self, filename=None, **format_params): + """Generate results in comma-separated form. Write to ``filename`` if given. + Any other parameters will be passed on to csv.writer.""" + if filename: + encoding = format_params.get("encoding", "utf-8") + outfile = open(filename, "w", newline="", encoding=encoding) + else: + outfile = StringIO() + + writer = CSVWriter(outfile, **format_params) + writer.writerow(self.field_names) + for row in self: + writer.writerow(row) + if filename: + outfile.close() + return CSVResultDescriptor(filename) + else: + return outfile.getvalue() + + def fetchmany(self, size): + """Fetch n results and add it to the results""" + if not self._done_fetching(): + try: + returned = self.sqlaproxy.fetchmany(size=size) + # sqlite with sqlalchemy raises sqlalchemy.exc.ResourceClosedError, + # psycopg2 raises psycopg2.ProgrammingError error when running a script + # that doesn't return rows e.g, 'CREATE TABLE' but others don't + # (e.g., duckdb), so here we catch all + except Exception as e: + if not any( + substring in str(e) + for substring in [ + "This result object does not return rows", + "no results to fetch", + ] + ): + # raise specific DB driver errors + raise RuntimeError(f"Error running the query: {str(e)}") from e + self.mark_fetching_as_done() + return + # spark doesn't support cursor + if hasattr(self._sqlaproxy, "dataframe"): + self._results = [] + self._pretty_table.clear() + self._extend_results(returned) + + if len(returned) < size: + self.mark_fetching_as_done() + + if ( + self._config.autolimit is not None + and self._config.autolimit != 0 + and len(self._results) >= self._config.autolimit + ): + self.mark_fetching_as_done() + + def fetch_for_repr_if_needed(self): + if self._config.displaylimit == 0: + self.fetchall() + + missing = self._config.displaylimit - len(self._results) + + if missing > 0: + self.fetchmany(missing) + + def fetchall(self): + if not self._done_fetching(): + if hasattr(self._sqlaproxy, "dataframe"): + self._results = [] + self._pretty_table.clear() + self._extend_results(self.sqlaproxy.fetchall()) + self.mark_fetching_as_done() + + def _init_table(self): + pretty = CustomPrettyTable(self.field_names) + + if isinstance(self._config.style, str): + _style = prettytable.TableStyle.__members__[self._config.style.upper()] + pretty.set_style(_style) + + return pretty + + def close(self): + self._sqlaproxy.close() + self._closed = True + + +def unduplicate_field_names(field_names): + """Append a number to duplicate field names to make them unique.""" + res = [] + for k in field_names: + if k in res: + i = 1 + while k + "_" + str(i) in res: + i += 1 + k += "_" + str(i) + res.append(k) + return res + + +def _convert_to_data_frame( + result_set, converter_name, constructor, constructor_kwargs=None +): + """ + Convert the result set to a pandas DataFrame, using native DuckDB methods if + possible + """ + constructor_kwargs = constructor_kwargs or {} + + # maybe create accessors in the connection objects? + if result_set._conn.is_dbapi_connection: + native_connection = result_set.sqlaproxy + elif hasattr(result_set.sqlaproxy, "dataframe"): + return result_set.sqlaproxy.dataframe.toPandas() + else: + native_connection = result_set._conn._connection.connection + + has_converter_method = hasattr(native_connection, converter_name) + + # native duckdb connection + if has_converter_method: + # we need to re-execute the statement because if we fetched some rows + # already, .df() will return None. But only if it's a select statement + # otherwise we might end up re-execute INSERT INTO or CREATE TABLE + # statements. + is_select = _statement_is_select(result_set._statement) + + if is_select: + # If command includes PIVOT, current transaction must be closed. + # Otherwise, re-executing the statement will return + # TransactionContext Error: cannot start a transaction within a transaction + if "pivot" in result_set._statement.lower(): + # fetchall retrieves the previous results and completes the transaction + # nothing is done with the results from fetchall() + native_connection.fetchall() + + native_connection.execute(result_set._statement) + + return getattr(native_connection, converter_name)() + else: + if converter_name == "df": + constructor_kwargs["columns"] = result_set.keys + + frame = constructor( + (tuple(row) for row in result_set), + **constructor_kwargs, + ) + + return frame + + +def _nonbreaking_spaces(match_obj): + """ + Make spaces visible in HTML by replacing all `` `` with `` `` + + Call with a ``re`` match object. Retain group 1, replace group 2 + with nonbreaking spaces. + """ + spaces = " " * len(match_obj.group(2)) + return "%s%s" % (match_obj.group(1), spaces) + + +def _statement_is_select(statement): + statement_ = statement.lower().strip() + # duckdb also allows FROM without SELECT + return ( + statement_.startswith("select") + or statement_.startswith("from") + or statement_.startswith("with") + or statement_.startswith("pivot") + ) diff --git a/src/sql/run/run.py b/src/sql/run/run.py new file mode 100644 index 000000000..a1e34aa7d --- /dev/null +++ b/src/sql/run/run.py @@ -0,0 +1,88 @@ +import sqlparse + +from sql import exceptions, display +from sql.run.resultset import ResultSet +from sql.run.pgspecial import handle_postgres_special + + +# TODO: conn also has access to config, we should clean this up to provide a clean +# way to access the config +def run_statements(conn, sql, config, parameters=None): + """ + Run a SQL query (supports running multiple SQL statements) with the given + connection. This is the function that's called when executing SQL magic. + + Parameters + ---------- + conn : sql.connection.AbstractConnection + The connection to use + + sql : str + SQL query to execution + + config + Configuration object + + Examples + -------- + + .. literalinclude:: ../../examples/run_statements.py + + """ + if not sql.strip(): + return "Connected: %s" % conn.name + + for statement in sqlparse.split(sql): + # strip all comments from sql + statement = sqlparse.format(statement, strip_comments=True) + # trailing comment after semicolon can be confused as its own statement, + # so we ignore it here. + if not statement: + continue + + first_word = sql.strip().split()[0].lower() + + if first_word == "begin": + raise exceptions.RuntimeError("JupySQL does not support transactions") + + # postgres metacommand + if first_word.startswith("\\") and is_postgres_or_redshift(conn.dialect): + result = handle_postgres_special(conn, statement) + + # regular query + else: + result = conn.raw_execute(statement, parameters=parameters) + if is_spark(conn.dialect) and config.lazy_execution: + return result.dataframe + + if ( + config.feedback >= 1 + and hasattr(result, "rowcount") + and result.rowcount > 0 + ): + display.message_success(f"{result.rowcount} rows affected.") + + result_set = ResultSet(result, config, statement, conn) + return select_df_type(result_set, config) + + +def is_postgres_or_redshift(dialect): + """Checks if dialect is postgres or redshift""" + return "postgres" in str(dialect) or "redshift" in str(dialect) + + +def is_spark(dialect): + return "spark" in str(dialect) + + +def select_df_type(resultset, config): + """ + Converts the input resultset to either a Pandas DataFrame + or Polars DataFrame based on the config settings. + """ + if config.autopandas: + return resultset.DataFrame() + elif config.autopolars: + return resultset.PolarsDataFrame(**config.polars_dataframe_kwargs) + else: + return resultset diff --git a/src/sql/run/sparkdataframe.py b/src/sql/run/sparkdataframe.py new file mode 100644 index 000000000..995193776 --- /dev/null +++ b/src/sql/run/sparkdataframe.py @@ -0,0 +1,52 @@ +try: + from pyspark.sql import DataFrame + from pyspark.sql.connect.dataframe import DataFrame as CDataFrame +except ModuleNotFoundError: + DataFrame = None + CDataFrame = None + +from sql import exceptions + + +def handle_spark_dataframe(dataframe, should_cache=False): + """Execute a ResultSet sqlaproxy using pyspark module.""" + if not DataFrame and not CDataFrame: + raise exceptions.MissingPackageError("pyspark not installed") + + return SparkResultProxy(dataframe, dataframe.columns, should_cache) + + +class SparkResultProxy(object): + """A fake class that pretends to behave like the ResultProxy from + SqlAlchemy. + """ + + dataframe = None + + def __init__(self, dataframe, headers, should_cache): + self.dataframe = dataframe + self.fetchall = dataframe.collect + self.rowcount = dataframe.count() + self.keys = lambda: headers + self.cursor = SparkCursor(headers) + self.returns_rows = True + if should_cache: + self.dataframe.cache() + + def fetchmany(self, size): + return self.dataframe.take(size) + + def fetchone(self): + return self.dataframe.head() + + def close(self): + self.dataframe.unpersist() + + +class SparkCursor(object): + """Class to extend to give SqlAlchemy Cursor like behaviour""" + + description = None + + def __init__(self, headers) -> None: + self.description = headers diff --git a/src/sql/run/table.py b/src/sql/run/table.py new file mode 100644 index 000000000..889b6ac07 --- /dev/null +++ b/src/sql/run/table.py @@ -0,0 +1,13 @@ +import prettytable + + +class CustomPrettyTable(prettytable.PrettyTable): + def add_rows(self, data): + for row in data: + formatted_row = [] + for cell in row: + if isinstance(cell, str) and cell.startswith("http"): + formatted_row.append("{}".format(cell, cell)) + else: + formatted_row.append(cell) + self.add_row(formatted_row) diff --git a/src/sql/stats.py b/src/sql/stats.py new file mode 100644 index 000000000..b03252154 --- /dev/null +++ b/src/sql/stats.py @@ -0,0 +1,117 @@ +from jinja2 import Template +from sqlalchemy.exc import ProgrammingError + +import sql.connection +from sql.util import flatten +from sql import exceptions + + +def _summary_stats(conn, table, column, with_=None): + if conn.dialect in {"duckdb", "postgresql"}: + return _summary_stats_parallel(conn, table, column, with_=with_) + elif conn.dialect in {"redshift"}: + return _summary_stats_redshift(conn, table, column, with_=with_) + else: + return _summary_stats_one_by_one(conn, table, column, with_=with_) + + +def _summary_stats_one_by_one(conn, table, column, with_=None): + if not conn: + conn = sql.connection.ConnectionManager.current.connection + + template_percentile = Template( + """ +SELECT +percentile_disc(0.25) WITHIN GROUP (ORDER BY "{{column}}") OVER (), +percentile_disc(0.50) WITHIN GROUP (ORDER BY "{{column}}") OVER (), +percentile_disc(0.75) WITHIN GROUP (ORDER BY "{{column}}") OVER () +FROM {{table}} +""" + ) + query = template_percentile.render(table=table, column=column) + + percentiles = list(conn.execute(query, with_).fetchone()) + + template = Template( + """ +SELECT +AVG("{{column}}") AS mean, +COUNT(*) AS N +FROM {{table}} +""" + ) + query = template.render(table=table, column=column) + + other = list(conn.execute(query, with_).fetchone()) + + keys = ["q1", "med", "q3", "mean", "N"] + return {k: float(v) for k, v in zip(keys, percentiles + other)} + + +def _summary_stats_redshift(conn, table, column, with_=None): + if not conn: + conn = sql.connection.ConnectionManager.current.connection + + template_percentile = Template( + """ +SELECT +approximate percentile_disc(0.25) WITHIN GROUP (ORDER BY "{{column}}"), +approximate percentile_disc(0.50) WITHIN GROUP (ORDER BY "{{column}}"), +approximate percentile_disc(0.75) WITHIN GROUP (ORDER BY "{{column}}") +FROM {{table}} +""" + ) + query = template_percentile.render(table=table, column=column) + + percentiles = list(conn.execute(query, with_).fetchone()) + + template = Template( + """ +SELECT +AVG("{{column}}") AS mean, +COUNT(*) AS N +FROM {{table}} +""" + ) + query = template.render(table=table, column=column) + + other = list(conn.execute(query, with_).fetchone()) + + keys = ["q1", "med", "q3", "mean", "N"] + + return {k: float(v) for k, v in zip(keys, percentiles + other)} + + +def _summary_stats_parallel(conn, table, column, with_=None): + """Compute percentiles and mean for boxplot""" + + if not conn: + conn = sql.connection.ConnectionManager.current + + driver = conn._get_database_information()["driver"] + + template = Template( + """ + SELECT + percentile_disc([0.25, 0.50, 0.75]) WITHIN GROUP \ + (ORDER BY "{{column}}") AS percentiles, + AVG("{{column}}") AS mean, + COUNT(*) AS N + FROM {{table}} +""" + ) + + query = template.render(table=table, column=column) + + try: + values = conn.execute(query, with_).fetchone() + except ProgrammingError as e: + print(e) + raise exceptions.RuntimeError( + f"\nEnsure that percentile_disc function is available on {driver}." + ) + except Exception as e: + raise e + + keys = ["q1", "med", "q3", "mean", "N"] + return {k: float(v) for k, v in zip(keys, flatten(values))} diff --git a/src/sql/store.py b/src/sql/store.py new file mode 100644 index 000000000..e36f99a19 --- /dev/null +++ b/src/sql/store.py @@ -0,0 +1,235 @@ +import sqlparse +from typing import Iterator, Iterable +from collections.abc import MutableMapping +from jinja2 import Template +from ploomber_core.exceptions import modify_exceptions +import sql.connection +import difflib + +from sql import exceptions +from sql import util + + +class SQLStore(MutableMapping): + """Stores SQL scripts to render large queries with CTEs + + Notes + ----- + .. versionadded:: 0.4.3 + + Examples + -------- + >>> from sql.store import SQLStore + >>> sqlstore = SQLStore() + >>> sqlstore.store("writers_fav", + ... "SELECT * FROM writers WHERE genre = 'non-fiction'") + >>> sqlstore.store("writers_fav_modern", + ... "SELECT * FROM writers_fav WHERE born >= 1970", + ... with_=["writers_fav"]) + >>> query = sqlstore.render("SELECT * FROM writers_fav_modern LIMIT 10", + ... with_=["writers_fav_modern"]) + >>> print(query) + WITH "writers_fav" AS ( + SELECT * FROM writers WHERE genre = 'non-fiction' + ), "writers_fav_modern" AS ( + SELECT * FROM writers_fav WHERE born >= 1970 + ) + SELECT * FROM writers_fav_modern LIMIT 10 + """ + + def __init__(self): + self._data = dict() + + def __setitem__(self, key: str, value: str) -> None: + self._data[key] = value + + def __getitem__(self, key) -> str: + if key not in self._data: + matches = difflib.get_close_matches(key, self._data) + error = f'"{key}" is not a valid snippet identifier.' + if matches: + raise exceptions.UsageError(error + f' Did you mean "{matches[0]}"?') + else: + valid = ", ".join(f'"{key}"' for key in self._data.keys()) + raise exceptions.UsageError(error + f" Valid identifiers are {valid}.") + return self._data[key] + + def __iter__(self) -> Iterator[str]: + for key in self._data: + yield key + + def __len__(self) -> int: + return len(self._data) + + def __delitem__(self, key: str) -> None: + del self._data[key] + + def render(self, query, with_=None): + # TODO: if with is false, WITH should not appear + return SQLQuery(self, query, with_) + + def infer_dependencies(self, query, key): + dependencies = [] + saved_keys = [ + saved_key for saved_key in list(self._data.keys()) if saved_key != key + ] + if saved_keys and query: + tables = util.extract_tables_from_query(query) + for table in tables: + if table in saved_keys: + dependencies.append(table) + return dependencies + + @modify_exceptions + def store(self, key, query, with_=None): + if "-" in key: + raise exceptions.UsageError( + "Using hyphens (-) in save argument isn't allowed." + " Please use underscores (_) instead" + ) + if with_ and key in with_: + raise exceptions.UsageError( + f"Script name ({key!r}) cannot appear in with_ argument" + ) + # We need to strip comments before storing else the comments + # are added within brackets as part of the CTE query, which + # causes the query to fail + query = sqlparse.format(query, strip_comments=True) + self._data[key] = SQLQuery(self, query, with_) + + +class SQLQuery: + """Holds queries and renders them""" + + def __init__(self, store: SQLStore, query: str, with_: Iterable = None): + self._store = store + self._query = query + self._with_ = with_ or [] + + if any("-" in x for x in self._with_): + raise exceptions.UsageError( + "Using hyphens is not allowed. " + "Please use " + + ", ".join(self._with_).replace("-", "_") + + " instead for the with argument.", + ) + + def __str__(self) -> str: + """ + We use the ' (backtick symbol) to wrap the CTE alias if the dialect supports + ` (backtick) + """ + with_clause_template = Template( + """WITH{% for name in with_ %} {{name}} AS ({{rts(saved[name]._query)}})\ +{{ "," if not loop.last }}{% endfor %}{{query}}""" + ) + + with_clause_template_backtick = Template( + """WITH{% for name in with_ %} `{{name}}` AS ({{rts(saved[name]._query)}})\ +{{ "," if not loop.last }}{% endfor %}{{query}}""" + ) + is_use_backtick = ( + sql.connection.ConnectionManager.current.is_use_backtick_template() + ) + with_all = _get_dependencies(self._store, self._with_) + template = ( + with_clause_template_backtick if is_use_backtick else with_clause_template + ) + # return query without 'with' when no dependency exists + if len(with_all) == 0: + return self._query.strip() + return template.render( + query=self._query, + saved=self._store._data, + with_=with_all, + rts=_remove_trailing_semicolon, + ) + + def remove_snippet_dependency(self, snippet): + if snippet in self._with_: + self._with_.remove(snippet) + + +def _remove_trailing_semicolon(query): + query_ = query.rstrip() + return query_[:-1] if query_[-1] == ";" else query + + +def _get_dependencies(store, keys): + """Get a list of all dependencies to reconstruct the CTEs in keys""" + # get the dependencies for each key + deps = _flatten([_get_dependencies_for_key(store, key) for key in keys]) + # remove duplicates but preserve order + return list(dict.fromkeys(deps + keys)) + + +def _get_dependencies_for_key(store, key): + """Retrieve dependencies for a single key""" + deps = store[key]._with_ + deps_of_deps = _flatten([_get_dependencies_for_key(store, dep) for dep in deps]) + return deps_of_deps + deps + + +def _flatten(elements): + """Flatten a list of lists""" + return [element for sub in elements for element in sub] + + +def get_dependents_for_key(store, key): + key_dependents = [] + for k in list(store): + deps = _get_dependencies_for_key(store, k) + if key in deps: + key_dependents.append(k) + return key_dependents + + +def get_all_keys(): + """ + Function to get list of all stored snippets in the current session + """ + return list(store) + + +def get_key_dependents(key: str) -> list: + """ + Function to find the stored snippets dependent on key + Parameters + ---------- + key : str, name of the table + + Returns + ------- + list + List of snippets dependent on key + + """ + deps = get_dependents_for_key(store, key) + return deps + + +def del_saved_key(key: str) -> str: + """ + Deletes a stored snippet + Parameters + ---------- + key : str, name of the snippet to be deleted + + Returns + ------- + list + Remaining stored snippets + """ + all_keys = get_all_keys() + if key not in all_keys: + raise exceptions.UsageError(f"No such saved snippet found : {key}") + del store[key] + return get_all_keys() + + +def is_saved_snippet(table: str) -> bool: + return table in get_all_keys() + + +# session-wide store +store = SQLStore() diff --git a/src/sql/traits.py b/src/sql/traits.py new file mode 100644 index 000000000..c6f52ec63 --- /dev/null +++ b/src/sql/traits.py @@ -0,0 +1,55 @@ +from traitlets import TraitError, TraitType +from sql import display +import warnings + +VALUE_WARNING = ( + 'Please use a valid option: "warn", "enabled", or "disabled". \n' + "For more information, " + "see the docs: " + "https://jupysql.ploomber.io/en/latest/api/configuration.html#named-parameters" +) + + +class Parameters(TraitType): + def __init__(self, **kwargs): + super(Parameters, self).__init__(**kwargs) + + def validate(self, obj, value): + if isinstance(value, bool): + if value: + warnings.warn( + "named_parameters: boolean values are now deprecated. " + f'Value {value} will be treated as "enabled". \n' + f"{VALUE_WARNING}", + FutureWarning, + ) + return "enabled" + else: + warnings.warn( + "named_parameters: boolean values are now deprecated. " + f'Value {value} will be treated as "warn" (default). \n' + f"{VALUE_WARNING}", + FutureWarning, + ) + return "warn" + elif isinstance(value, str): + if not value: + display.message( + 'named_parameters: Value "" will be treated as "warn" (default)' + ) + return "warn" + + value = value.lower() + if value not in ("warn", "enabled", "disabled"): + raise TraitError( + f"{value} is not a valid option for named_parameters. " + f'Valid options are: "warn", "enabled", or "disabled".' + ) + + return value + + else: + raise TraitError( + f"{value} is not a valid option for named_parameters. " + f'Valid options are: "warn", "enabled", or "disabled".' + ) diff --git a/src/sql/util.py b/src/sql/util.py new file mode 100644 index 000000000..4eadcf1f8 --- /dev/null +++ b/src/sql/util.py @@ -0,0 +1,683 @@ +import warnings +import difflib +from sql import exceptions, display +import json +from pathlib import Path +from sqlglot import parse_one, exp +from sqlglot.errors import ParseError +from sqlalchemy.exc import SQLAlchemyError +from ploomber_core.dependencies import requires + +try: + from pyspark.sql.utils import AnalysisException +except ModuleNotFoundError: + AnalysisException = None + +import ast +from os.path import isfile +import re + +from jinja2 import Template + + +try: + import toml +except ModuleNotFoundError: + toml = None + +SINGLE_QUOTE = "'" +DOUBLE_QUOTE = '"' + +CONFIGURATION_DOCS_STR = "https://jupysql.ploomber.io/en/latest/api/configuration.html#loading-from-a-file" # noqa + + +def sanitize_identifier(identifier): + if (identifier[0] == SINGLE_QUOTE and identifier[-1] == SINGLE_QUOTE) or ( + identifier[0] == DOUBLE_QUOTE and identifier[-1] == DOUBLE_QUOTE + ): + return identifier[1:-1] + else: + return identifier + + +def convert_to_scientific(value): + """ + Converts value to scientific notation if necessary + + Parameters + ---------- + value : any + Value to format. + """ + if ( + isinstance(value, (int, float)) + and not isinstance(value, bool) + and _is_long_number(value) + ): + new_value = "{:,.3e}".format(value) + + else: + new_value = value + + return new_value + + +def _is_long_number(num) -> bool: + """ + Checks if num's digits > 10 + """ + if "." in str(num): + split_by_decimal = str(num).split(".") + if len(split_by_decimal[0]) > 10 or len(split_by_decimal[1]) > 10: + return True + return False + + +def get_suggestions_message(suggestions): + suggestions_message = "" + if len(suggestions) > 0: + _suggestions_string = pretty_print(suggestions, last_delimiter="or") + suggestions_message = f"\nDid you mean: {_suggestions_string}" + return suggestions_message + + +def pretty_print( + obj: list, delimiter: str = ",", last_delimiter: str = "and", repr_: bool = False +) -> str: + """ + Returns a formatted string representation of an array + """ + if repr_: + sorted_ = sorted(repr(element) for element in obj) + else: + sorted_ = sorted(f"'{element}'" for element in obj) + + if len(sorted_) > 1: + sorted_[-1] = f"{last_delimiter} {sorted_[-1]}" + + return f"{delimiter} ".join(sorted_) + + +def strip_multiple_chars(string: str, chars: str) -> str: + """ + Trims characters from the start and end of the string + """ + return string.translate(str.maketrans("", "", chars)) + + +def flatten(src, ltypes=(list, tuple)): + """The flatten function creates a new tuple / list + with all sub-tuple / sub-list elements concatenated into it recursively + + Parameters + ---------- + src : tuple / list + Source tuple / list with all sub-tuple / sub-list elements + ltypes : tuple, optional + sub element's data type, by default (list, tuple) + + Returns + ------- + tuple / list + Flatten tuple / list + """ + ltype = type(src) + # Create a process list to handle flatten elements + process_list = list(src) + i = 0 + while i < len(process_list): + while isinstance(process_list[i], ltypes): + if not process_list[i]: + process_list.pop(i) + i -= 1 + break + else: + process_list[i : i + 1] = process_list[i] + i += 1 + + # If input src data type is tuple, return tuple + if not isinstance(process_list, ltype): + return tuple(process_list) + return process_list + + +def parse_sql_results_to_json(rows, columns) -> str: + """ + Serializes sql rows to a JSON formatted ``str`` + """ + dicts = [dict(zip(list(columns), row)) for row in rows] + rows_json = json.dumps(dicts, indent=4, sort_keys=True, default=str).replace( + "null", '"None"' + ) + + return rows_json + + +def show_deprecation_warning(): + """ + Raises CTE deprecation warning + """ + warnings.warn( + "CTE dependencies are now automatically inferred, " + "you can omit the --with arguments. Using --with will " + "raise an exception in the next major release so please remove it.", + FutureWarning, + ) + + +def check_duplicate_arguments( + magic_execute, cmd_from, args, allowed_duplicates=None, disallowed_aliases=None +) -> bool: + """ + Raises UsageError when duplicate arguments are passed to magics. + Returns true if no duplicates in arguments or aliases. + + Parameters + ---------- + magic_execute + The execute method of the magic class. + cmd_from + Which magic class invoked this function. One of 'sql', 'sqlplot' or 'sqlcmd'. + args + The arguments passed to the magic command. + allowed_duplicates + The duplicate arguments that are allowed for the class which invoked this + function. Defaults to None. + disallowed_aliases + The aliases for the arguments that are not allowed to be used together + for the class that invokes this function. Defaults to None. + + Returns + ------- + boolean + When there are no duplicates, a True bool is returned. + """ + allowed_duplicates = allowed_duplicates or [] + disallowed_aliases = disallowed_aliases or {} + + aliased_arguments = {} + unaliased_arguments = [] + + # Separates the aliased_arguments and unaliased_arguments. + # Aliased arguments example: '-w' and '--with' + if cmd_from != "sqlcmd": + for decorator in magic_execute.decorators: + decorator_args = decorator.args + if len(decorator_args) > 1: + aliased_arguments[decorator_args[0]] = decorator_args[1] + else: + if decorator_args[0].startswith("--") or decorator_args[0].startswith( + "-" + ): + unaliased_arguments.append(decorator_args[0]) + + if aliased_arguments == {}: + aliased_arguments = disallowed_aliases + + # Separate arguments from passed options + args = [arg for arg in args if arg.startswith("--") or arg.startswith("-")] + + # Separate single and double hyphen arguments + # Using sets here for better performance of looking up hash tables + single_hyphen_opts = set() + double_hyphen_opts = set() + + for arg in args: + if arg.startswith("--"): + double_hyphen_opts.add(arg) + elif arg.startswith("-"): + single_hyphen_opts.add(arg) + + # Get duplicate arguments + duplicate_args = [] + visited_args = set() + for arg in args: + if arg not in allowed_duplicates: + if arg not in visited_args: + visited_args.add(arg) + else: + duplicate_args.append(arg) + + # Check if alias pairs are present and track the pair for the error message + # Example: would filter out `-w` and `--with` if both are present + alias_pairs_present = [ + (opt, aliased_arguments[opt]) + for opt in single_hyphen_opts + if opt in aliased_arguments + if aliased_arguments[opt] in double_hyphen_opts + ] + + # Generate error message based on presence of duplicates and + # aliased arguments + error_message = "" + if duplicate_args: + duplicates_error = ( + f"Duplicate arguments in %{cmd_from}. " + "Please use only one of each of the following: " + f"{', '.join(sorted(duplicate_args))}. " + ) + else: + duplicates_error = "" + + if alias_pairs_present: + arg_list = sorted([" or ".join(pair) for pair in alias_pairs_present]) + alias_error = ( + f"Duplicate aliases for arguments in %{cmd_from}. " + "Please use either one of " + f"{', '.join(arg_list)}." + ) + else: + alias_error = "" + + error_message = f"{duplicates_error}{alias_error}" + + # If there is an error message to be raised, raise it + if error_message: + raise exceptions.UsageError(error_message) + + return True + + +def find_path_from_root(file_name): + """ + Recursively finds an absolute path to file_name starting + from current to root directory + """ + current = Path().resolve() + while not (current / file_name).exists(): + if current == current.parent: + return None + + current = current.parent + + return Path(current, file_name) + + +def find_close_match(word, possibilities): + """Find closest match between invalid input and possible options""" + return difflib.get_close_matches(word, possibilities) + + +def find_close_match_config(word, possibilities, n=3): + """Finds closest matching configurations and displays message""" + closest_matches = difflib.get_close_matches(word, possibilities, n=n) + if not closest_matches: + display.message_html( + f"'{word}' is an invalid configuration. Please review our " + "" # noqa + "configuration guideline." + ) + else: + display.message( + f"'{word}' is an invalid configuration. Did you mean " + f"{pretty_print(closest_matches, last_delimiter='or')}?" + ) + + +def get_line_content_from_toml(file_path, line_number): + """ + Locates a line that error occurs when loading a toml file + and returns the line, key, and value + """ + with open(file_path, "r") as file: + lines = file.readlines() + eline = lines[line_number - 1].strip() + ekey, evalue = None, None + if "=" in eline: + ekey, evalue = map(str.strip, eline.split("=")) + return eline, ekey, evalue + + +def to_upper_if_snowflake_conn(conn, upper): + return ( + upper.upper() + if callable(conn._get_sqlglot_dialect) + and conn._get_sqlglot_dialect() == "snowflake" + else upper + ) + + +@requires(["toml"]) +def load_toml(file_path): + """ + Returns toml file content in a dictionary format + and raises error if it fails to load the toml file + """ + try: + with open(file_path, "r") as file: + content = file.read() + return toml.loads(content) + except toml.TomlDecodeError as e: + raise parse_toml_error(e, file_path) + + +def parse_toml_error(e, file_path): + eline, ekey, evalue = get_line_content_from_toml(file_path, e.lineno) + if "Duplicate keys!" in str(e): + return exceptions.ConfigurationError( + f"Duplicate key found: '{ekey}' in {file_path}" + ) + elif "Only all lowercase booleans" in str(e): + return exceptions.ConfigurationError( + f"Invalid value '{evalue}' in '{eline}' in {file_path}. " + "Valid boolean values: true, false" + ) + elif "invalid literal for int()" in str(e): + return exceptions.ConfigurationError( + f"Invalid value '{evalue}' in '{eline}' in {file_path}. " + "To use str value, enclose it with ' or \"." + ) + else: + return e + + +def get_user_configs(primary_path, alternate_path): + """ + Returns saved configuration settings in a toml file from given file_path + + Parameters + ---------- + primary_path : Path + file path to toml in project directory + alternate_path : Path + file path to ~/.jupysql/config + + Returns + ------- + dict + saved configuration settings + Path + the path of the file used to get user configurations + """ + data = None + display_tip = True # Set to true if tip is to be displayed + configuration_docs_displayed = False # To disable showing guidelines once shown + + # Look for user configurations in pyproject.toml and ~/.jupysql/config + # in that particular order + path_list = [primary_path, alternate_path] + for file_path in path_list: + section_found = False + if file_path and file_path.exists(): + data = load_toml(file_path) + + data = data.get("tool") + + # Look for jupysql section under tool + if data: + keys = data.keys() + data = data.get("jupysql") + if data is None: + similar_key = case_insensitive_match("jupysql", keys) + if similar_key: + display.message( + f"Hint: We found 'tool.{similar_key}' in {file_path}. " + f"Did you mean 'tool.jupysql'?" + ) + + # Look for SqlMagic section under jupysql + if data: + keys = data.keys() + data = data.get("SqlMagic") + if data is None: + similar_key_list = find_close_match("SqlMagic", keys) + if similar_key_list: + raise exceptions.ConfigurationError( + f"[tool.jupysql.{similar_key_list[0]}] is an " + f"invalid section name in {file_path}. " + f"Did you mean [tool.jupysql.SqlMagic]?" + ) + + if data is None: + if display_tip: + display.message( + f"Tip: You may define configurations in {primary_path}" + f" or {alternate_path}. " + ) + display_tip = False + elif data == {}: + section_found = True + display.message( + f"[tool.jupysql.SqlMagic] present in {file_path} but empty. " + ) + display_tip = False + else: + section_found = True + + if not display_tip and not configuration_docs_displayed: + display.message_html( + f"Please review our " + "configuration guideline." + ) + configuration_docs_displayed = True + + if not data and not section_found and file_path and file_path.exists(): + display.message(f"Did not find user configurations in {file_path}.") + elif section_found and data: + return data, file_path + + return None, None + + +def get_default_configs(sql): + """ + Returns a dictionary of SqlMagic configuration settings users can set + with their default values. + """ + default_configs = sql.trait_defaults() + del default_configs["parent"] + del default_configs["config"] + return default_configs + + +def _are_numeric_values(*values): + return all([isinstance(value, (int, float)) for value in values]) + + +def validate_mutually_exclusive_args(arg_names, args): + """ + Raises ValueError if a list of values from arg_names filtered by + args' boolean representations is longer than one. + + Parameters + ---------- + arg_names : list + args' names in string + args : list + args values + """ + specified_args = [arg_name for arg_name, arg in zip(arg_names, args) if arg] + if len(specified_args) > 1: + raise exceptions.ValueError( + f"{pretty_print(specified_args)} are specified. " + "You can only specify one of them." + ) + + +def validate_nonidentifier_connection(arg): + """ + Raises UsageError if a connection is passed to `%sql/%%sql` through + object property, list, or dictionary. + + Parameters + ---------- + arg : str + argument to check whether it is a valid connection or not + """ + if not arg.isidentifier() and is_valid_python_code(arg) and not arg.endswith(";"): + raise exceptions.UsageError( + f"'{arg}' is not a valid connection identifier. " + "Please pass the variable's name directly, as passing " + "object attributes, dictionaries or lists won't work." + ) + + +def is_valid_python_code(code): + try: + ast.parse(code) + return True + except SyntaxError: + return False + + +def extract_tables_from_query(query): + """ + Function to extract names of tables from + a syntactically correct query + + Parameters + ---------- + query : str, user query + + Returns + ------- + list + List of tables in the query + [] if error in parsing the query + """ + try: + tables = [ + table.name + for table in parse_one(query).find_all(exp.Table) + if hasattr(table, "name") + ] + return tables + except ParseError: + # TODO : Instead of returning [] return the + # exact parse error + return [] + + +def is_sqlalchemy_error(error): + """Function to check if error is SQLAlchemy error""" + return isinstance(error, SQLAlchemyError) + + +def is_non_sqlalchemy_error(error): + """Function to check if error is a specific non-SQLAlchemy error""" + specific_db_errors = [ + "duckdb.CatalogException", + "Catalog Error", + "Parser Error", + "pyodbc.ProgrammingError", + # Clickhouse errors + "DB::Exception:", + ] + is_pyspark_analysis_exception = ( + isinstance(error, AnalysisException) if AnalysisException else False + ) + return ( + any(msg in str(error) for msg in specific_db_errors) + or is_pyspark_analysis_exception + ) + + +def if_substring_exists(string, substrings): + """Function to check if any of substring in + substrings exist in string""" + return any((msg in string) or (re.search(msg, string)) for msg in substrings) + + +def enclose_table_with_double_quotations(table, conn): + """ + Function to enclose a file path, schema name, + or table name with double quotations + """ + if isfile(table): + _table = f'"{table}"' + elif "." in table and not table.startswith('"'): + parts = table.split(".") + _table = f'"{parts[0]}"."{parts[1]}"' + else: + _table = table + + use_backticks = conn.is_use_backtick_template() + if use_backticks: + _table = _table.replace('"', "`") + + return _table + + +def is_rendering_required(line): + """Function to check possibility of line + text containing expandable arguments""" + + return "{{" in line and "}}" in line + + +def render_string_using_namespace(value, user_ns): + """ + Function to substitute command line arguments + with variables defined by user in the IPython + kernel. + + Parameters + ---------- + value : str, + text to be rendered + + user_ns : dict, + User namespace of IPython kernel + """ + + if isinstance(value, str) and value.startswith("{{") and value.endswith("}}"): + return Template(value).render(user_ns) + return value + + +def expand_args(args, user_ns): + """ + Function to substitute command line arguments + with variables defined by user in the IPython + kernel. + + Parameters + ---------- + args : argparse.Namespace, + object to hold the command line arguments. + + user_ns : dict, + User namespace of IPython kernel + """ + + for attribute in vars(args): + value = getattr(args, attribute) + if value: + if isinstance(value, list): + substituted_value = [] + for item in value: + rendered_value = render_string_using_namespace(item, user_ns) + substituted_value.append(rendered_value) + setattr(args, attribute, substituted_value) + else: + rendered_value = render_string_using_namespace(value, user_ns) + setattr(args, attribute, rendered_value) + + +def case_insensitive_match(target, string_list): + """ + Perform a case-insensitive match of a target string against a list of strings. + + Parameters + ---------- + target : str + The target string to match. + string_list : list of str + The list of strings to search through. + + Returns + ------- + str or None + The first matching string from the list, preserving its original case, + or None if there is no match. + + Examples + -------- + >>> case_insensitive_match('foo', ['bar', 'FOO']) + 'FOO' + """ + target_lower = target.lower() + for string in string_list: + if string.lower() == target_lower: + return string + return None diff --git a/src/sql/warnings.py b/src/sql/warnings.py new file mode 100644 index 000000000..977bbba08 --- /dev/null +++ b/src/sql/warnings.py @@ -0,0 +1,6 @@ +class JupySQLQuotedNamedParametersWarning(UserWarning): + pass + + +class JupySQLRollbackPerformed(UserWarning): + pass diff --git a/src/sql/widgets/__init__.py b/src/sql/widgets/__init__.py new file mode 100644 index 000000000..d19ba8bf0 --- /dev/null +++ b/src/sql/widgets/__init__.py @@ -0,0 +1,3 @@ +from sql.widgets.table_widget.table_widget import TableWidget + +__all__ = ["TableWidget"] diff --git a/src/sql/widgets/table_widget/__init__.py b/src/sql/widgets/table_widget/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/sql/widgets/table_widget/css/tableWidget.css b/src/sql/widgets/table_widget/css/tableWidget.css new file mode 100644 index 000000000..9dd3826f5 --- /dev/null +++ b/src/sql/widgets/table_widget/css/tableWidget.css @@ -0,0 +1,23 @@ +.sort-button { + background: none; + border: none; +} + +.sort-button.selected { + background: #efefef; + border: 1px solid #767676; +} + +.pages-buttons button.selected { + background: #efefef; + border: 1px solid #767676; + border-radius: 2px; +} +.pages-buttons button { + background: none; + border: none; + padding: 0 10px; +} +.jupysql-table-widget { + display: inline; +} \ No newline at end of file diff --git a/src/sql/widgets/table_widget/js/tableWidget.js b/src/sql/widgets/table_widget/js/tableWidget.js new file mode 100644 index 000000000..6148cae19 --- /dev/null +++ b/src/sql/widgets/table_widget/js/tableWidget.js @@ -0,0 +1,472 @@ +function isJupyterNotebook() { + return window["Jupyter"]; +} + +function getTable(element) { + let table; + if (element) { + const tableContainer = element.closest(".table-container"); + table = tableContainer.querySelector("table"); + } else { + const _isJupyterNotebook = isJupyterNotebook(); + if (_isJupyterNotebook) { + table = document.querySelector(".selected .table-container table"); + } else { + table = document.querySelector(".jp-Cell.jp-mod-active .table-container table"); + } + } + + return table; +} + +function getSortDetails() { + let sort = undefined; + + const table = getTable(); + if (table) { + const column = table.getAttribute("sort-by-column"); + const order = table.getAttribute("sort-by-order"); + + if (column && order) { + sort = { + "column" : column, + "order" : order + } + } + } + + return sort; +} + +function sortColumnClick(element, column, order, callback) { + // fetch data with sort logic + const table = getTable(element); + table.setAttribute("sort-by-column", column); + table.setAttribute("sort-by-order", order); + const rowsPerPage = table.getAttribute("rows-per-page"); + const currrPage = table.getAttribute("curr-page-idx"); + + const sort = { + 'column' : column, + 'order' : order + } + + const fetchParameters = { + rowsPerPage : parseInt(rowsPerPage), + page : parseInt(currrPage), + sort : sort, + table : table.getAttribute("table-name") + } + + fetchTableData(fetchParameters, callback) +} + +function fetchTableData(fetchParameters, callback) { + + sendObject = { + 'nRows' : fetchParameters.rowsPerPage, + 'page': fetchParameters.page, + 'table' : fetchParameters.table + } + + if (fetchParameters.sort) { + sendObject.sort = fetchParameters.sort + } + + const _isJupyterNotebook = isJupyterNotebook(); + + + if (_isJupyterNotebook) { + // for Jupyter Notebook + const comm = + Jupyter.notebook.kernel.comm_manager.new_comm('comm_target_handle_table_widget', {}) + comm.send(sendObject) + comm.on_msg(function(msg) { + const rows = JSON.parse(msg.content.data['rows']); + if (callback) { + callback(rows) + } + }); + } else{ + // for JupyterLab + dispatchEventToKernel(sendObject) + + const controller = new AbortController(); + + document.addEventListener('onTableWidgetRowsReady', (customEvent) => { + const rows = JSON.parse(customEvent.detail.data.rows) + controller.abort() + if (callback) { + callback(rows) + } + }, {signal: controller.signal}) + } + + +} + + +function dispatchEventToKernel(data) { + let customEvent = new CustomEvent('onUpdateTableWidget', { + bubbles: true, + cancelable: true, + composed: false, + detail : { + data : data + } + }); + document.body.dispatchEvent(customEvent) +} + +function handleRowsNumberOfRowsChange(e) { + const rowsPerPage = parseInt(e.value); + let table = getTable(); + table.setAttribute('rows-per-page', rowsPerPage); + + const nTotal = table.getAttribute('n-total'); + + const maxPages = Math.ceil(nTotal / rowsPerPage) + table.setAttribute('max-pages', maxPages); + + const fetchParameters = { + rowsPerPage : rowsPerPage, + page : 0, + sort : getSortDetails(), + table : table.getAttribute("table-name") + } + + setTimeout(() => { + fetchTableData(fetchParameters, (rows) => { + updateTable(rows); + }) + }, 100); +} + +function updateTable(rows, currPage, tableToUpdate) { + const table = tableToUpdate || getTable(); + const trs = table.querySelectorAll("tbody tr"); + const tbody = table.querySelector("tbody"); + tbody.innerHTML = ""; + + const _html = createTableRows(rows) + + tbody.innerHTML = _html + + setTimeout(() => { + updatePaginationBar(table, currPage || 0) + }, 100) +} + +function createTableRows(rows) { + const _html = rows.map(function(row) { + const tds = + Object.keys(row).map(function(key) { + + return "" + row[key] + "" + }).join("") ; + return "" + tds + ""; + }).join(""); + + return _html +} + +function showTablePage(page, rowsPerPage, data) { + const table = getTable(); + const trs = table.querySelectorAll("tbody tr"); + const tbody = table.querySelector("tbody"); + tbody.innerHTML = ""; + + const rows = data; + const startIndex = page * rowsPerPage; + const endIndex = startIndex + rowsPerPage; + const _html = rows.map(row => { + const tds = + Object.keys(row).map(key => `${row[key]}`).join(""); + return `${tds}`; + }).join(""); + + tbody.innerHTML = _html; + + table.setAttribute("curr-page-idx", page); + updatePaginationBar(table, page); +} + +function nextPageClick(element) { + const table = getTable(element); + const currPageIndex = parseInt(table.getAttribute("curr-page-idx")); + const rowsPerPage = parseInt(table.getAttribute("rows-per-page")); + const maxPages = parseInt(table.getAttribute("max-pages")); + + const nextPage = currPageIndex + 1; + if (nextPage < maxPages) { + const fetchParameters = { + rowsPerPage : rowsPerPage, + page : nextPage, + sort : getSortDetails(), + table : table.getAttribute("table-name") + } + + fetchTableData(fetchParameters, (rows) => { + showTablePage(nextPage, rowsPerPage, rows) + }); + } + +} + +function prevPageClick() { + const table = getTable(); + const currPageIndex = parseInt(table.getAttribute("curr-page-idx")); + const rowsPerPage = parseInt(table.getAttribute("rows-per-page")); + const prevPage = currPageIndex - 1; + if (prevPage >= 0) { + const fetchParameters = { + rowsPerPage : rowsPerPage, + page : prevPage, + sort : getSortDetails(), + table : table.getAttribute("table-name") + } + + fetchTableData(fetchParameters, (rows) => { + showTablePage(prevPage, rowsPerPage, rows) + }); + } +} + +function setPageButton(table, label, navigateTo, isSelected) { + const rowsPerPage = parseInt(table.getAttribute("rows-per-page")); + const selected = isSelected ? "selected" : ""; + + const button = ` + + ` + return button; +} + +function updatePaginationBar(table, currPage) { + const maxPages = parseInt(table.getAttribute("max-pages")); + const maxPagesInRow = 6; + const rowsPerPage = parseInt(table.getAttribute("rows-per-page")); + table.setAttribute("curr-page-idx", currPage); + + let buttonsArray = [] + + let startEllipsisAdded = false + let endEllipsisAdded = false + + // add first + let selected = currPage === 0; + buttonsArray.push(setPageButton(table, "1", 0, selected)); + + for (i = 1; i < maxPages - 1; i++) { + const navigateTo = i; + const label = i + 1; + selected = currPage === i; + const inStartRange = currPage < maxPagesInRow; + const inEndRange = maxPages - 1 - currPage < maxPagesInRow; + + if (inStartRange) { + if (i < maxPagesInRow) { + buttonsArray + .push(setPageButton(table, label, navigateTo, selected)); + } else { + if (!startEllipsisAdded) { + buttonsArray.push("..."); + startEllipsisAdded = true; + } + } + } else if (inEndRange) { + if (maxPages - 1 - i < maxPagesInRow) { + buttonsArray + .push(setPageButton(table, label, navigateTo, selected)); + } else { + if (!endEllipsisAdded) { + buttonsArray.push("..."); + endEllipsisAdded = true; + } + } + } + + if (!inStartRange && !inEndRange) { + if (currPage === i-2) { + buttonsArray.push("..."); + } + if ( + currPage === i - 1 || + currPage === i || + currPage === i + 1 + ) { + buttonsArray + .push(setPageButton(table, label, navigateTo, selected)) + } + + if (currPage === i+2) { + buttonsArray.push("..."); + } + + } + } + + selected = currPage === maxPages - 1 ? "selected" : ""; + + buttonsArray. + push(setPageButton(table, maxPages, maxPages - 1, selected)) + + const buttonsHtml = buttonsArray.join(""); + table.parentNode + .querySelector(".pages-buttons").innerHTML = buttonsHtml; +} + +function removeSelectionFromAllSortButtons() { + document.querySelectorAll(".sort-button") + .forEach(el => el.classList.remove("selected")) +} + +function initTable() { + // template variables we should pass + const initialRows = {{initialRows}}; + const columns = {{columns}}; + const rowsPerPage={{rows_per_page}}; + const nPages={{n_pages}}; + const nTotal={{n_total}}; + const tableName="{{table_name}}"; + const tableContainerId = "{{table_container_id}}"; + const options = [10, 25, 50, 100]; + options_html = + options.map(option => ``); + + + let ths_ = columns.map(col => `${col}`).join(""); + + let table = ` +
+ Show + + entries +
+ + + + + ${ths_} + + + + + +
+ + +
+ +
+
+ +
+ ` + + let tableContainer = document.querySelector(`#${tableContainerId}`); + + tableContainer.innerHTML = table + + if (initialRows) { + initializeTableRows(tableContainer, rowsPerPage, initialRows) + + } else { + setTimeout(() => { + const fetchParameters = { + rowsPerPage : rowsPerPage, + page : 0, + sort : getSortDetails(), + table : tableName + } + + fetchTableData(fetchParameters, (rows) => { + initializeTableRows(tableContainer, rowsPerPage, rows) + }) + }, 100); + } + +} + +function initializeTableRows(tableContainer, rowsPerPage, rows) { + updateTable(rows, 0, + tableContainer.querySelector("table")); + // update ths_ to make sure order columns + // are matching the data + if (rows.length > 0) { + let row = rows[0]; + let ths_ = + Object.keys(row).map(col => + ` +
+ ${col} + + + + +
+ + `).join(""); + let thead = tableContainer.querySelector("thead") + thead.innerHTML = ths_ + } +} + +initTable() \ No newline at end of file diff --git a/src/sql/widgets/table_widget/table_widget.py b/src/sql/widgets/table_widget/table_widget.py new file mode 100644 index 000000000..cdd157161 --- /dev/null +++ b/src/sql/widgets/table_widget/table_widget.py @@ -0,0 +1,196 @@ +from sql.connection import ConnectionManager +from IPython import get_ipython +import math +import time +from sql.util import parse_sql_results_to_json +from sql.inspect import fetch_sql_with_pagination, is_table_exists +from sql.widgets import utils + +import os +from ploomber_core.dependencies import check_installed + +# Widget base dir +BASE_DIR = os.path.dirname(__file__) + + +class TableWidget: + def __init__(self, table, schema=None): + """ + Creates an HTML table element and populates it with SQL table + + Parameters + ---------- + table : str + Table name where the data is located + """ + + self.html = "" + + is_table_exists(table, schema) + + # load css + html_style = utils.load_css(f"{BASE_DIR}/css/tableWidget.css") + self.add_to_html(html_style) + + self.create_table(table, schema) + + # register listener for jupyter lab + self.register_comm() + + # load_tests + self.load_tests() + + def _repr_html_(self): + return self.html + + def add_to_html(self, html): + self.html += html + + def create_table(self, table, schema): + """ + Creates an HTML table with default data + """ + if schema: + table_ = f"{schema}.{table}" + else: + table_ = table + + rows_per_page = 10 + rows, columns = fetch_sql_with_pagination(table_, 0, rows_per_page) + rows = parse_sql_results_to_json(rows, columns) + + query = f"SELECT count(*) FROM {table_}" + n_total = ConnectionManager.current.raw_execute(query).fetchone()[0] + table_name = table_.strip('"').strip("'") + + n_pages = math.ceil(n_total / rows_per_page) + + unique_id = str(int(time.time())) + table_container_id = f"tableContainer_{unique_id}" + + # Create table container with unique id + table_container_html = f""" +
+ """ + self.add_to_html(table_container_html) + + html_scripts = utils.load_js( + [ + f"{BASE_DIR}/js/tableWidget.js", + utils.set_template_params( + columns=list(columns), + rows_per_page=rows_per_page, + n_pages=n_pages, + n_total=n_total, + table_name=table_name, + table_container_id=table_container_id, + table=table_, + initialRows=rows, + ), + ] + ) + self.add_to_html(html_scripts) + + def load_tests(self): + """ + Define which JS functions we should + include in this widget's test unit. + + Example: + + Given following the html: + + + + We can include `drawList` in the test unit by extracting it + from the html and add it to this widget's test property. + + self.tests["drawList"] = utils.extract_function_by_name( + html, "drawList" + ) + + + Testing with pytest: + + import js2py + + def test_draw_list(expected): + expected = "
  • item1
  • item2
" + + table_widget = TableWidget("empty_table") + + result = js2py.eval_js(table_widget.tests["drawList"]) + + assert result == expected + + """ + self.tests = dict() + self.tests["createTableRows"] = utils.extract_function_by_name( + self.html, "createTableRows" + ) + + def register_comm(self): + """ + Register communication between the frontend and the kernel. + """ + + check_installed( + ["jupysql_plugin"], "jupysql-plugin", pip_names=["jupysql-plugin"] + ) + + def comm_handler(comm, open_msg): + """ + Handle received messages from the frontend + """ + + @comm.on_msg + def _recv(msg): + data = msg["content"]["data"] + n_rows = data["nRows"] + page = data["page"] + + sort_column = None + sort_order = None + table_name = data["table"] + + if "sort" in data: + sort = data["sort"] + sort_column = sort["column"] + sort_order = sort["order"] + + offset = page * n_rows + + rows, columns = fetch_sql_with_pagination( + table_name, + offset, + n_rows, + sort_column=sort_column, + sort_order=sort_order, + ) + rows_json = parse_sql_results_to_json(rows, columns) + + comm.send({"rows": rows_json}) + + ipython = get_ipython() + + if hasattr(ipython, "kernel"): + ipython.kernel.comm_manager.register_target( + "comm_target_handle_table_widget", comm_handler + ) diff --git a/src/sql/widgets/utils.py b/src/sql/widgets/utils.py new file mode 100644 index 000000000..c2df88817 --- /dev/null +++ b/src/sql/widgets/utils.py @@ -0,0 +1,92 @@ +import re +from jinja2 import Template + + +def load_file(file_path) -> str: + """ + Returns the content of a file + """ + with open(file_path, mode="r") as file: + return file.read() + + +def load_js(*files) -> str: + """ + Loads js files into HTML + """ + + +def load_css(*files) -> str: + """ + Loads css files into HTML + """ + + +def set_template_params(**kwargs): + """ + Returns parameters in a dict format for Jinja2 template. + + We can use it when loading JS files with custom parameters. + + e.g. + html_scripts = utils.load_js([path_to_file, + set_template_params( + param_one = 1, + param_one = 2) + ] + ) + """ + return kwargs + + +def extract_function_by_name(source, function_name) -> str: + """ + Return function str by name from string + + Parameters + ---------- + source : str + Text to extract JS function from + + function_name : str + The name of the function to extract + """ + pattern = ( + r"function\s+" + + function_name + + r"\s*\([^)]*\)\s*\{((?:[^{}]+|\{(?:[^{}]+|\{[^{}]*\})*\})*)\}" + ) + match = re.search(pattern, source) + if match: + return match.group(0) + else: + return None diff --git a/src/tests/baseline_images/test_ggplot/boxplot.png b/src/tests/baseline_images/test_ggplot/boxplot.png new file mode 100644 index 000000000..f540683d1 Binary files /dev/null and b/src/tests/baseline_images/test_ggplot/boxplot.png differ diff --git a/src/tests/baseline_images/test_ggplot/facet_wrap_custom_fill.png b/src/tests/baseline_images/test_ggplot/facet_wrap_custom_fill.png new file mode 100644 index 000000000..43f4ce361 Binary files /dev/null and b/src/tests/baseline_images/test_ggplot/facet_wrap_custom_fill.png differ diff --git a/src/tests/baseline_images/test_ggplot/facet_wrap_custom_fill_and_color.png b/src/tests/baseline_images/test_ggplot/facet_wrap_custom_fill_and_color.png new file mode 100644 index 000000000..d619c56fd Binary files /dev/null and b/src/tests/baseline_images/test_ggplot/facet_wrap_custom_fill_and_color.png differ diff --git a/src/tests/baseline_images/test_ggplot/facet_wrap_custom_stacked_histogram.png b/src/tests/baseline_images/test_ggplot/facet_wrap_custom_stacked_histogram.png new file mode 100644 index 000000000..c17c6811d Binary files /dev/null and b/src/tests/baseline_images/test_ggplot/facet_wrap_custom_stacked_histogram.png differ diff --git a/src/tests/baseline_images/test_ggplot/facet_wrap_custom_stacked_histogram_cmap.png b/src/tests/baseline_images/test_ggplot/facet_wrap_custom_stacked_histogram_cmap.png new file mode 100644 index 000000000..871892f7a Binary files /dev/null and b/src/tests/baseline_images/test_ggplot/facet_wrap_custom_stacked_histogram_cmap.png differ diff --git a/src/tests/baseline_images/test_ggplot/facet_wrap_default.png b/src/tests/baseline_images/test_ggplot/facet_wrap_default.png new file mode 100644 index 000000000..39c4941be Binary files /dev/null and b/src/tests/baseline_images/test_ggplot/facet_wrap_default.png differ diff --git a/src/tests/baseline_images/test_ggplot/facet_wrap_default_no_legend.png b/src/tests/baseline_images/test_ggplot/facet_wrap_default_no_legend.png new file mode 100644 index 000000000..0c4c3b434 Binary files /dev/null and b/src/tests/baseline_images/test_ggplot/facet_wrap_default_no_legend.png differ diff --git a/src/tests/baseline_images/test_ggplot/facet_wrap_default_with_nulls.png b/src/tests/baseline_images/test_ggplot/facet_wrap_default_with_nulls.png new file mode 100644 index 000000000..39c4941be Binary files /dev/null and b/src/tests/baseline_images/test_ggplot/facet_wrap_default_with_nulls.png differ diff --git a/src/tests/baseline_images/test_ggplot/facet_wrap_nulls_data.png b/src/tests/baseline_images/test_ggplot/facet_wrap_nulls_data.png new file mode 100644 index 000000000..9f03070f3 Binary files /dev/null and b/src/tests/baseline_images/test_ggplot/facet_wrap_nulls_data.png differ diff --git a/src/tests/baseline_images/test_ggplot/histogram_binwidth_facet_wrap.png b/src/tests/baseline_images/test_ggplot/histogram_binwidth_facet_wrap.png new file mode 100644 index 000000000..588831221 Binary files /dev/null and b/src/tests/baseline_images/test_ggplot/histogram_binwidth_facet_wrap.png differ diff --git a/src/tests/baseline_images/test_ggplot/histogram_binwidth_with_multiple_cols.png b/src/tests/baseline_images/test_ggplot/histogram_binwidth_with_multiple_cols.png new file mode 100644 index 000000000..be068e91d Binary files /dev/null and b/src/tests/baseline_images/test_ggplot/histogram_binwidth_with_multiple_cols.png differ diff --git a/src/tests/baseline_images/test_ggplot/histogram_categorical.png b/src/tests/baseline_images/test_ggplot/histogram_categorical.png new file mode 100644 index 000000000..23c440bd2 Binary files /dev/null and b/src/tests/baseline_images/test_ggplot/histogram_categorical.png differ diff --git a/src/tests/baseline_images/test_ggplot/histogram_categorical_combined.png b/src/tests/baseline_images/test_ggplot/histogram_categorical_combined.png new file mode 100644 index 000000000..b05db5f1f Binary files /dev/null and b/src/tests/baseline_images/test_ggplot/histogram_categorical_combined.png differ diff --git a/src/tests/baseline_images/test_ggplot/histogram_custom_color.png b/src/tests/baseline_images/test_ggplot/histogram_custom_color.png new file mode 100644 index 000000000..162c2a51a Binary files /dev/null and b/src/tests/baseline_images/test_ggplot/histogram_custom_color.png differ diff --git a/src/tests/baseline_images/test_ggplot/histogram_custom_fill.png b/src/tests/baseline_images/test_ggplot/histogram_custom_fill.png new file mode 100644 index 000000000..6d4f25d6e Binary files /dev/null and b/src/tests/baseline_images/test_ggplot/histogram_custom_fill.png differ diff --git a/src/tests/baseline_images/test_ggplot/histogram_custom_fill_and_color.png b/src/tests/baseline_images/test_ggplot/histogram_custom_fill_and_color.png new file mode 100644 index 000000000..134c538b1 Binary files /dev/null and b/src/tests/baseline_images/test_ggplot/histogram_custom_fill_and_color.png differ diff --git a/src/tests/baseline_images/test_ggplot/histogram_default.png b/src/tests/baseline_images/test_ggplot/histogram_default.png new file mode 100644 index 000000000..f327b8ac0 Binary files /dev/null and b/src/tests/baseline_images/test_ggplot/histogram_default.png differ diff --git a/src/tests/baseline_images/test_ggplot/histogram_numeric_categorical_combined.png b/src/tests/baseline_images/test_ggplot/histogram_numeric_categorical_combined.png new file mode 100644 index 000000000..71d21eb01 Binary files /dev/null and b/src/tests/baseline_images/test_ggplot/histogram_numeric_categorical_combined.png differ diff --git a/src/tests/baseline_images/test_ggplot/histogram_numeric_categorical_combined_custom_fill.png b/src/tests/baseline_images/test_ggplot/histogram_numeric_categorical_combined_custom_fill.png new file mode 100644 index 000000000..a3416ccfe Binary files /dev/null and b/src/tests/baseline_images/test_ggplot/histogram_numeric_categorical_combined_custom_fill.png differ diff --git a/src/tests/baseline_images/test_ggplot/histogram_numeric_categorical_combined_custom_multi_color.png b/src/tests/baseline_images/test_ggplot/histogram_numeric_categorical_combined_custom_multi_color.png new file mode 100644 index 000000000..02ba730be Binary files /dev/null and b/src/tests/baseline_images/test_ggplot/histogram_numeric_categorical_combined_custom_multi_color.png differ diff --git a/src/tests/baseline_images/test_ggplot/histogram_numeric_categorical_combined_custom_multi_fill.png b/src/tests/baseline_images/test_ggplot/histogram_numeric_categorical_combined_custom_multi_fill.png new file mode 100644 index 000000000..0d9a36aa1 Binary files /dev/null and b/src/tests/baseline_images/test_ggplot/histogram_numeric_categorical_combined_custom_multi_fill.png differ diff --git a/src/tests/baseline_images/test_ggplot/histogram_stacked_custom_cmap.png b/src/tests/baseline_images/test_ggplot/histogram_stacked_custom_cmap.png new file mode 100644 index 000000000..feff9a792 Binary files /dev/null and b/src/tests/baseline_images/test_ggplot/histogram_stacked_custom_cmap.png differ diff --git a/src/tests/baseline_images/test_ggplot/histogram_stacked_custom_color.png b/src/tests/baseline_images/test_ggplot/histogram_stacked_custom_color.png new file mode 100644 index 000000000..ee77b2559 Binary files /dev/null and b/src/tests/baseline_images/test_ggplot/histogram_stacked_custom_color.png differ diff --git a/src/tests/baseline_images/test_ggplot/histogram_stacked_custom_color_and_fill.png b/src/tests/baseline_images/test_ggplot/histogram_stacked_custom_color_and_fill.png new file mode 100644 index 000000000..606acec17 Binary files /dev/null and b/src/tests/baseline_images/test_ggplot/histogram_stacked_custom_color_and_fill.png differ diff --git a/src/tests/baseline_images/test_ggplot/histogram_stacked_default.png b/src/tests/baseline_images/test_ggplot/histogram_stacked_default.png new file mode 100644 index 000000000..8cdbad5e3 Binary files /dev/null and b/src/tests/baseline_images/test_ggplot/histogram_stacked_default.png differ diff --git a/src/tests/baseline_images/test_ggplot/histogram_stacked_large_bins.png b/src/tests/baseline_images/test_ggplot/histogram_stacked_large_bins.png new file mode 100644 index 000000000..37584293f Binary files /dev/null and b/src/tests/baseline_images/test_ggplot/histogram_stacked_large_bins.png differ diff --git a/src/tests/baseline_images/test_ggplot/histogram_stacked_with_binwidth.png b/src/tests/baseline_images/test_ggplot/histogram_stacked_with_binwidth.png new file mode 100644 index 000000000..5c704a37a Binary files /dev/null and b/src/tests/baseline_images/test_ggplot/histogram_stacked_with_binwidth.png differ diff --git a/src/tests/baseline_images/test_ggplot/histogram_stacked_with_breaks.png b/src/tests/baseline_images/test_ggplot/histogram_stacked_with_breaks.png new file mode 100644 index 000000000..1e1bee4b1 Binary files /dev/null and b/src/tests/baseline_images/test_ggplot/histogram_stacked_with_breaks.png differ diff --git a/src/tests/baseline_images/test_ggplot/histogram_stacked_with_extreme_breaks.png b/src/tests/baseline_images/test_ggplot/histogram_stacked_with_extreme_breaks.png new file mode 100644 index 000000000..83c1f16b6 Binary files /dev/null and b/src/tests/baseline_images/test_ggplot/histogram_stacked_with_extreme_breaks.png differ diff --git a/src/tests/baseline_images/test_ggplot/histogram_with_binwidth.png b/src/tests/baseline_images/test_ggplot/histogram_with_binwidth.png new file mode 100644 index 000000000..f3565b727 Binary files /dev/null and b/src/tests/baseline_images/test_ggplot/histogram_with_binwidth.png differ diff --git a/src/tests/baseline_images/test_ggplot/histogram_with_breaks.png b/src/tests/baseline_images/test_ggplot/histogram_with_breaks.png new file mode 100644 index 000000000..cbe674d2d Binary files /dev/null and b/src/tests/baseline_images/test_ggplot/histogram_with_breaks.png differ diff --git a/src/tests/baseline_images/test_ggplot/histogram_with_default.png b/src/tests/baseline_images/test_ggplot/histogram_with_default.png new file mode 100644 index 000000000..35d7c9a01 Binary files /dev/null and b/src/tests/baseline_images/test_ggplot/histogram_with_default.png differ diff --git a/src/tests/baseline_images/test_ggplot/histogram_with_narrow_binwidth.png b/src/tests/baseline_images/test_ggplot/histogram_with_narrow_binwidth.png new file mode 100644 index 000000000..8b4d69e16 Binary files /dev/null and b/src/tests/baseline_images/test_ggplot/histogram_with_narrow_binwidth.png differ diff --git a/src/tests/baseline_images/test_magic_plot/bar_one_col.png b/src/tests/baseline_images/test_magic_plot/bar_one_col.png new file mode 100644 index 000000000..9096669d6 Binary files /dev/null and b/src/tests/baseline_images/test_magic_plot/bar_one_col.png differ diff --git a/src/tests/baseline_images/test_magic_plot/bar_one_col_h.png b/src/tests/baseline_images/test_magic_plot/bar_one_col_h.png new file mode 100644 index 000000000..1fb31d680 Binary files /dev/null and b/src/tests/baseline_images/test_magic_plot/bar_one_col_h.png differ diff --git a/src/tests/baseline_images/test_magic_plot/bar_one_col_null.png b/src/tests/baseline_images/test_magic_plot/bar_one_col_null.png new file mode 100644 index 000000000..9096669d6 Binary files /dev/null and b/src/tests/baseline_images/test_magic_plot/bar_one_col_null.png differ diff --git a/src/tests/baseline_images/test_magic_plot/bar_one_col_num_h.png b/src/tests/baseline_images/test_magic_plot/bar_one_col_num_h.png new file mode 100644 index 000000000..c09a93f02 Binary files /dev/null and b/src/tests/baseline_images/test_magic_plot/bar_one_col_num_h.png differ diff --git a/src/tests/baseline_images/test_magic_plot/bar_one_col_num_v.png b/src/tests/baseline_images/test_magic_plot/bar_one_col_num_v.png new file mode 100644 index 000000000..4b482d7c5 Binary files /dev/null and b/src/tests/baseline_images/test_magic_plot/bar_one_col_num_v.png differ diff --git a/src/tests/baseline_images/test_magic_plot/bar_two_col.png b/src/tests/baseline_images/test_magic_plot/bar_two_col.png new file mode 100644 index 000000000..2798537e2 Binary files /dev/null and b/src/tests/baseline_images/test_magic_plot/bar_two_col.png differ diff --git a/src/tests/baseline_images/test_magic_plot/bar_with_table_in_schema.png b/src/tests/baseline_images/test_magic_plot/bar_with_table_in_schema.png new file mode 100644 index 000000000..3db659855 Binary files /dev/null and b/src/tests/baseline_images/test_magic_plot/bar_with_table_in_schema.png differ diff --git a/src/tests/baseline_images/test_magic_plot/boxplot.png b/src/tests/baseline_images/test_magic_plot/boxplot.png new file mode 100644 index 000000000..a49300719 Binary files /dev/null and b/src/tests/baseline_images/test_magic_plot/boxplot.png differ diff --git a/src/tests/baseline_images/test_magic_plot/boxplot_duckdb.png b/src/tests/baseline_images/test_magic_plot/boxplot_duckdb.png new file mode 100644 index 000000000..a49300719 Binary files /dev/null and b/src/tests/baseline_images/test_magic_plot/boxplot_duckdb.png differ diff --git a/src/tests/baseline_images/test_magic_plot/boxplot_h.png b/src/tests/baseline_images/test_magic_plot/boxplot_h.png new file mode 100644 index 000000000..bd326a805 Binary files /dev/null and b/src/tests/baseline_images/test_magic_plot/boxplot_h.png differ diff --git a/src/tests/baseline_images/test_magic_plot/boxplot_null.png b/src/tests/baseline_images/test_magic_plot/boxplot_null.png new file mode 100644 index 000000000..e148626ad Binary files /dev/null and b/src/tests/baseline_images/test_magic_plot/boxplot_null.png differ diff --git a/src/tests/baseline_images/test_magic_plot/boxplot_two.png b/src/tests/baseline_images/test_magic_plot/boxplot_two.png new file mode 100644 index 000000000..9709d8ae6 Binary files /dev/null and b/src/tests/baseline_images/test_magic_plot/boxplot_two.png differ diff --git a/src/tests/baseline_images/test_magic_plot/boxplot_with_table_in_schema.png b/src/tests/baseline_images/test_magic_plot/boxplot_with_table_in_schema.png new file mode 100644 index 000000000..e57e2e734 Binary files /dev/null and b/src/tests/baseline_images/test_magic_plot/boxplot_with_table_in_schema.png differ diff --git a/src/tests/baseline_images/test_magic_plot/hist.png b/src/tests/baseline_images/test_magic_plot/hist.png new file mode 100644 index 000000000..be5ca4d58 Binary files /dev/null and b/src/tests/baseline_images/test_magic_plot/hist.png differ diff --git a/src/tests/baseline_images/test_magic_plot/hist_bin.png b/src/tests/baseline_images/test_magic_plot/hist_bin.png new file mode 100644 index 000000000..1fa92cca0 Binary files /dev/null and b/src/tests/baseline_images/test_magic_plot/hist_bin.png differ diff --git a/src/tests/baseline_images/test_magic_plot/hist_binwidth.png b/src/tests/baseline_images/test_magic_plot/hist_binwidth.png new file mode 100644 index 000000000..f3565b727 Binary files /dev/null and b/src/tests/baseline_images/test_magic_plot/hist_binwidth.png differ diff --git a/src/tests/baseline_images/test_magic_plot/hist_breaks.png b/src/tests/baseline_images/test_magic_plot/hist_breaks.png new file mode 100644 index 000000000..cbe674d2d Binary files /dev/null and b/src/tests/baseline_images/test_magic_plot/hist_breaks.png differ diff --git a/src/tests/baseline_images/test_magic_plot/hist_custom.png b/src/tests/baseline_images/test_magic_plot/hist_custom.png new file mode 100644 index 000000000..2d4a79b47 Binary files /dev/null and b/src/tests/baseline_images/test_magic_plot/hist_custom.png differ diff --git a/src/tests/baseline_images/test_magic_plot/hist_null.png b/src/tests/baseline_images/test_magic_plot/hist_null.png new file mode 100644 index 000000000..9e78e817c Binary files /dev/null and b/src/tests/baseline_images/test_magic_plot/hist_null.png differ diff --git a/src/tests/baseline_images/test_magic_plot/hist_two.png b/src/tests/baseline_images/test_magic_plot/hist_two.png new file mode 100644 index 000000000..dcb53f9fa Binary files /dev/null and b/src/tests/baseline_images/test_magic_plot/hist_two.png differ diff --git a/src/tests/baseline_images/test_magic_plot/histogram_with_table_in_schema.png b/src/tests/baseline_images/test_magic_plot/histogram_with_table_in_schema.png new file mode 100644 index 000000000..be5ca4d58 Binary files /dev/null and b/src/tests/baseline_images/test_magic_plot/histogram_with_table_in_schema.png differ diff --git a/src/tests/baseline_images/test_magic_plot/pie_one_col.png b/src/tests/baseline_images/test_magic_plot/pie_one_col.png new file mode 100644 index 000000000..8decc41c8 Binary files /dev/null and b/src/tests/baseline_images/test_magic_plot/pie_one_col.png differ diff --git a/src/tests/baseline_images/test_magic_plot/pie_one_col_null.png b/src/tests/baseline_images/test_magic_plot/pie_one_col_null.png new file mode 100644 index 000000000..8decc41c8 Binary files /dev/null and b/src/tests/baseline_images/test_magic_plot/pie_one_col_null.png differ diff --git a/src/tests/baseline_images/test_magic_plot/pie_one_col_num.png b/src/tests/baseline_images/test_magic_plot/pie_one_col_num.png new file mode 100644 index 000000000..2c3edfd98 Binary files /dev/null and b/src/tests/baseline_images/test_magic_plot/pie_one_col_num.png differ diff --git a/src/tests/baseline_images/test_magic_plot/pie_two_col.png b/src/tests/baseline_images/test_magic_plot/pie_two_col.png new file mode 100644 index 000000000..fd65c2dc1 Binary files /dev/null and b/src/tests/baseline_images/test_magic_plot/pie_two_col.png differ diff --git a/src/tests/baseline_images/test_magic_plot/pie_with_table_in_schema.png b/src/tests/baseline_images/test_magic_plot/pie_with_table_in_schema.png new file mode 100644 index 000000000..0d3ec47ef Binary files /dev/null and b/src/tests/baseline_images/test_magic_plot/pie_with_table_in_schema.png differ diff --git a/src/tests/conftest.py b/src/tests/conftest.py new file mode 100644 index 000000000..7696339a0 --- /dev/null +++ b/src/tests/conftest.py @@ -0,0 +1,218 @@ +import os +import urllib.request +from pathlib import Path + +import pytest + +from sql.magic import SqlMagic, RenderMagic +from sql.magic_plot import SqlPlotMagic +from sql.magic_cmd import SqlCmdMagic +from sql.connection import ConnectionManager +from sql._testing import TestingShell +from sql import connection +from sql.store import store +from sql import _current + +PATH_TO_TESTS = Path(__file__).absolute().parent +PATH_TO_TMP_ASSETS = PATH_TO_TESTS / "tmp" +PATH_TO_TMP_ASSETS.mkdir(exist_ok=True) + + +@pytest.fixture +def check_duplicate_message_factory(): + def _generate_error_message(cmd, args, aliases=None): + error_message = "" + duplicates = set([arg for arg in args if args.count(arg) != 1]) + + if duplicates: + error_message += ( + f"Duplicate arguments in %{cmd}. " + "Please use only one of each of the following: " + f"{', '.join(sorted(duplicates))}." + ) + if aliases: + error_message += " " + + if aliases: + alias_list = [] + for pair in sorted(aliases): + print(pair[0], pair[1]) + alias_list.append(f"{f'-{pair[0]}'} or {f'--{pair[1]}'}") + error_message += ( + f"Duplicate aliases for arguments in %{cmd}. " + "Please use either one of " + f"{', '.join(alias_list)}." + ) + + return error_message + + return _generate_error_message + + +@pytest.fixture(scope="function", autouse=True) +def isolate_tests(monkeypatch): + """ + Fixture to ensure connections are isolated between tests, preventing tests + from accidentally closing connections created by other tests. + + Also clear up any stored snippets. + """ + # reset connections + connections = {} + monkeypatch.setattr(connection.ConnectionManager, "connections", connections) + monkeypatch.setattr(connection.ConnectionManager, "current", None) + + # reset store + store.clear() + + yield + + # close connections + connection.ConnectionManager.close_all() + + +def path_to_tests(): + return PATH_TO_TESTS + + +@pytest.fixture +def chinook_db(): + path = PATH_TO_TMP_ASSETS / "my.db" + if not path.is_file(): + url = ( + "https://raw.githubusercontent.com" + "/lerocha/chinook-database/master/" + "ChinookDatabase/DataSources/Chinook_Sqlite.sqlite" + ) + urllib.request.urlretrieve(url, path) + + return str(path) + + +# TODO: this is legacy code, we need to remove it +def runsql(ip_session, statements): + if isinstance(statements, str): + statements = [statements] + for statement in statements: + result = ip_session.run_line_magic("sql", "sqlite:// %s" % statement) + return result # returns only last result + + +@pytest.fixture +def clean_conns(): + ConnectionManager.current = None + ConnectionManager.connections = dict() + yield + + +@pytest.fixture +def ip_no_magics(): + ip_session = TestingShell.preconfigured_shell() + + # to prevent using the actual default, which reads from the home directory + ip_session.run_cell("%config SqlMagic.dsn_filename = 'default.ini'") + + yield ip_session + ConnectionManager.close_all() + + +@pytest.fixture +def ip_empty(ip_no_magics): + sql_magic = SqlMagic(ip_no_magics) + _current._set_sql_magic(sql_magic) + + ip_no_magics.register_magics(sql_magic) + ip_no_magics.register_magics(RenderMagic) + ip_no_magics.register_magics(SqlPlotMagic) + ip_no_magics.register_magics(SqlCmdMagic) + + yield ip_no_magics + ConnectionManager.close_all() + + +@pytest.fixture +def sql_magic(): + ip_session = TestingShell.preconfigured_shell() + + sql_magic = SqlMagic(ip_session) + + yield sql_magic + ConnectionManager.close_all() + + +def insert_sample_data(ip): + ip.run_cell( + """%%sql +CREATE TABLE test (n INT, name TEXT); +INSERT INTO test VALUES (1, 'foo'); +INSERT INTO test VALUES (2, 'bar'); +CREATE TABLE [table with spaces] (first INT, second TEXT); +CREATE TABLE author (first_name, last_name, year_of_death); +INSERT INTO author VALUES ('William', 'Shakespeare', 1616); +INSERT INTO author VALUES ('Bertold', 'Brecht', 1956); +CREATE TABLE empty_table (column INT, another INT); +CREATE TABLE website (person, link, birthyear INT); +INSERT INTO website VALUES ('Bertold Brecht', + 'https://en.wikipedia.org/wiki/Bertolt_Brecht', 1954 ); +INSERT INTO website VALUES ('William Shakespeare', + 'https://en.wikipedia.org/wiki/William_Shakespeare', 1564); +INSERT INTO website VALUES ('Steve Steve', 'google_link', 2023); +CREATE TABLE number_table (x INT, y INT); +INSERT INTO number_table VALUES (4, (-2)); +INSERT INTO number_table VALUES ((-5), 0); +INSERT INTO number_table VALUES (2, 4); +INSERT INTO number_table VALUES (0, 2); +INSERT INTO number_table VALUES ((-5), (-1)); +INSERT INTO number_table VALUES ((-2), (-3)); +INSERT INTO number_table VALUES ((-2), (-3)); +INSERT INTO number_table VALUES ((-4), 2); +INSERT INTO number_table VALUES (2, (-5)); +INSERT INTO number_table VALUES (4, 3); +""" + ) + + +@pytest.fixture +def ip(ip_empty): + """Provides an IPython session in which tables have been created""" + ip_empty.run_cell("%sql sqlite://") + insert_sample_data(ip_empty) + + yield ip_empty + + ConnectionManager.close_all() + + +@pytest.fixture +def ip_dbapi(ip_empty): + ip_empty.run_cell("import sqlite3; conn = sqlite3.connect(':memory:');") + ip_empty.run_cell("%sql conn") + insert_sample_data(ip_empty) + + yield ip_empty + + ConnectionManager.close_all() + + +@pytest.fixture +def tmp_empty(tmp_path): + """ + Create temporary path using pytest native fixture, + them move it, yield, and restore the original path + """ + + old = os.getcwd() + os.chdir(str(tmp_path)) + yield str(Path(tmp_path).resolve()) + os.chdir(old) + + +@pytest.fixture +def load_penguin(ip): + tmp = "https://raw.githubusercontent.com/mwaskom/seaborn-data/master/penguins.csv" + if not Path("penguins.csv").is_file(): + urllib.request.urlretrieve( + tmp, + "penguins.csv", + ) + ip.run_cell("%sql duckdb://") diff --git a/src/tests/integration/baseline_images/test_questDB/custom_engine_histogram.png b/src/tests/integration/baseline_images/test_questDB/custom_engine_histogram.png new file mode 100644 index 000000000..7e3b13725 Binary files /dev/null and b/src/tests/integration/baseline_images/test_questDB/custom_engine_histogram.png differ diff --git a/src/tests/integration/baseline_images/test_questDB/facet_wrap_custom_fill.png b/src/tests/integration/baseline_images/test_questDB/facet_wrap_custom_fill.png new file mode 100644 index 000000000..ba99381cc Binary files /dev/null and b/src/tests/integration/baseline_images/test_questDB/facet_wrap_custom_fill.png differ diff --git a/src/tests/integration/baseline_images/test_questDB/facet_wrap_custom_fill_and_color.png b/src/tests/integration/baseline_images/test_questDB/facet_wrap_custom_fill_and_color.png new file mode 100644 index 000000000..0fd373d19 Binary files /dev/null and b/src/tests/integration/baseline_images/test_questDB/facet_wrap_custom_fill_and_color.png differ diff --git a/src/tests/integration/baseline_images/test_questDB/facet_wrap_custom_stacked_histogram.png b/src/tests/integration/baseline_images/test_questDB/facet_wrap_custom_stacked_histogram.png new file mode 100644 index 000000000..99a9a70c8 Binary files /dev/null and b/src/tests/integration/baseline_images/test_questDB/facet_wrap_custom_stacked_histogram.png differ diff --git a/src/tests/integration/baseline_images/test_questDB/facet_wrap_custom_stacked_histogram_cmap.png b/src/tests/integration/baseline_images/test_questDB/facet_wrap_custom_stacked_histogram_cmap.png new file mode 100644 index 000000000..d8f9e3606 Binary files /dev/null and b/src/tests/integration/baseline_images/test_questDB/facet_wrap_custom_stacked_histogram_cmap.png differ diff --git a/src/tests/integration/baseline_images/test_questDB/facet_wrap_default.png b/src/tests/integration/baseline_images/test_questDB/facet_wrap_default.png new file mode 100644 index 000000000..82c1323e6 Binary files /dev/null and b/src/tests/integration/baseline_images/test_questDB/facet_wrap_default.png differ diff --git a/src/tests/integration/baseline_images/test_questDB/facet_wrap_default_no_legend.png b/src/tests/integration/baseline_images/test_questDB/facet_wrap_default_no_legend.png new file mode 100644 index 000000000..fc891f46f Binary files /dev/null and b/src/tests/integration/baseline_images/test_questDB/facet_wrap_default_no_legend.png differ diff --git a/src/tests/integration/baseline_images/test_questDB/histogram_binwidth_with_multiple_cols.png b/src/tests/integration/baseline_images/test_questDB/histogram_binwidth_with_multiple_cols.png new file mode 100644 index 000000000..0770980ac Binary files /dev/null and b/src/tests/integration/baseline_images/test_questDB/histogram_binwidth_with_multiple_cols.png differ diff --git a/src/tests/integration/baseline_images/test_questDB/histogram_breaks.png b/src/tests/integration/baseline_images/test_questDB/histogram_breaks.png new file mode 100644 index 000000000..c32f464f1 Binary files /dev/null and b/src/tests/integration/baseline_images/test_questDB/histogram_breaks.png differ diff --git a/src/tests/integration/baseline_images/test_questDB/histogram_breaks_over_max.png b/src/tests/integration/baseline_images/test_questDB/histogram_breaks_over_max.png new file mode 100644 index 000000000..18e2cd7af Binary files /dev/null and b/src/tests/integration/baseline_images/test_questDB/histogram_breaks_over_max.png differ diff --git a/src/tests/integration/baseline_images/test_questDB/histogram_categorical.png b/src/tests/integration/baseline_images/test_questDB/histogram_categorical.png new file mode 100644 index 000000000..0881c7f6d Binary files /dev/null and b/src/tests/integration/baseline_images/test_questDB/histogram_categorical.png differ diff --git a/src/tests/integration/baseline_images/test_questDB/histogram_categorical_combined.png b/src/tests/integration/baseline_images/test_questDB/histogram_categorical_combined.png new file mode 100644 index 000000000..e953f1a77 Binary files /dev/null and b/src/tests/integration/baseline_images/test_questDB/histogram_categorical_combined.png differ diff --git a/src/tests/integration/baseline_images/test_questDB/histogram_numeric_categorical_combined.png b/src/tests/integration/baseline_images/test_questDB/histogram_numeric_categorical_combined.png new file mode 100644 index 000000000..286de6b71 Binary files /dev/null and b/src/tests/integration/baseline_images/test_questDB/histogram_numeric_categorical_combined.png differ diff --git a/src/tests/integration/baseline_images/test_questDB/histogram_numeric_categorical_combined_custom_fill.png b/src/tests/integration/baseline_images/test_questDB/histogram_numeric_categorical_combined_custom_fill.png new file mode 100644 index 000000000..f05566d5c Binary files /dev/null and b/src/tests/integration/baseline_images/test_questDB/histogram_numeric_categorical_combined_custom_fill.png differ diff --git a/src/tests/integration/baseline_images/test_questDB/histogram_numeric_categorical_combined_custom_multi_color.png b/src/tests/integration/baseline_images/test_questDB/histogram_numeric_categorical_combined_custom_multi_color.png new file mode 100644 index 000000000..096a509e6 Binary files /dev/null and b/src/tests/integration/baseline_images/test_questDB/histogram_numeric_categorical_combined_custom_multi_color.png differ diff --git a/src/tests/integration/baseline_images/test_questDB/histogram_numeric_categorical_combined_custom_multi_fill.png b/src/tests/integration/baseline_images/test_questDB/histogram_numeric_categorical_combined_custom_multi_fill.png new file mode 100644 index 000000000..76f439ec5 Binary files /dev/null and b/src/tests/integration/baseline_images/test_questDB/histogram_numeric_categorical_combined_custom_multi_fill.png differ diff --git a/src/tests/integration/baseline_images/test_questDB/histogram_stacked_breaks.png b/src/tests/integration/baseline_images/test_questDB/histogram_stacked_breaks.png new file mode 100644 index 000000000..d3dacca64 Binary files /dev/null and b/src/tests/integration/baseline_images/test_questDB/histogram_stacked_breaks.png differ diff --git a/src/tests/integration/baseline_images/test_questDB/histogram_stacked_custom_cmap.png b/src/tests/integration/baseline_images/test_questDB/histogram_stacked_custom_cmap.png new file mode 100644 index 000000000..860aa9c5f Binary files /dev/null and b/src/tests/integration/baseline_images/test_questDB/histogram_stacked_custom_cmap.png differ diff --git a/src/tests/integration/baseline_images/test_questDB/histogram_stacked_custom_color.png b/src/tests/integration/baseline_images/test_questDB/histogram_stacked_custom_color.png new file mode 100644 index 000000000..f6afdb5f6 Binary files /dev/null and b/src/tests/integration/baseline_images/test_questDB/histogram_stacked_custom_color.png differ diff --git a/src/tests/integration/baseline_images/test_questDB/histogram_stacked_custom_color_and_fill.png b/src/tests/integration/baseline_images/test_questDB/histogram_stacked_custom_color_and_fill.png new file mode 100644 index 000000000..640087192 Binary files /dev/null and b/src/tests/integration/baseline_images/test_questDB/histogram_stacked_custom_color_and_fill.png differ diff --git a/src/tests/integration/baseline_images/test_questDB/histogram_stacked_default.png b/src/tests/integration/baseline_images/test_questDB/histogram_stacked_default.png new file mode 100644 index 000000000..dd539f508 Binary files /dev/null and b/src/tests/integration/baseline_images/test_questDB/histogram_stacked_default.png differ diff --git a/src/tests/integration/baseline_images/test_questDB/histogram_stacked_large_bins.png b/src/tests/integration/baseline_images/test_questDB/histogram_stacked_large_bins.png new file mode 100644 index 000000000..c08035366 Binary files /dev/null and b/src/tests/integration/baseline_images/test_questDB/histogram_stacked_large_bins.png differ diff --git a/src/tests/integration/baseline_images/test_questDB/histogram_stacked_with_binwidth.png b/src/tests/integration/baseline_images/test_questDB/histogram_stacked_with_binwidth.png new file mode 100644 index 000000000..f6a27e244 Binary files /dev/null and b/src/tests/integration/baseline_images/test_questDB/histogram_stacked_with_binwidth.png differ diff --git a/src/tests/integration/baseline_images/test_questDB/histogram_with_binwidth.png b/src/tests/integration/baseline_images/test_questDB/histogram_with_binwidth.png new file mode 100644 index 000000000..d0fc0e916 Binary files /dev/null and b/src/tests/integration/baseline_images/test_questDB/histogram_with_binwidth.png differ diff --git a/src/tests/integration/baseline_images/test_questDB/histogram_with_narrow_binwidth.png b/src/tests/integration/baseline_images/test_questDB/histogram_with_narrow_binwidth.png new file mode 100644 index 000000000..715a07ef2 Binary files /dev/null and b/src/tests/integration/baseline_images/test_questDB/histogram_with_narrow_binwidth.png differ diff --git a/src/tests/integration/conftest.py b/src/tests/integration/conftest.py new file mode 100644 index 000000000..cad899583 --- /dev/null +++ b/src/tests/integration/conftest.py @@ -0,0 +1,578 @@ +import os +from pathlib import Path +import shutil +import pandas as pd +from pyspark.sql import SparkSession +import pytest +from sqlalchemy import MetaData, Table, create_engine, text +import uuid +import duckdb + +from sql import _testing +from sql import connection +from sql import store + + +def _requires_env_variables(database, variables): + for variable in variables: + if os.getenv(variable) is None: + raise ValueError( + f"{variable} is required to run {database} integration tests" + ) + + +@pytest.fixture(scope="function", autouse=True) +def isolate_tests(monkeypatch): + """ + Fixture to ensure connections are isolated between tests, preventing tests + from accidentally closing connections created by other tests. + + Also clear up any stored snippets. + """ + # reset connections + connections = {} + monkeypatch.setattr(connection.ConnectionManager, "connections", connections) + monkeypatch.setattr(connection.ConnectionManager, "current", None) + + # reset store + store.store = store.SQLStore() + + yield + + # FIXME: cannot close connections because some of them are shared across tests + # e.g., setup_duckdb, we need to isolate them and then we'll be able to close them + # here + # connection.ConnectionManager.close_all() + + +@pytest.fixture +def get_database_config_helper(): + return _testing.DatabaseConfigHelper + + +@pytest.fixture(autouse=True) +def run_around_tests(tmpdir_factory): + """ + Create the temporary folder to keep some static database storage files & destroy + later + """ + # Create tmp folder + my_tmpdir = tmpdir_factory.mktemp(_testing.DatabaseConfigHelper.get_tmp_dir()) + yield my_tmpdir + # Destroy tmp folder + shutil.rmtree(str(my_tmpdir)) + + +@pytest.fixture(scope="session") +def test_table_name_dict(): + return { + "taxi": f"taxi_{str(uuid.uuid4())[:6]}", + "numbers": f"numbers_{str(uuid.uuid4())[:6]}", + "plot_something": f"plot_something_{str(uuid.uuid4())[:6]}", + "new_table_from_df": f"new_table_from_df_{str(uuid.uuid4())[:6]}", + } + + +def drop_table(engine, table_name): + tbl = Table(table_name, MetaData(), autoload_with=engine) + tbl.drop(engine, checkfirst=False) + + +def load_taxi_data_clickhouse(engine, table_name): + data = ["Eric Ken", "John Smith", "Kevin Kelly"] * 15 + query = f"""CREATE TABLE {table_name} (taxi_driver_name String) + ENGINE = MergeTree() + ORDER BY tuple()""" + insert_query = f"INSERT INTO {table_name} (taxi_driver_name) VALUES " + insert_query += ", ".join(f"('{name}')" for name in data) + engine.execute(text(query)) + engine.execute(text(insert_query)) + + +def load_plot_data_clickhouse(engine, table_name): + data = {"x": range(0, 5), "y": range(5, 10)} + query = f"""CREATE TABLE {table_name} (x Int32, y Int32) + ENGINE = MergeTree() + ORDER BY tuple()""" + engine.execute(text(query)) + for values in zip(data["x"], data["y"]): + query = f"INSERT INTO {table_name} (x, y) VALUES ('{values[0]}', {values[1]})" + engine.execute(text(query)) + + +def load_numbers_data_clickhouse(engine, table_name): + data = [1, 2, 3] * 20 + query = f"""CREATE TABLE {table_name} (numbers_elements Int32) + ENGINE = MergeTree() + ORDER BY tuple()""" + insert_query = f"INSERT INTO {table_name} (numbers_elements) VALUES " + insert_query += ", ".join(f"('{number}')" for number in data) + engine.execute(text(query)) + engine.execute(text(insert_query)) + + +def load_taxi_data(engine, table_name, index=True): + table_name = table_name + df = pd.DataFrame( + {"taxi_driver_name": ["Eric Ken", "John Smith", "Kevin Kelly"] * 15} + ) + df.to_sql( + name=table_name, con=engine, chunksize=1000, if_exists="replace", index=index + ) + + +def load_plot_data(engine, table_name, index=True): + df = pd.DataFrame({"x": range(0, 5), "y": range(5, 10)}) + df.to_sql( + name=table_name, con=engine, chunksize=1000, if_exists="replace", index=index + ) + + +def load_numeric_data(engine, table_name, index=True): + df = pd.DataFrame({"numbers_elements": [1, 2, 3] * 20}) + df.to_sql( + name=table_name, con=engine, chunksize=1000, if_exists="replace", index=index + ) + + +def load_generic_testing_data(engine, test_table_name_dict, index=True): + load_taxi_data(engine, table_name=test_table_name_dict["taxi"], index=index) + load_plot_data( + engine, table_name=test_table_name_dict["plot_something"], index=index + ) + load_numeric_data(engine, table_name=test_table_name_dict["numbers"], index=index) + + +def tear_down_generic_testing_data(engine, test_table_name_dict): + drop_table(engine, table_name=test_table_name_dict["taxi"]) + drop_table(engine, table_name=test_table_name_dict["plot_something"]) + drop_table(engine, table_name=test_table_name_dict["numbers"]) + + +@pytest.fixture(scope="session") +def setup_postgreSQL(test_table_name_dict): + with _testing.postgres(): + engine = create_engine( + _testing.DatabaseConfigHelper.get_database_url("postgreSQL") + ) + # Load pre-defined datasets + load_generic_testing_data(engine, test_table_name_dict) + yield engine + tear_down_generic_testing_data(engine, test_table_name_dict) + engine.dispose() + + +@pytest.fixture +def ip_with_postgreSQL(ip_empty, setup_postgreSQL): + configKey = "postgreSQL" + alias = _testing.DatabaseConfigHelper.get_database_config(configKey)["alias"] + + # Select database engine + ip_empty.run_cell( + "%sql " + + _testing.DatabaseConfigHelper.get_database_url(configKey) + + " --alias " + + alias + ) + yield ip_empty + # Disconnect database + ip_empty.run_cell("%sql -x " + alias) + + +@pytest.fixture +def postgreSQL_config_incorrect_pwd(ip_empty, setup_postgreSQL): + configKey = "postgreSQL" + alias = _testing.DatabaseConfigHelper.get_database_config(configKey)["alias"] + url = _testing.DatabaseConfigHelper.get_database_url(configKey) + url = url.replace(":ploomber_app_password", "") + return alias, url + + +@pytest.fixture(scope="session") +def setup_mySQL(test_table_name_dict): + with _testing.mysql(): + engine = create_engine( + _testing.DatabaseConfigHelper.get_database_url("mySQL"), + ) + # Load pre-defined datasets + load_generic_testing_data(engine, test_table_name_dict) + yield engine + tear_down_generic_testing_data(engine, test_table_name_dict) + engine.dispose() + + +@pytest.fixture +def ip_with_mySQL(ip_empty, setup_mySQL): + configKey = "mySQL" + alias = _testing.DatabaseConfigHelper.get_database_config(configKey)["alias"] + + # Select database engine + ip_empty.run_cell( + "%sql " + + _testing.DatabaseConfigHelper.get_database_url(configKey) + + " --alias " + + alias + ) + yield ip_empty + connection.ConnectionManager.close_all() + + +@pytest.fixture(scope="session") +def setup_mariaDB(test_table_name_dict): + with _testing.mariadb(): + engine = create_engine( + _testing.DatabaseConfigHelper.get_database_url("mariaDB") + ) + # Load pre-defined datasets + load_generic_testing_data(engine, test_table_name_dict) + yield engine + tear_down_generic_testing_data(engine, test_table_name_dict) + engine.dispose() + + +@pytest.fixture +def ip_with_mariaDB(ip_empty, setup_mariaDB): + configKey = "mariaDB" + alias = _testing.DatabaseConfigHelper.get_database_config(configKey)["alias"] + + # Select database engine + ip_empty.run_cell( + "%sql " + + _testing.DatabaseConfigHelper.get_database_url(configKey) + + " --alias " + + alias + ) + yield ip_empty + connection.ConnectionManager.close_all() + + +@pytest.fixture(scope="session") +def setup_SQLite(test_table_name_dict): + config = _testing.DatabaseConfigHelper.get_database_config("SQLite") + + if Path(config["database"]).exists(): + Path(config["database"]).unlink() + + engine = create_engine(_testing.DatabaseConfigHelper.get_database_url("SQLite")) + # Load pre-defined datasets + load_generic_testing_data(engine, test_table_name_dict) + yield engine + + tear_down_generic_testing_data(engine, test_table_name_dict) + engine.dispose() + + +@pytest.fixture +def ip_with_SQLite(ip_empty, setup_SQLite): + configKey = "SQLite" + config = _testing.DatabaseConfigHelper.get_database_config(configKey) + alias = config["alias"] + + # Select database engine, use different sqlite database endpoint + ip_empty.run_cell( + "%sql " + + _testing.DatabaseConfigHelper.get_database_url(configKey) + + " --alias " + + alias + ) + yield ip_empty + # Disconnect database + ip_empty.run_cell("%sql -x " + alias) + + connection.ConnectionManager.current.close() + + +@pytest.fixture +def setup_duckDB_native(test_table_name_dict): + conn = duckdb.connect(database=":memory:", read_only=False) + yield conn + conn.close() + + +@pytest.fixture(scope="session") +def setup_spark(test_table_name_dict): + import os + import shutil + import sys + + os.environ["PYSPARK_PYTHON"] = sys.executable + os.environ["PYSPARK_DRIVER_PYTHON"] = sys.executable + spark = SparkSession.builder.master("local[1]").enableHiveSupport().getOrCreate() + load_generic_testing_data_spark(spark, test_table_name_dict) + yield spark + spark.stop() + shutil.rmtree("metastore_db", ignore_errors=True) + shutil.rmtree("spark-warehouse", ignore_errors=True) + os.remove("derby.log") + + +def load_generic_testing_data_spark(spark: SparkSession, test_table_name_dict): + spark.createDataFrame( + pd.DataFrame( + {"taxi_driver_name": ["Eric Ken", "John Smith", "Kevin Kelly"] * 15} + ) + ).createOrReplaceTempView(test_table_name_dict["taxi"]) + spark.createDataFrame( + pd.DataFrame({"x": range(0, 5), "y": range(5, 10)}) + ).createOrReplaceTempView(test_table_name_dict["plot_something"]) + spark.createDataFrame( + pd.DataFrame({"numbers_elements": [1, 2, 3] * 20}) + ).createOrReplaceTempView(test_table_name_dict["numbers"]) + + +@pytest.fixture +def ip_with_spark(ip_empty, setup_spark): + alias = "SparkSession" + + ip_empty.push({"conn": setup_spark}) + # Select database engine, use different sqlite database endpoint + ip_empty.run_cell("%sql " + "conn" + " --alias " + alias) + yield ip_empty + # Disconnect database + ip_empty.run_cell("%sql -x " + alias) + + +def load_generic_testing_data_duckdb_native(ip, test_table_name_dict): + ip.run_cell("import pandas as pd") + ip.run_cell( + f"""{test_table_name_dict['taxi']} = pd.DataFrame({{'taxi_driver_name': + ["Eric Ken", "John Smith", "Kevin Kelly"] * 15}} )""" + ) + ip.run_cell( + f"""{test_table_name_dict['plot_something']} = pd.DataFrame( + {{"x": range(0, 5), "y": range(5, 10)}} )""" + ) + ip.run_cell( + f"""{test_table_name_dict['numbers']} = pd.DataFrame( + {{"numbers_elements": [1, 2, 3] * 20}} )""" + ) + return ip + + +def teardown_generic_testing_data_duckdb_native(ip, test_table_name_dict): + ip.run_cell(f"del {test_table_name_dict['taxi']}") + ip.run_cell(f"del {test_table_name_dict['plot_something']}") + ip.run_cell(f"del {test_table_name_dict['numbers']}") + return ip + + +@pytest.fixture +def ip_with_duckDB_native(ip_empty, setup_duckDB_native, test_table_name_dict): + configKey = "duckDB" + alias = _testing.DatabaseConfigHelper.get_database_config(configKey)["alias"] + + engine = setup_duckDB_native + ip_empty.push({"conn": engine}) + + ip_empty.run_cell("%sql conn" + " --alias " + alias) + ip_empty = load_generic_testing_data_duckdb_native(ip_empty, test_table_name_dict) + yield ip_empty + + ip_empty = teardown_generic_testing_data_duckdb_native( + ip_empty, test_table_name_dict + ) + ip_empty.run_cell("%sql --close " + alias) + + +@pytest.fixture(scope="session") +def setup_duckDB(test_table_name_dict): + config = _testing.DatabaseConfigHelper.get_database_config("duckDB") + + if Path(config["database"]).exists(): + Path(config["database"]).unlink() + + engine = create_engine(_testing.DatabaseConfigHelper.get_database_url("duckDB")) + # Load pre-defined datasets + load_generic_testing_data(engine, test_table_name_dict) + yield engine + tear_down_generic_testing_data(engine, test_table_name_dict) + engine.dispose() + + +@pytest.fixture +def ip_with_duckDB(ip_empty, setup_duckDB): + configKey = "duckDB" + config = _testing.DatabaseConfigHelper.get_database_config(configKey) + alias = config["alias"] + + # Select database engine, use different sqlite database endpoint + ip_empty.run_cell( + "%sql " + + _testing.DatabaseConfigHelper.get_database_url(configKey) + + " --alias " + + alias + ) + yield ip_empty + # Disconnect database + ip_empty.run_cell("%sql -x " + alias) + + +@pytest.fixture +def ip_with_duckdb_native_empty(tmp_empty, ip_empty): + ip_empty.run_cell("import duckdb; conn = duckdb.connect('my.db')") + ip_empty.run_cell("%sql conn --alias duck") + yield ip_empty + ip_empty.run_cell("conn.close()") + + +@pytest.fixture +def ip_with_duckdb_sqlalchemy_empty(tmp_empty, ip_empty): + ip_empty.run_cell("%sql duckdb:///my.db --alias duckdb") + yield ip_empty + ip_empty.run_cell("%sql --close duckdb") + + +@pytest.fixture +def ip_with_sqlite_native_empty(tmp_empty, ip_empty): + ip_empty.run_cell("import sqlite3; conn = sqlite3.connect('')") + ip_empty.run_cell("%sql conn --alias sqlite") + yield ip_empty + ip_empty.run_cell("conn.close()") + + +@pytest.fixture(scope="session") +def setup_MSSQL(test_table_name_dict): + with _testing.mssql(): + engine = create_engine(_testing.DatabaseConfigHelper.get_database_url("MSSQL")) + # Load pre-defined datasets + load_generic_testing_data(engine, test_table_name_dict) + yield engine + tear_down_generic_testing_data(engine, test_table_name_dict) + engine.dispose() + + +@pytest.fixture +def ip_with_MSSQL(ip_empty, setup_MSSQL): + configKey = "MSSQL" + alias = _testing.DatabaseConfigHelper.get_database_config(configKey)["alias"] + + # Select database engine + ip_empty.run_cell( + "%sql " + + _testing.DatabaseConfigHelper.get_database_url(configKey) + + " --alias " + + alias + ) + yield ip_empty + # Disconnect database + ip_empty.run_cell("%sql -x " + alias) + + +@pytest.fixture(scope="session") +def setup_Snowflake(test_table_name_dict): + _requires_env_variables("snowflake", ["SF_USERNAME", "SF_PASSWORD"]) + + engine = create_engine(_testing.DatabaseConfigHelper.get_database_url("Snowflake")) + engine.connect() + # Load pre-defined datasets + load_generic_testing_data(engine, test_table_name_dict, index=False) + yield engine + tear_down_generic_testing_data(engine, test_table_name_dict) + engine.dispose() + + +@pytest.fixture +def ip_with_Snowflake(ip_empty, setup_Snowflake): + configKey = "Snowflake" + config = _testing.DatabaseConfigHelper.get_database_config(configKey) + # Select database engine + ip_empty.run_cell( + "%sql " + + _testing.DatabaseConfigHelper.get_database_url(configKey) + + " --alias " + + config["alias"] + ) + yield ip_empty + # Disconnect database + ip_empty.run_cell("%sql -x " + config["alias"]) + + +@pytest.fixture(scope="session") +def setup_redshift(test_table_name_dict): + _requires_env_variables( + "redshift", ["REDSHIFT_USERNAME", "REDSHIFT_PASSWORD", "REDSHIFT_HOST"] + ) + + engine = create_engine(_testing.DatabaseConfigHelper.get_database_url("redshift")) + engine.connect() + # Load pre-defined datasets + load_generic_testing_data(engine, test_table_name_dict, index=False) + yield engine + tear_down_generic_testing_data(engine, test_table_name_dict) + engine.dispose() + + +@pytest.fixture +def ip_with_redshift(ip_empty, setup_redshift): + configKey = "redshift" + config = _testing.DatabaseConfigHelper.get_database_config(configKey) + # Select database engine + ip_empty.run_cell( + "%sql " + + _testing.DatabaseConfigHelper.get_database_url(configKey) + + " --alias " + + config["alias"] + ) + yield ip_empty + # Disconnect database + ip_empty.run_cell("%sql -x " + config["alias"]) + + +@pytest.fixture(scope="session") +def setup_oracle(test_table_name_dict): + with _testing.oracle(): + engine = create_engine(_testing.DatabaseConfigHelper.get_database_url("oracle")) + engine.connect() + # Load pre-defined datasets + load_generic_testing_data(engine, test_table_name_dict, index=False) + yield engine + tear_down_generic_testing_data(engine, test_table_name_dict) + engine.dispose() + + +@pytest.fixture +def ip_with_oracle(ip_empty, setup_oracle): + configKey = "oracle" + config = _testing.DatabaseConfigHelper.get_database_config(configKey) + # Select database engine + ip_empty.run_cell( + "%sql " + + _testing.DatabaseConfigHelper.get_database_url(configKey) + + " --alias " + + config["alias"] + ) + yield ip_empty + # Disconnect database + ip_empty.run_cell("%sql -x " + config["alias"]) + + +@pytest.fixture(scope="session") +def setup_clickhouse(test_table_name_dict): + with _testing.clickhouse(): + engine = create_engine( + _testing.DatabaseConfigHelper.get_database_url("clickhouse") + ) + engine.connect() + # Load pre-defined datasets + load_taxi_data_clickhouse(engine, test_table_name_dict["taxi"]) + load_plot_data_clickhouse(engine, test_table_name_dict["plot_something"]) + load_numbers_data_clickhouse(engine, test_table_name_dict["numbers"]) + yield engine + tear_down_generic_testing_data(engine, test_table_name_dict) + engine.dispose() + + +@pytest.fixture +def ip_with_clickhouse(ip_empty, setup_clickhouse): + configKey = "clickhouse" + config = _testing.DatabaseConfigHelper.get_database_config(configKey) + # Select database engine + ip_empty.run_cell( + "%sql " + + _testing.DatabaseConfigHelper.get_database_url(configKey) + + " --alias " + + config["alias"] + ) + yield ip_empty + # Disconnect database + ip_empty.run_cell("%sql -x " + config["alias"]) diff --git a/src/tests/integration/test_clickhouse.py b/src/tests/integration/test_clickhouse.py new file mode 100644 index 000000000..947a6809a --- /dev/null +++ b/src/tests/integration/test_clickhouse.py @@ -0,0 +1,113 @@ +import pytest +from matplotlib import pyplot as plt + + +def test_query_count(ip_with_clickhouse, test_table_name_dict): + out = ip_with_clickhouse.run_line_magic( + "sql", + f""" + SELECT * + FROM {test_table_name_dict['taxi']} + LIMIT 3; + """, + ) + + assert len(out) == 3 + + +@pytest.mark.parametrize( + "cell", + [ + ( + "%sqlplot histogram --with plot_something_subset \ + --table plot_something_subset --column x" + ), + ( + "%sqlplot hist --with plot_something_subset \ + --table plot_something_subset --column x" + ), + ( + "%sqlplot histogram --with plot_something_subset \ + --table plot_something_subset --column x --bins 10" + ), + ], + ids=[ + "histogram", + "hist", + "histogram-bins", + ], +) +def test_sqlplot_histogram(ip_with_clickhouse, cell, request, test_table_name_dict): + # clean current Axes + plt.cla() + + ip_with_clickhouse.run_cell( + f"%sql --save plot_something_subset\ + --no-execute SELECT * from {test_table_name_dict['plot_something']} \ + LIMIT 3" + ) + out = ip_with_clickhouse.run_cell(cell) + + assert type(out.result).__name__ in {"Axes", "AxesSubplot"} + + +@pytest.mark.xfail( + reason="Issue in persist. CompileError: No engine for table " +) +def test_create_table_with_indexed_df(ip_with_clickhouse, test_table_name_dict): + ip_with_clickhouse.run_cell("%config SqlMagic.displaylimit = 0") + + # Prepare DF + ip_with_clickhouse.run_cell( + f"""results = %sql SELECT * FROM {test_table_name_dict['taxi']} \ + LIMIT 3""" + ) + ip_with_clickhouse.run_cell( + f"{test_table_name_dict['new_table_from_df']} = results.DataFrame()" + ) + # Create table from DF + persist_out = ip_with_clickhouse.run_cell( + f"%sql --persist {test_table_name_dict['new_table_from_df']}" + ) + query_out = ip_with_clickhouse.run_cell( + f"%sql SELECT * FROM {test_table_name_dict['new_table_from_df']}" + ) + assert persist_out.error_in_exec is None and query_out.error_in_exec is None + assert len(query_out.result) == 15 + + +@pytest.mark.xfail( + reason="Known table parameter issue with oracledb, \ + addressing in #506" +) +@pytest.mark.parametrize( + "cell", + [ + "%sqlplot boxplot --with plot_something_subset \ + --table plot_something_subset --column x", + "%sqlplot box --with plot_something_subset \ + --table plot_something_subset --column x", + "%sqlplot boxplot --with plot_something_subset \ + --table plot_something_subset --column x --orient h", + "%sqlplot boxplot --with plot_something_subset \ + --table plot_something_subset --column x", + ], + ids=[ + "boxplot", + "box", + "boxplot-with-horizontal", + "boxplot-with", + ], +) +def test_sqlplot_boxplot(ip_with_clickhouse, cell, request, test_table_name_dict): + # clean current Axes + plt.cla() + ip_with_clickhouse.run_cell( + f"%sql --save plot_something_subset --no-execute\ + SELECT * from {test_table_name_dict['plot_something']} \ + LiMIT 3" + ) + + out = ip_with_clickhouse.run_cell(cell) + + assert type(out.result).__name__ in {"Axes", "AxesSubplot"} diff --git a/src/tests/integration/test_connection.py b/src/tests/integration/test_connection.py new file mode 100644 index 000000000..63dc71ac9 --- /dev/null +++ b/src/tests/integration/test_connection.py @@ -0,0 +1,395 @@ +import uuid +from unittest.mock import ANY, Mock, call +from functools import partial + + +import sqlalchemy +from sqlalchemy import create_engine +import pytest + + +from sql.connection import ( + SQLAlchemyConnection, + DBAPIConnection, + ConnectionManager, + SparkConnectConnection, +) +from sql import _testing +from sql.connection import connection + + +# TODO: refactor the fixtures so each test can use its own database +# and we don't have to worry about unique table names +def gen_name(prefix="table"): + return f"{prefix}_{str(uuid.uuid4())[:8]}" + + +@pytest.mark.parametrize( + "dynamic_db, Constructor, alias, dialect", + [ + [ + "setup_postgreSQL", + SQLAlchemyConnection, + "postgresql://ploomber_app:***@localhost:5432/db", + "postgresql", + ], + [ + "setup_duckDB_native", + DBAPIConnection, + "DuckDBPyConnection", + "duckdb", + ], + [ + "setup_duckDB", + SQLAlchemyConnection, + "duckdb:////tmp/db-duckdb", + "duckdb", + ], + [ + "setup_postgreSQL", + partial(SQLAlchemyConnection, alias="some-postgres"), + "some-postgres", + "postgresql", + ], + [ + "setup_duckDB_native", + partial(DBAPIConnection, alias="some-duckdb"), + "some-duckdb", + "duckdb", + ], + # TODO: add test for DBAPIConnection where we cannot detect the dialect + ], +) +def test_connection_properties(dynamic_db, request, Constructor, alias, dialect): + dynamic_db = request.getfixturevalue(dynamic_db) + + conn = Constructor(dynamic_db) + + assert conn.alias == alias + assert conn.dialect == dialect + + +@pytest.mark.parametrize( + "dynamic_db, Constructor, expected", + [ + [ + "setup_postgreSQL", + SQLAlchemyConnection, + "postgresql://ploomber_app:***@localhost:5432/db", + ], + [ + "setup_duckDB", + SQLAlchemyConnection, + "duckdb:////tmp/db-duckdb", + ], + [ + "setup_duckDB_native", + DBAPIConnection, + "DuckDBPyConnection", + ], + [ + "setup_duckDB", + partial(SQLAlchemyConnection, alias="some-alias"), + "some-alias", + ], + [ + "setup_duckDB_native", + partial(DBAPIConnection, alias="another-alias"), + "another-alias", + ], + ["setup_spark", SparkConnectConnection, "SparkSession"], + ], +) +def test_connection_identifiers( + dynamic_db, request, monkeypatch, Constructor, expected +): + dynamic_db = request.getfixturevalue(dynamic_db) + + Constructor(dynamic_db) + + assert len(ConnectionManager.connections) == 1 + assert set(ConnectionManager.connections) == {expected} + + +@pytest.mark.parametrize( + "dynamic_db, Constructor, expected", + [ + [ + "setup_postgreSQL", + SQLAlchemyConnection, + { + "dialect": "postgresql", + "driver": "psycopg2", + "server_version_info": ANY, + }, + ], + [ + "setup_duckDB", + SQLAlchemyConnection, + { + "dialect": "duckdb", + "driver": "duckdb_engine", + "server_version_info": ANY, + }, + ], + [ + "setup_duckDB_native", + DBAPIConnection, + { + "dialect": "duckdb", + "driver": "DuckDBPyConnection", + "server_version_info": ANY, + }, + ], + [ + "setup_SQLite", + SQLAlchemyConnection, + { + "dialect": "sqlite", + "driver": "pysqlite", + "server_version_info": ANY, + }, + ], + [ + "setup_mySQL", + SQLAlchemyConnection, + { + "dialect": "mysql", + "driver": "pymysql", + "server_version_info": ANY, + }, + ], + [ + "setup_mariaDB", + SQLAlchemyConnection, + { + "dialect": "mysql", + "driver": "pymysql", + "server_version_info": ANY, + }, + ], + [ + "setup_MSSQL", + SQLAlchemyConnection, + { + "dialect": "mssql", + "driver": "pyodbc", + "server_version_info": ANY, + }, + ], + [ + "setup_Snowflake", + SQLAlchemyConnection, + { + "dialect": "snowflake", + "driver": "snowflake", + "server_version_info": ANY, + }, + ], + # TODO: add oracle (cannot run it locally yet) + ], + ids=[ + "postgresql-sqlalchemy", + "duckdb-sqlalchemy", + "duckdb-dbapi", + "sqlite-sqlalchemy", + "mysql-sqlalchemy", + "mariadb-sqlalchemy", + "mssql-sqlalchemy", + "snowflake-sqlalchemy", + ], +) +def test_get_database_information(dynamic_db, request, Constructor, expected): + conn = Constructor(request.getfixturevalue(dynamic_db)) + assert conn._get_database_information() == expected + + +@pytest.mark.parametrize( + "dynamic_db, dialect", + [ + ("ip_with_duckDB_native", "duckdb"), + ("ip_with_sqlite_native_empty", None), + ], +) +def test_dbapi_connection_sets_right_dialect(dynamic_db, dialect, request): + request.getfixturevalue(dynamic_db) + + assert ConnectionManager.current.is_dbapi_connection + assert ConnectionManager.current.dialect == dialect + + +def test_duckdb_autocommit_on_with_manual_commit(tmp_empty, monkeypatch): + class Config: + autocommit = True + + engine = create_engine("duckdb:///my.db") + + conn = SQLAlchemyConnection(engine=engine, config=Config) + conn_mock_commit = Mock(wraps=conn._connection.commit) + monkeypatch.setattr(conn._connection, "commit", conn_mock_commit) + + conn.raw_execute( + """ +CREATE TABLE numbers ( + x INTEGER +); +""" + ) + conn.raw_execute( + """ + INSERT INTO numbers VALUES (1), (2), (3); + """ + ) + + # if commit is working, the table should be readable from another connection + another = SQLAlchemyConnection( + engine=create_engine("duckdb:///my.db"), config=Config + ) + another_mock_commit = Mock(wraps=another._connection.commit) + monkeypatch.setattr(another._connection, "commit", another_mock_commit) + + results = another.raw_execute("SELECT * FROM numbers") + + assert list(results) == [(1,), (2,), (3,)] + conn_mock_commit.assert_has_calls([call(), call()]) + # due to https://github.com/Mause/duckdb_engine/issues/734, we should not + # call commit on SELECT statements + another_mock_commit.assert_not_called() + + +def test_postgres_autocommit_on_with_manual_commit(setup_postgreSQL, monkeypatch): + url = _testing.DatabaseConfigHelper.get_database_url("postgreSQL") + + class Config: + autocommit = True + + monkeypatch.setattr( + connection, "set_sqlalchemy_isolation_level", Mock(return_value=False) + ) + + engine = create_engine(url) + + conn = SQLAlchemyConnection(engine=engine, config=Config) + conn_mock_commit = Mock(wraps=conn._connection.commit) + monkeypatch.setattr(conn._connection, "commit", conn_mock_commit) + + conn.raw_execute( + """ +CREATE TABLE numbers ( + x INTEGER +); +""" + ) + conn.raw_execute( + """ + INSERT INTO numbers VALUES (1), (2), (3); + """ + ) + + # if commit is working, the table should be readable from another connection + another = SQLAlchemyConnection(engine=create_engine(url), config=Config) + another_mock_commit = Mock(wraps=another._connection.commit) + monkeypatch.setattr(another._connection, "commit", another_mock_commit) + + results = another.raw_execute("SELECT * FROM numbers") + + assert list(results) == [(1,), (2,), (3,)] + conn_mock_commit.assert_has_calls([call(), call()]) + # due to https://github.com/Mause/duckdb_engine/issues/734, we are not calling + # commit on SELECT statements for DuckDB, but for other databases we do + another_mock_commit.assert_has_calls([call()]) + + +def test_duckdb_autocommit_off(tmp_empty): + class Config: + autocommit = False + + engine = create_engine("duckdb:///my.db") + conn = SQLAlchemyConnection(engine=engine, config=Config) + conn.raw_execute( + """ +CREATE TABLE numbers ( + x INTEGER +); +""" + ) + conn.raw_execute( + """ + INSERT INTO numbers VALUES (1), (2), (3); + """ + ) + + # since autocommit is off, the table should not be readable from another connection + another = SQLAlchemyConnection( + engine=create_engine("duckdb:///my.db"), config=Config + ) + + with pytest.raises(sqlalchemy.exc.ProgrammingError) as excinfo: + another.raw_execute("SELECT * FROM numbers") + + assert "Catalog Error: Table with name numbers does not exist!" in str( + excinfo.value + ) + + +# TODO: if we set autocommit to False, then we should not be able to create a table +# we need to add a test. Currently, it's failing with +# "CREATE DATABASE cannot run inside a transaction block" so looks like even with +# autocommit off, we are still in a transaction block (perhaps it's a psycopg2 thing?) +def test_autocommit_on_with_sqlalchemy_that_supports_isolation_level(setup_postgreSQL): + """Test case when we use sqlalchemy to set the isolation level for autocommit""" + + class Config: + autocommit = True + + url = _testing.DatabaseConfigHelper.get_database_url("postgreSQL") + + conn_one = SQLAlchemyConnection(create_engine(url), config=Config) + conn_two = SQLAlchemyConnection(create_engine(url), config=Config) + + # mock commit to ensure it's not called + conn_one._connection.commit = Mock( + side_effect=ValueError( + "commit should not be called manually if the " + "driver supports isolation level" + ) + ) + + db = gen_name(prefix="db") + name = gen_name(prefix="table") + + # this will fail if we don't use the isolation level feature because if we use + # manual commit, then we'll get the "CREATE DATABASE cannot run inside a + # transaction block" error + conn_one.raw_execute(f"CREATE DATABASE {db}") + + conn_one.raw_execute(f"CREATE TABLE {name} (id int)") + conn_two.raw_execute(f"SELECT * FROM {name}") + + assert conn_one._connection._execution_options == {"isolation_level": "AUTOCOMMIT"} + + +@pytest.mark.parametrize("autocommit_value", [True, False]) +def test_mssql_with_pytds(setup_MSSQL, autocommit_value): + """ + In https://github.com/ploomber/jupysql/issues/15, we determined that turning off + autocommit would fix the issue but I was unable to reproduce the problem, + this is working fine. + """ + + class Config: + autocommit = autocommit_value + + url = _testing.DatabaseConfigHelper.get_database_url("mssql_pytds") + + conn_one = SQLAlchemyConnection(create_engine(url), config=Config) + + name = gen_name(prefix="table") + conn_one.raw_execute(f"CREATE TABLE {name} (id int)") + conn_one.raw_execute(f"INSERT INTO {name} VALUES (1), (2), (3)") + results = conn_one.raw_execute(f"SELECT * FROM {name}").fetchall() + + conn_one.close() + + assert url.startswith("mssql+pytds") + assert [(1,), (2,), (3,)] == results diff --git a/src/tests/integration/test_containers.py b/src/tests/integration/test_containers.py new file mode 100644 index 000000000..8bc985c77 --- /dev/null +++ b/src/tests/integration/test_containers.py @@ -0,0 +1,74 @@ +import os +import pytest +from sql import _testing + +is_on_github = False +if "GITHUB_ACTIONS" in os.environ: + is_on_github = True + + +@pytest.mark.parametrize( + "container_context, excepted_database_ready_string, configKey", + [ + ( + _testing.postgres, + "database system is ready to accept connections", + "postgreSQL", + ), + (_testing.mysql, "mysqld: ready for connections", "mySQL"), + (_testing.mariadb, "mysqld: ready for connections", "mariaDB"), + (_testing.mssql, "This container is running as user mssql", "MSSQL"), + ], +) +def test_invidual_container( + container_context, excepted_database_ready_string, configKey +): + if is_on_github: + return + with container_context() as container: + assert any( + excepted_database_ready_string in str(line, "utf-8") + for line in container.logs(stream=True) + ) + assert _testing.database_ready(database=configKey) + + +def test_database_config_helper(monkeypatch): + mock_tmp_dir = "some_folder" + mock_config_key = "someDatabaseKey" + mock_config_dict = { + "drivername": "some_driver_name", + "username": "some_username", + "password": "some_password", + "database": "some_db", + "host": "some_host", + "port": "1234", + "alias": "some_alias", + "docker_ct": { + "name": "some_name", + "image": "some_image", + "ports": {1234: 5678}, + }, + "query": {"key1": "value1", "key2": "value2"}, + } + monkeypatch.setattr( + _testing, + "databaseConfig", + { + mock_config_key: mock_config_dict, + }, + ) + + monkeypatch.setattr(_testing, "TMP_DIR", "some_folder") + expected_url = ( + "some_driver_name://some_username:some_password@some_host:1234" + "/some_db?key1=value1&key2=value2" + ) + assert ( + _testing.DatabaseConfigHelper.get_database_config(mock_config_key) + == mock_config_dict + ) + assert ( + _testing.DatabaseConfigHelper.get_database_url(mock_config_key) == expected_url + ) + assert _testing.DatabaseConfigHelper.get_tmp_dir() == mock_tmp_dir diff --git a/src/tests/integration/test_duckDB.py b/src/tests/integration/test_duckDB.py new file mode 100644 index 000000000..4a0027212 --- /dev/null +++ b/src/tests/integration/test_duckDB.py @@ -0,0 +1,252 @@ +from unittest.mock import Mock +import pytest + +import polars as pl +import pandas as pd + + +@pytest.mark.parametrize( + "method, expected_type, expected_native_method", + [ + ("DataFrame", pd.DataFrame, "df"), + ("PolarsDataFrame", pl.DataFrame, "pl"), + ], +) +@pytest.mark.parametrize( + "autocommit", + [True, False], +) +def test_sqlalchemy_connection_converts_to_data_frames_natively( + monkeypatch, + ip_with_duckdb_sqlalchemy_empty, + method, + expected_type, + expected_native_method, + autocommit, +): + ip_with_duckdb_sqlalchemy_empty.run_cell( + f"%config SqlMagic.autocommit = {autocommit}" + ) + + ip_with_duckdb_sqlalchemy_empty.run_cell( + "%sql CREATE TABLE weather (city VARCHAR, temp_lo INT);" + ) + ip_with_duckdb_sqlalchemy_empty.run_cell( + "%sql INSERT INTO weather VALUES ('San Francisco', 46);" + ) + ip_with_duckdb_sqlalchemy_empty.run_cell( + "%sql INSERT INTO weather VALUES ('NYC', 20);" + ) + ip_with_duckdb_sqlalchemy_empty.run_cell("results = %sql SELECT * FROM weather") + + results = ip_with_duckdb_sqlalchemy_empty.run_cell("results").result + + mock_sqlalchemy_conn = Mock(wraps=results._conn) + mock_sqlalchemy_conn.is_dbapi_connection = False + monkeypatch.setattr(results, "_conn", mock_sqlalchemy_conn) + + out = ip_with_duckdb_sqlalchemy_empty.run_cell(f"results.{method}()") + + mock_sqlalchemy_conn._connection.connection.execute.assert_called_once_with( + "SELECT * FROM weather" + ) + getattr( + mock_sqlalchemy_conn._connection.connection, expected_native_method + ).assert_called_once_with() + assert isinstance(out.result, expected_type) + assert out.result.shape == (2, 2) + + +@pytest.mark.parametrize( + "method, expected_type, expected_native_method", + [ + ("DataFrame", pd.DataFrame, "df"), + ("PolarsDataFrame", pl.DataFrame, "pl"), + ], +) +@pytest.mark.parametrize( + "autocommit", + [True, False], +) +def test_native_connection_converts_to_data_frames_natively( + monkeypatch, + ip_with_duckdb_native_empty, + method, + expected_type, + expected_native_method, + autocommit, +): + ip_with_duckdb_native_empty.run_cell(f"%config SqlMagic.autocommit = {autocommit}") + + ip_with_duckdb_native_empty.run_cell( + "%sql CREATE TABLE weather (city VARCHAR, temp_lo INT);" + ) + ip_with_duckdb_native_empty.run_cell( + "%sql INSERT INTO weather VALUES ('San Francisco', 46);" + ) + ip_with_duckdb_native_empty.run_cell("%sql INSERT INTO weather VALUES ('NYC', 20);") + ip_with_duckdb_native_empty.run_cell("results = %sql SELECT * FROM weather") + + results = ip_with_duckdb_native_empty.run_cell("results").result + + mock_native_connection = Mock(wraps=results.sqlaproxy) + monkeypatch.setattr(results, "_sqlaproxy", mock_native_connection) + + out = ip_with_duckdb_native_empty.run_cell(f"results.{method}()") + + mock_native_connection.execute.assert_called_once_with("SELECT * FROM weather") + getattr(mock_native_connection, expected_native_method).assert_called_once_with() + assert isinstance(out.result, expected_type) + assert out.result.shape == (2, 2) + + +@pytest.mark.parametrize( + "conversion_cell, expected_type", + [ + ("%config SqlMagic.autopandas = True", pd.DataFrame), + ("%config SqlMagic.autopolars = True", pl.DataFrame), + ], + ids=[ + "autopandas_on", + "autopolars_on", + ], +) +def test_convert_to_dataframe_automatically( + ip_with_duckdb_native_empty, + conversion_cell, + expected_type, +): + ip_with_duckdb_native_empty.run_cell(conversion_cell) + ip_with_duckdb_native_empty.run_cell( + "%sql CREATE TABLE weather (city VARCHAR, temp_lo INT);" + ) + ip_with_duckdb_native_empty.run_cell( + "%sql INSERT INTO weather VALUES ('San Francisco', 46);" + ) + ip_with_duckdb_native_empty.run_cell("%sql INSERT INTO weather VALUES ('NYC', 20);") + df = ip_with_duckdb_native_empty.run_cell("%sql SELECT * FROM weather").result + assert isinstance(df, expected_type) + assert df.shape == (2, 2) + + +@pytest.mark.parametrize( + "config", + [ + "%config SqlMagic.autopandas = True", + "%config SqlMagic.autopandas = False", + ], + ids=[ + "autopandas_on", + "autopandas_off", + ], +) +@pytest.mark.parametrize( + "sql, tables", + [ + ["%sql SELECT * FROM weather; SELECT * FROM weather;", ["weather"]], + [ + "%sql CREATE TABLE names (name VARCHAR,); SELECT * FROM weather;", + ["weather", "names"], + ], + [ + ( + "%sql CREATE TABLE names (city VARCHAR,);" + "CREATE TABLE more_names (city VARCHAR,);" + "INSERT INTO names VALUES ('NYC');" + "SELECT * FROM names UNION ALL SELECT * FROM more_names;" + ), + ["weather", "names", "more_names"], + ], + ], + ids=[ + "multiple_selects", + "multiple_statements", + "multiple_tables_created", + ], +) +@pytest.mark.parametrize( + "ip", + [ + "ip_with_duckdb_native_empty", + "ip_with_duckdb_sqlalchemy_empty", + ], +) +def test_multiple_statements(ip, config, sql, tables, request): + ip_ = request.getfixturevalue(ip) + ip_.run_cell(config) + + ip_.run_cell("%sql CREATE TABLE weather (city VARCHAR,);") + ip_.run_cell("%sql INSERT INTO weather VALUES ('NYC');") + ip_.run_cell("%sql SELECT * FROM weather;") + + out = ip_.run_cell(sql) + + if config == "%config SqlMagic.autopandas = True": + assert out.result.to_dict() == {"city": {0: "NYC"}} + else: + assert out.result.dict() == {"city": ("NYC",)} + + if ip == "ip_with_duckdb_sqlalchemy_empty": + out_tables = ip_.run_cell("%sqlcmd tables") + assert set(tables) == set(r[0] for r in out_tables.result._table.rows) + + +@pytest.mark.parametrize( + "ip", + [ + "ip_with_duckdb_native_empty", + "ip_with_duckdb_sqlalchemy_empty", + ], +) +def test_empty_data_frame_if_last_statement_is_not_select(ip, request): + ip = request.getfixturevalue(ip) + ip.run_cell("%config SqlMagic.autopandas=True") + out = ip.run_cell("%sql CREATE TABLE a (c VARCHAR,); CREATE TABLE b (c VARCHAR,);") + assert len(out.result) == 0 + + +@pytest.mark.parametrize( + "sql", + [ + ( + "%sql CREATE TABLE a (x INT,); CREATE TABLE b (x INT,); " + "INSERT INTO a VALUES (1,); INSERT INTO b VALUES(2,); " + "SELECT * FROM a UNION ALL SELECT * FROM b;" + ), + """\ +%%sql +CREATE TABLE a (x INT,); +CREATE TABLE b (x INT,); +INSERT INTO a VALUES (1,); +INSERT INTO b VALUES(2,); +SELECT * FROM a UNION ALL SELECT * FROM b; +""", + ], +) +@pytest.mark.parametrize( + "ip", + [ + "ip_with_duckdb_native_empty", + "ip_with_duckdb_sqlalchemy_empty", + ], +) +def test_commits_all_statements(ip, sql, request): + ip = request.getfixturevalue(ip) + out = ip.run_cell(sql) + assert out.error_in_exec is None + assert out.result.dict() == {"x": (1, 2)} + + +@pytest.mark.parametrize( + "ip", + [ + "ip_with_duckdb_native_empty", + "ip_with_duckdb_sqlalchemy_empty", + ], +) +def test_can_query_existing_df(ip, request): + ip = request.getfixturevalue(ip) + df = pd.DataFrame({"city": ["NYC"]}) # noqa + ip.run_cell("%sql SET python_scan_all_frames=true") + out = ip.run_cell("%sql SELECT * FROM df;") + assert out.result.dict() == {"city": ("NYC",)} diff --git a/src/tests/integration/test_generic_db_operations.py b/src/tests/integration/test_generic_db_operations.py new file mode 100644 index 000000000..65352a1f5 --- /dev/null +++ b/src/tests/integration/test_generic_db_operations.py @@ -0,0 +1,1581 @@ +from uuid import uuid4 +import shutil +from matplotlib import pyplot as plt +import pytest +import warnings +from sql.error_handler import CTE_MSG +from IPython.core.error import UsageError + +import math + +ALL_DATABASES = [ + "ip_with_postgreSQL", + "ip_with_mySQL", + "ip_with_mariaDB", + "ip_with_SQLite", + "ip_with_duckDB_native", + "ip_with_duckDB", + "ip_with_MSSQL", + "ip_with_Snowflake", + "ip_with_oracle", + "ip_with_clickhouse", + "ip_with_spark", +] + +# NOTE: We don't need to add tests for Snowflake and Redshift +# for future PRs. +# Reference issue: https://github.com/ploomber/jupysql/issues/984 + + +@pytest.fixture(autouse=True) +def run_around_tests(tmpdir_factory): + # Create tmp folder + my_tmpdir = tmpdir_factory.mktemp("tmp") + yield my_tmpdir + # Destroy tmp folder + shutil.rmtree(str(my_tmpdir)) + + +@pytest.mark.parametrize( + "ip_with_dynamic_db, query_prefix, query_suffix", + [ + ("ip_with_postgreSQL", "", "LIMIT 3"), + ("ip_with_mySQL", "", "LIMIT 3"), + ("ip_with_mariaDB", "", "LIMIT 3"), + ("ip_with_SQLite", "", "LIMIT 3"), + ("ip_with_duckDB_native", "", "LIMIT 3"), + ("ip_with_duckDB", "", "LIMIT 3"), + ("ip_with_Snowflake", "", "LIMIT 3"), + ("ip_with_redshift", "", "LIMIT 3"), + ("ip_with_clickhouse", "", "LIMIT 3"), + ("ip_with_oracle", "", "FETCH FIRST 3 ROWS ONLY"), + ("ip_with_MSSQL", "TOP 3", ""), + ("ip_with_spark", "", "LIMIT 3"), + ], +) +def test_run_query( + ip_with_dynamic_db, query_prefix, query_suffix, request, test_table_name_dict +): + ip_with_dynamic_db = request.getfixturevalue(ip_with_dynamic_db) + + # run a query + out = ip_with_dynamic_db.run_cell( + f"%sql SELECT {query_prefix} * FROM {test_table_name_dict['taxi']} \ + {query_suffix}" + ) + + # test --save + ip_with_dynamic_db.run_cell( + f"%sql --save taxi_subset --no-execute SELECT {query_prefix} * FROM\ + {test_table_name_dict['taxi']} {query_suffix}" + ) + + out_query_with_save_arg = ip_with_dynamic_db.run_cell( + "%sql --with taxi_subset SELECT * FROM taxi_subset" + ) + + assert len(out.result) == 3 + assert len(out_query_with_save_arg.result) == 3 + + +@pytest.mark.parametrize( + "ip_with_dynamic_db", + [ + "ip_with_postgreSQL", + "ip_with_mySQL", + "ip_with_mariaDB", + "ip_with_SQLite", + "ip_with_duckDB", + "ip_with_duckDB_native", + "ip_with_Snowflake", + "ip_with_redshift", + "ip_with_clickhouse", + "ip_with_spark", + ], +) +def test_handle_multiple_open_result_sets( + ip_with_dynamic_db, request, test_table_name_dict +): + ip_with_dynamic_db = request.getfixturevalue(ip_with_dynamic_db) + taxi_table = test_table_name_dict["taxi"] + numbers_table = test_table_name_dict["numbers"] + + ip_with_dynamic_db.run_cell("%config SqlMagic.displaylimit = 2") + + taxi = ip_with_dynamic_db.run_cell( + f"%sql SELECT * FROM {taxi_table} LIMIT 5" + ).result + + numbers = ip_with_dynamic_db.run_cell( + f"%sql SELECT * FROM {numbers_table} LIMIT 5" + ).result + + # NOTE: we do not check the value of the indexes because snowflake does not support + # them + assert taxi.dict()["taxi_driver_name"] == ( + "Eric Ken", + "John Smith", + "Kevin Kelly", + "Eric Ken", + "John Smith", + ) + assert numbers.dict()["numbers_elements"] == (1, 2, 3, 1, 2) + + +@pytest.mark.parametrize( + "ip_with_dynamic_db, args", + [ + ("ip_with_postgreSQL", ""), + ("ip_with_mySQL", ""), + ("ip_with_mariaDB", ""), + ("ip_with_SQLite", ""), + ("ip_with_duckDB", ""), + pytest.param( + "ip_with_duckDB_native", + "", + marks=pytest.mark.xfail( + reason="'duckdb.DuckDBPyConnection' object has no attribute 'rowcount'" + ), + ), + # snowflake and redshift do not support "CREATE INDEX", so we need to + # pass --no-index + ("ip_with_Snowflake", "--no-index"), + ("ip_with_redshift", "--no-index"), + pytest.param( + "ip_with_clickhouse", + "", + marks=pytest.mark.xfail( + reason="sqlalchemy.exc.CompileError: " + "No engine for table " + ), + ), + ("ip_with_spark", "--no-index"), + ], +) +def test_create_table_with_indexed_df( + ip_with_dynamic_db, args, request, test_table_name_dict +): + limit = 15 + expected = 15 + + ip_with_dynamic_db = request.getfixturevalue(ip_with_dynamic_db) + # Clean up + + ip_with_dynamic_db.run_cell("%config SqlMagic.displaylimit = 0") + + ip_with_dynamic_db.run_cell( + f"%sql DROP TABLE IF EXISTS {test_table_name_dict['new_table_from_df']}" + ) + + # Prepare DF + ip_with_dynamic_db.run_cell( + f"results = %sql SELECT * FROM {test_table_name_dict['taxi']}\ + LIMIT {limit}" + ) + + # Prepare expected df + expected_df = ip_with_dynamic_db.run_cell( + f"%sql SELECT * FROM {test_table_name_dict['taxi']}\ + LIMIT {limit}" + ) + + ip_with_dynamic_db.run_cell( + f"{test_table_name_dict['new_table_from_df']} = results.DataFrame()" + ) + # Create table from DF + persist_out = ip_with_dynamic_db.run_cell( + f"%sql --persist {test_table_name_dict['new_table_from_df']} {args}" + ) + out_df = ip_with_dynamic_db.run_cell( + f"%sql SELECT * FROM {test_table_name_dict['new_table_from_df']}" + ) + assert persist_out.error_in_exec is None and out_df.error_in_exec is None + assert len(out_df.result) == expected + + expected_df_ = expected_df.result.DataFrame() + out_df_ = out_df.result.DataFrame() + + assert expected_df_.equals(out_df_.loc[:, out_df_.columns != "level_0"]) + + +def get_connection_count(ip_with_dynamic_db): + out = ip_with_dynamic_db.run_line_magic("sql", "-l") + print("Current connections:", out) + connections_count = len(out) + return connections_count + + +@pytest.mark.parametrize( + "ip_with_dynamic_db, expected", + [ + ("ip_with_postgreSQL", 1), + ("ip_with_mySQL", 1), + ("ip_with_mariaDB", 1), + ("ip_with_SQLite", 1), + ("ip_with_duckDB", 1), + ("ip_with_duckDB_native", 1), + ("ip_with_MSSQL", 1), + ("ip_with_Snowflake", 1), + ("ip_with_clickhouse", 1), + ("ip_with_spark", 1), + ], +) +def test_active_connection_number(ip_with_dynamic_db, expected, request): + ip_with_dynamic_db = request.getfixturevalue(ip_with_dynamic_db) + assert get_connection_count(ip_with_dynamic_db) == expected + + +@pytest.mark.parametrize( + "ip_with_dynamic_db, config_key", + [ + ("ip_with_postgreSQL", "postgreSQL"), + ("ip_with_mySQL", "mySQL"), + ("ip_with_mariaDB", "mariaDB"), + ("ip_with_SQLite", "SQLite"), + ("ip_with_duckDB", "duckDB"), + ("ip_with_duckDB_native", "duckDB"), + ("ip_with_MSSQL", "MSSQL"), + ("ip_with_Snowflake", "Snowflake"), + ("ip_with_oracle", "oracle"), + ("ip_with_clickhouse", "clickhouse"), + ], +) +def test_close_and_connect( + ip_with_dynamic_db, config_key, request, get_database_config_helper +): + ip_with_dynamic_db = request.getfixturevalue(ip_with_dynamic_db) + + conn_alias = get_database_config_helper.get_database_config(config_key)["alias"] + database_url = get_database_config_helper.get_database_url(config_key) + # Disconnect + + ip_with_dynamic_db.run_cell("%sql -x " + conn_alias) + + assert get_connection_count(ip_with_dynamic_db) == 0 + # Connect, also check there is no error on re-connecting + with warnings.catch_warnings(): + warnings.simplefilter("error") + ip_with_dynamic_db.run_cell("%sql " + database_url + " --alias " + conn_alias) + + assert get_connection_count(ip_with_dynamic_db) == 1 + + +@pytest.mark.parametrize( + "cell", + [ + ( + "%sqlplot histogram --with plot_something_subset --table\ + plot_something_subset --column x" + ), + ( + "%sqlplot hist --with plot_something_subset --table\ + plot_something_subset --column x" + ), + ( + "%sqlplot histogram --with plot_something_subset --table\ + plot_something_subset --column x --bins 10" + ), + ( + "%sqlplot histogram --with plot_something_subset --table\ + plot_something_subset --column x --breaks 0 2 3 4 5" + ), + ( + "%sqlplot histogram --with plot_something_subset --table\ + plot_something_subset --column x --binwidth 1" + ), + ], + ids=[ + "histogram", + "hist", + "histogram-bins", + "histogram-breaks", + "histogram-binwidth", + ], +) +@pytest.mark.parametrize( + "ip_with_dynamic_db", + [ + ("ip_with_postgreSQL"), + ("ip_with_mySQL"), + ("ip_with_mariaDB"), + ("ip_with_SQLite"), + ("ip_with_duckDB"), + ("ip_with_Snowflake"), + ("ip_with_duckDB_native"), + ("ip_with_redshift"), + ("ip_with_spark"), + pytest.param( + "ip_with_MSSQL", + marks=pytest.mark.xfail(reason="sqlglot does not support SQL server"), + ), + pytest.param( + "ip_with_clickhouse", + marks=pytest.mark.xfail( + reason="Plotting from snippet not working in clickhouse" + ), + ), + ], +) +def test_sqlplot_histogram(ip_with_dynamic_db, cell, request, test_table_name_dict): + # clean current Axes + ip_with_dynamic_db = request.getfixturevalue(ip_with_dynamic_db) + plt.cla() + + ip_with_dynamic_db.run_cell( + f"%sql --save plot_something_subset\ + --no-execute SELECT * from {test_table_name_dict['plot_something']} LIMIT 3" + ) + out = ip_with_dynamic_db.run_cell(cell) + + assert type(out.result).__name__ in {"Axes", "AxesSubplot"} + + +BOX_PLOT_FAIL_REASON = ( + "Known issue, the SQL engine must support percentile_disc() SQL clause" +) + + +@pytest.mark.parametrize( + "cell", + [ + "%sqlplot boxplot --with plot_something_subset \ + --table plot_something_subset --column x", + "%sqlplot box --with plot_something_subset \ + --table plot_something_subset --column x", + "%sqlplot boxplot --with plot_something_subset \ + --table plot_something_subset --column x --orient h", + "%sqlplot boxplot --with plot_something_subset \ + --table plot_something_subset --column x", + ], + ids=[ + "boxplot", + "box", + "boxplot-with-horizontal", + "boxplot-with", + ], +) +@pytest.mark.parametrize( + "ip_with_dynamic_db", + [ + "ip_with_postgreSQL", + "ip_with_duckDB", + "ip_with_redshift", + "ip_with_MSSQL", + pytest.param( + "ip_with_duckDB_native", + marks=pytest.mark.xfail(reason="Custom driver not supported"), + ), + pytest.param( + "ip_with_mySQL", marks=pytest.mark.xfail(reason=BOX_PLOT_FAIL_REASON) + ), + pytest.param( + "ip_with_mariaDB", marks=pytest.mark.xfail(reason=BOX_PLOT_FAIL_REASON) + ), + pytest.param( + "ip_with_SQLite", marks=pytest.mark.xfail(reason=BOX_PLOT_FAIL_REASON) + ), + pytest.param( + "ip_with_Snowflake", + marks=pytest.mark.xfail( + reason="Something wrong with sqlplot boxplot in snowflake" + ), + ), + pytest.param( + "ip_with_clickhouse", + marks=pytest.mark.xfail( + reason="Plotting from snippet not working in clickhouse" + ), + ), + pytest.param( + "ip_with_spark", marks=pytest.mark.xfail(reason=BOX_PLOT_FAIL_REASON) + ), + ], +) +def test_sqlplot_boxplot(ip_with_dynamic_db, cell, request, test_table_name_dict): + # clean current Axes + ip_with_dynamic_db = request.getfixturevalue(ip_with_dynamic_db) + plt.cla() + ip_with_dynamic_db.run_cell( + f"%sql --save plot_something_subset --no-execute\ + SELECT * from {test_table_name_dict['plot_something']} LIMIT 3" + ) + + out = ip_with_dynamic_db.run_cell(cell) + + assert type(out.result).__name__ in {"Axes", "AxesSubplot"} + + +@pytest.mark.parametrize( + "ip_with_dynamic_db", + [ + "ip_with_postgreSQL", + "ip_with_duckDB", + "ip_with_redshift", + "ip_with_MSSQL", + "ip_with_spark", + ], +) +def test_sqlplot_bar(ip_with_dynamic_db, request, test_table_name_dict): + plt.cla() + + ip_with_dynamic_db = request.getfixturevalue(ip_with_dynamic_db) + ip_with_dynamic_db.run_cell( + f"%sql --save plot_something_subset --no-execute\ + SELECT * from {test_table_name_dict['plot_something']} LIMIT 3" + ) + + cell = ( + "%sqlplot bar --with plot_something_subset " + "--table plot_something_subset --column x" + ) + out = ip_with_dynamic_db.run_cell(cell) + + assert type(out.result).__name__ in {"Axes", "AxesSubplot"} + + +@pytest.mark.parametrize( + "ip_with_dynamic_db", + [ + "ip_with_postgreSQL", + "ip_with_duckDB", + "ip_with_redshift", + "ip_with_MSSQL", + "ip_with_spark", + ], +) +def test_sqlplot_pie(ip_with_dynamic_db, request, test_table_name_dict): + plt.cla() + + ip_with_dynamic_db = request.getfixturevalue(ip_with_dynamic_db) + ip_with_dynamic_db.run_cell( + f"%sql --save plot_something_subset --no-execute\ + SELECT * from {test_table_name_dict['plot_something']} LIMIT 3" + ) + + cell = ( + "%sqlplot pie --with plot_something_subset " + "--table plot_something_subset --column x" + ) + out = ip_with_dynamic_db.run_cell(cell) + + assert type(out.result).__name__ in {"Axes", "AxesSubplot"} + + +@pytest.mark.parametrize( + "ip_with_dynamic_db", + [ + ("ip_with_postgreSQL"), + ("ip_with_duckDB"), + ("ip_with_Snowflake"), + ("ip_with_duckDB_native"), + pytest.param( + "ip_with_redshift", + marks=pytest.mark.xfail(reason="permission denied for database dev"), + ), + pytest.param( + "ip_with_SQLite", + marks=pytest.mark.xfail(reason="does not support schema"), + ), + pytest.param( + "ip_with_mariaDB", + marks=pytest.mark.xfail(reason="schema access denied"), + ), + pytest.param( + "ip_with_mySQL", + marks=pytest.mark.xfail(reason="schema access denied"), + ), + pytest.param( + "ip_with_MSSQL", + marks=pytest.mark.xfail(reason="sqlplot does not support SQL server"), + ), + pytest.param( + "ip_with_clickhouse", + marks=pytest.mark.xfail( + reason="Plotting from snippet not working in clickhouse" + ), + ), + "ip_with_spark", + ], +) +def test_sqlplot_using_schema(ip_with_dynamic_db, request): + ip_with_dynamic_db = request.getfixturevalue(ip_with_dynamic_db) + plt.cla() + ip_with_dynamic_db.run_cell( + """%%sql +CREATE SCHEMA IF NOT EXISTS schema1; +CREATE TABLE IF NOT EXISTS schema1.table1 ( + x INTEGER, + y INTEGER +); + +INSERT INTO schema1.table1 (x, y) +VALUES + (1, 2), + (3, 4), + (5, 6); +""" + ) + + out = ip_with_dynamic_db.run_cell( + "%sqlplot histogram --table schema1.table1 --column x" + ) + assert type(out.result).__name__ in {"Axes", "AxesSubplot"} + + out = ip_with_dynamic_db.run_cell( + "%sqlplot histogram --table table1 --schema schema1 --column x" + ) + assert type(out.result).__name__ in {"Axes", "AxesSubplot"} + + +@pytest.mark.parametrize( + "ip_with_dynamic_db", + [ + ("ip_with_postgreSQL"), + ("ip_with_mySQL"), + ("ip_with_mariaDB"), + ("ip_with_SQLite"), + ("ip_with_duckDB"), + ("ip_with_redshift"), + pytest.param( + "ip_with_duckDB_native", + marks=pytest.mark.xfail(reason="not supported yet for native connections"), + ), + pytest.param( + "ip_with_MSSQL", + marks=pytest.mark.xfail(reason="not working yet"), + ), + ("ip_with_Snowflake"), + ("ip_with_oracle"), + ("ip_with_clickhouse"), + ("ip_with_spark"), + ], +) +def test_sqlcmd_test(ip_with_dynamic_db, request, test_table_name_dict): + ip_with_dynamic_db = request.getfixturevalue(ip_with_dynamic_db) + table = test_table_name_dict["numbers"] + + ip_with_dynamic_db.run_cell(f"%sql select * from {table}") + + with pytest.raises(UsageError) as excinfo: + ip_with_dynamic_db.run_cell( + f"%sqlcmd test --table {table} --column numbers_elements " + "--less-than 1 --greater 2" + ) + + assert "The above values do not match your test requirements." in str(excinfo.value) + + +@pytest.mark.parametrize( + "ip_with_dynamic_db", + [ + ("ip_with_postgreSQL"), + ("ip_with_mySQL"), + ("ip_with_mariaDB"), + ("ip_with_SQLite"), + ("ip_with_duckDB"), + ("ip_with_duckDB_native"), + ("ip_with_MSSQL"), + pytest.param( + "ip_with_Snowflake", + marks=pytest.mark.xfail( + reason="Something wrong with test_sql_cmd_magic_dos in snowflake" + ), + ), + ("ip_with_oracle"), + ("ip_with_clickhouse"), + ("ip_with_spark"), + ], +) +def test_profile_data_mismatch(ip_with_dynamic_db, request, capsys): + ip_with_dynamic_db = request.getfixturevalue(ip_with_dynamic_db) + + ip_with_dynamic_db.run_cell( + """ + %%sql sqlite:// + CREATE TABLE people (name varchar(50),age varchar(50),number int, + country varchar(50),gender_1 varchar(50), gender_2 varchar(50)); + INSERT INTO people VALUES ('joe', '48', 82, 'usa', '0', 'male'); + INSERT INTO people VALUES ('paula', '50', 93, 'uk', '1', 'female'); + """ + ) + + out = ip_with_dynamic_db.run_cell("%sqlcmd profile -t people").result + + stats_table_html = out._table_html + + assert "td:nth-child(3)" in stats_table_html + assert "td:nth-child(6)" in stats_table_html + assert "td:nth-child(7)" not in stats_table_html + assert "td:nth-child(4)" not in stats_table_html + assert ( + "Columns agegender_1 have a datatype mismatch" + in stats_table_html + ) + + +@pytest.mark.parametrize( + "ip_with_dynamic_db, table, table_columns, expected, message", + [ + ( + "ip_with_postgreSQL", + "taxi", + ["index", "taxi_driver_name"], + { + "count": [45, 45], + "mean": [22.0, math.nan], + "min": [0, "Eric Ken"], + "max": [44, "Kevin Kelly"], + "unique": [45, 3], + "freq": [1, 15], + "top": [0, "Eric Ken"], + "std": ["1.299e+01", math.nan], + "25%": [11.0, math.nan], + "50%": [22.0, math.nan], + "75%": [33.0, math.nan], + }, + None, + ), + pytest.param( + "ip_with_mySQL", + "taxi", + ["taxi_driver_name"], + { + "count": [45], + "mean": [0.0], + "min": ["Eric Ken"], + "max": ["Kevin Kelly"], + "unique": [3], + "freq": [15], + "top": ["Kevin Kelly"], + }, + "Following statistics are not available in", + marks=pytest.mark.xfail( + reason="Need to get column names from table with a different query" + ), + ), + pytest.param( + "ip_with_mariaDB", + "taxi", + ["taxi_driver_name"], + { + "count": [45], + "mean": [0.0], + "min": ["Eric Ken"], + "max": ["Kevin Kelly"], + "unique": [3], + "freq": [15], + "top": ["Kevin Kelly"], + }, + "Following statistics are not available in", + marks=pytest.mark.xfail( + reason="Need to get column names from table with a different query" + ), + ), + ( + "ip_with_SQLite", + "taxi", + ["taxi_driver_name"], + { + "count": [45], + "mean": [0.0], + "min": ["Eric Ken"], + "max": ["Kevin Kelly"], + "unique": [3], + "freq": [15], + "top": ["Kevin Kelly"], + }, + "Following statistics are not available in", + ), + ( + "ip_with_duckDB", + "taxi", + ["index", "taxi_driver_name"], + { + "count": [45, 45], + "mean": [22.0, math.nan], + "min": [0, "Eric Ken"], + "max": [44, "Kevin Kelly"], + "unique": [45, 3], + "freq": [1, 15], + "top": [0, "Eric Ken"], + "std": ["1.299e+01", math.nan], + "25%": [11.0, math.nan], + "50%": [22.0, math.nan], + "75%": [33.0, math.nan], + }, + None, + ), + ( + "ip_with_duckDB_native", + "taxi", + ["index", "taxi_driver_name"], + { + "count": [45, 45], + "mean": [22.0, math.nan], + "min": [0, "Eric Ken"], + "max": [44, "Kevin Kelly"], + "unique": [45, 3], + "freq": [1, 15], + "top": [0, "Eric Ken"], + "std": ["1.299e+01", math.nan], + "25%": [11.0, math.nan], + "50%": [22.0, math.nan], + "75%": [33.0, math.nan], + }, + None, + ), + ( + "ip_with_MSSQL", + "taxi", + ["taxi_driver_name"], + {"unique": [3], "min": ["Eric Ken"], "max": ["Kevin Kelly"], "count": [45]}, + "Following statistics are not available in", + ), + pytest.param( + "ip_with_Snowflake", + "taxi", + ["taxi_driver_name"], + {}, + None, + marks=pytest.mark.xfail( + reason="Something wrong with test_profile_query in snowflake" + ), + ), + pytest.param( + "ip_with_oracle", + "taxi", + ["taxi_driver_name"], + {}, + None, + marks=pytest.mark.xfail( + reason="Something wrong with test_profile_query in snowflake" + ), + ), + pytest.param( + "ip_with_clickhouse", + "taxi", + ["taxi_driver_name"], + { + "count": [45], + "mean": [0.0], + "min": ["Eric Ken"], + "max": ["Kevin Kelly"], + "unique": [3], + "freq": [15], + "top": ["Kevin Kelly"], + }, + "Following statistics are not available in", + ), + ( + "ip_with_spark", + "taxi", + ["taxi_driver_name"], + { + "count": [45], + "mean": [math.nan], + "min": ["Eric Ken"], + "max": ["Kevin Kelly"], + "unique": [3], + "freq": [15], + "top": ["Eric Ken"], + "std": [math.nan], + "25%": [math.nan], + "50%": [math.nan], + "75%": [math.nan], + }, + None, + ), + ], +) +def test_sqlcmd_profile( + request, + ip_with_dynamic_db, + table, + table_columns, + expected, + test_table_name_dict, + message, +): + pytest.skip("Skip on unclosed session issue") + ip_with_dynamic_db = request.getfixturevalue(ip_with_dynamic_db) + + out = ip_with_dynamic_db.run_cell( + f""" + %sqlcmd profile --table "{test_table_name_dict[table]}" + """ + ).result + + stats_table = out._table + stats_table_html = out._table_html + assert len(stats_table.rows) == len(expected) + + for row in stats_table: + criteria = row.get_string(fields=[" "], border=False).strip() + + for i, column in enumerate(table_columns): + cell_value = row.get_string( + fields=[column], border=False, header=False + ).strip() + + assert criteria in expected + assert cell_value == str(expected[criteria][i]) + + if message: + assert message in stats_table_html + + +@pytest.mark.parametrize( + "table", + [ + "numbers", + ], +) +@pytest.mark.parametrize( + "ip_with_dynamic_db", + [ + ("ip_with_postgreSQL"), + ("ip_with_mySQL"), + ("ip_with_mariaDB"), + ("ip_with_SQLite"), + ("ip_with_duckDB"), + ("ip_with_redshift"), + pytest.param( + "ip_with_duckDB_native", + marks=pytest.mark.xfail(reason="Bug #428"), + ), + ("ip_with_MSSQL"), + ("ip_with_Snowflake"), + ("ip_with_clickhouse"), + pytest.param( + "ip_with_spark", + marks=pytest.mark.xfail(reason="Not Implemented"), + ), + ], +) +def test_sqlcmd_columns(ip_with_dynamic_db, table, request, test_table_name_dict): + ip_with_dynamic_db = request.getfixturevalue(ip_with_dynamic_db) + out = ip_with_dynamic_db.run_cell( + f"%sqlcmd columns --table {test_table_name_dict[table]}" + ) + assert out.result + + +@pytest.mark.parametrize( + "ip_with_dynamic_db", + [ + ("ip_with_postgreSQL"), + ("ip_with_mySQL"), + ("ip_with_mariaDB"), + ("ip_with_SQLite"), + ("ip_with_duckDB"), + ("ip_with_redshift"), + pytest.param( + "ip_with_duckDB_native", + marks=pytest.mark.xfail(reason="Bug #428"), + ), + ("ip_with_MSSQL"), + ("ip_with_Snowflake"), + ("ip_with_clickhouse"), + pytest.param( + "ip_with_spark", + marks=pytest.mark.xfail(reason="Not Implemented"), + ), + ], +) +def test_sqlcmd_tables(ip_with_dynamic_db, request): + ip_with_dynamic_db = request.getfixturevalue(ip_with_dynamic_db) + out = ip_with_dynamic_db.run_cell("%sqlcmd tables") + assert out.result + + +@pytest.mark.parametrize( + "cell", + [ + "%%sql\nSELECT * FROM numbers WHERE 0=1", + "%%sql\nSELECT *\n-- %one $another\nFROM numbers WHERE 0=1", + ], + ids=[ + "simple-query", + "interpolation-like-comment", + ], +) +@pytest.mark.parametrize("ip_with_dynamic_db", ALL_DATABASES) +def test_sql_query(ip_with_dynamic_db, cell, request, test_table_name_dict): + ip_with_dynamic_db = request.getfixturevalue(ip_with_dynamic_db) + + if "numbers" in cell: + cell = cell.replace("numbers", test_table_name_dict["numbers"]) + + out = ip_with_dynamic_db.run_cell(cell) + assert out.error_in_exec is None + + +@pytest.mark.parametrize( + "cell", + [ + "%%sql\nSELECT * FROM subset", + "%%sql --with subset\nSELECT * FROM subset", + ], + ids=[ + "cte-inferred", + "cte-explicit", + ], +) +@pytest.mark.parametrize( + "ip_with_dynamic_db", + [ + "ip_with_postgreSQL", + "ip_with_mySQL", + "ip_with_mariaDB", + "ip_with_SQLite", + "ip_with_duckDB_native", + "ip_with_duckDB", + "ip_with_MSSQL", + "ip_with_Snowflake", + "ip_with_oracle", + "ip_with_clickhouse", + "ip_with_spark", + ], +) +def test_sql_query_cte(ip_with_dynamic_db, request, test_table_name_dict, cell): + ip_with_dynamic_db = request.getfixturevalue(ip_with_dynamic_db) + + ip_with_dynamic_db.run_cell( + "%%sql --save subset --no-execute \n" + f"SELECT * FROM {test_table_name_dict['numbers']}" + ) + + out = ip_with_dynamic_db.run_cell(cell) + assert out.error_in_exec is None + + +@pytest.mark.parametrize( + "ip_with_dynamic_db", + [ + "ip_with_postgreSQL", + "ip_with_mySQL", + "ip_with_mariaDB", + "ip_with_SQLite", + "ip_with_duckDB_native", + "ip_with_duckDB", + "ip_with_Snowflake", + "ip_with_MSSQL", + "ip_with_oracle", + pytest.param( + "ip_with_clickhouse", + marks=pytest.mark.xfail(reason="Not yet implemented"), + ), + "ip_with_spark", + ], +) +def test_sql_error_suggests_using_cte(ip_with_dynamic_db, request): + ip_with_dynamic_db = request.getfixturevalue(ip_with_dynamic_db) + + with pytest.raises(UsageError) as excinfo: + ip_with_dynamic_db.run_cell( + """ + %%sql +S""" + ) + + assert excinfo.value.error_type == "RuntimeError" + assert CTE_MSG in str(excinfo.value) + + +@pytest.mark.xfail(reason="Not yet implemented") +@pytest.mark.parametrize( + "ip_with_dynamic_db", + [ + "ip_with_postgreSQL", + "ip_with_mySQL", + "ip_with_mariaDB", + "ip_with_SQLite", + "ip_with_duckDB_native", + "ip_with_duckDB", + "ip_with_Snowflake", + "ip_with_MSSQL", + "ip_with_oracle", + "ip_with_clickhouse", + "ip_with_spark", + ], +) +def test_results_sets_are_closed(ip_with_dynamic_db, request, test_table_name_dict): + ip_with_dynamic_db = request.getfixturevalue(ip_with_dynamic_db) + + ip_with_dynamic_db.run_cell( + f"""%%sql +CREATE TABLE my_numbers AS SELECT * FROM {test_table_name_dict['numbers']} + """ + ) + + ip_with_dynamic_db.run_cell( + """%%sql +SELECT * FROM my_numbers + """ + ) + + ip_with_dynamic_db.run_cell( + """%%sql +DROP TABLE my_numbers + """ + ) + + +@pytest.mark.parametrize( + "ip_with_dynamic_db", + [ + "ip_with_postgreSQL", + "ip_with_mySQL", + "ip_with_mariaDB", + "ip_with_SQLite", + "ip_with_duckDB_native", + "ip_with_duckDB", + "ip_with_Snowflake", + "ip_with_MSSQL", + "ip_with_oracle", + "ip_with_clickhouse", + "ip_with_spark", + ], +) +@pytest.mark.parametrize( + "cell", + [ + "%sql SELECT * FROM __TABLE_NAME__", + ( + "%sql WITH something AS (SELECT * FROM __TABLE_NAME__) " + "SELECT * FROM something" + ), + ], +) +def test_autocommit_retrieve_existing_resultssets( + ip_with_dynamic_db, request, test_table_name_dict, cell +): + """ + duckdb-engine causes existing result cursor to become empty if we call + connection.commit(), this test ensures that we correctly handle that edge + case for duckdb and potentially other drivers. + + See: https://github.com/Mause/duckdb_engine/issues/734 + """ + + ip_with_dynamic_db = request.getfixturevalue(ip_with_dynamic_db) + + ip_with_dynamic_db.run_cell("%config SqlMagic.autocommit=True") + + first = ip_with_dynamic_db.run_cell( + cell.replace("__TABLE_NAME__", test_table_name_dict["numbers"]) + ).result + + second = ip_with_dynamic_db.run_cell( + f"%sql SELECT * FROM {test_table_name_dict['numbers']}" + ).result + + third = ip_with_dynamic_db.run_cell( + f"%sql SELECT * FROM {test_table_name_dict['numbers']}" + ).result + + first.fetchmany(size=1) + second.fetchmany(size=1) + third.fetchmany(size=1) + + assert len(first) == 60 + assert len(second) == 60 + assert len(third) == 60 + + +@pytest.mark.parametrize( + "ip_with_dynamic_db", + [ + "ip_with_duckDB_native", + "ip_with_duckDB", + ], +) +def test_autocommit_retrieve_existing_resultssets_duckdb_from( + ip_with_dynamic_db, request, test_table_name_dict +): + ip_with_dynamic_db = request.getfixturevalue(ip_with_dynamic_db) + + ip_with_dynamic_db.run_cell("%config SqlMagic.autocommit=True") + + result = ip_with_dynamic_db.run_cell( + f'%sql FROM {test_table_name_dict["numbers"]} LIMIT 5' + ).result + + another = ip_with_dynamic_db.run_cell( + f"%sql FROM {test_table_name_dict['numbers']} LIMIT 5" + ).result + + assert len(result) == 5 + assert len(another) == 5 + + +CREATE_TABLE = "CREATE TABLE __TABLE_NAME__ (number INT)" +CREATE_TEMP_TABLE = "CREATE TEMP TABLE __TABLE_NAME__ (number INT)" +CREATE_TEMPORARY_TABLE = "CREATE TEMPORARY TABLE __TABLE_NAME__ (number INT)" +CREATE_GLOBAL_TEMPORARY_TABLE = ( + "CREATE GLOBAL TEMPORARY TABLE __TABLE_NAME__ (number INT)" +) + + +@pytest.mark.parametrize( + "ip_with_dynamic_db, create_table_statement", + [ + ("ip_with_postgreSQL", CREATE_TABLE), + ("ip_with_postgreSQL", CREATE_TEMP_TABLE), + ("ip_with_mySQL", CREATE_TABLE), + ("ip_with_mySQL", CREATE_TEMPORARY_TABLE), + ("ip_with_mariaDB", CREATE_TABLE), + ("ip_with_mariaDB", CREATE_TEMPORARY_TABLE), + ("ip_with_SQLite", CREATE_TABLE), + ("ip_with_SQLite", CREATE_TEMP_TABLE), + ("ip_with_duckDB", CREATE_TABLE), + ("ip_with_duckDB", CREATE_TEMP_TABLE), + ("ip_with_duckDB_native", CREATE_TABLE), + pytest.param( + "ip_with_duckDB_native", + CREATE_TEMP_TABLE, + marks=pytest.mark.xfail( + reason="We're executing operations in different cursors" + ), + ), + ("ip_with_MSSQL", CREATE_TABLE), + pytest.param( + "ip_with_MSSQL", + CREATE_TEMP_TABLE, + marks=pytest.mark.xfail(reason="We need to fix the create table statement"), + ), + pytest.param( + "ip_with_oracle", + CREATE_TABLE, + marks=pytest.mark.xfail(reason="Not working yet"), + ), + pytest.param( + "ip_with_oracle", + CREATE_GLOBAL_TEMPORARY_TABLE, + marks=pytest.mark.xfail(reason="Not working yet"), + ), + ("ip_with_Snowflake", CREATE_TABLE), + ("ip_with_Snowflake", CREATE_TEMPORARY_TABLE), + pytest.param( + "ip_with_clickhouse", + CREATE_TABLE, + marks=pytest.mark.xfail(reason="Not working yet"), + ), + ("ip_with_spark", CREATE_TABLE), + ], +) +def test_autocommit_create_table_single_cell( + ip_with_dynamic_db, + request, + create_table_statement, +): + ip_with_dynamic_db = request.getfixturevalue(ip_with_dynamic_db) + ip_with_dynamic_db.run_cell("%config SqlMagic.autocommit=True") + __TABLE_NAME__ = f"table_{str(uuid4())[:8]}" + + create_table_statement = create_table_statement.replace( + "__TABLE_NAME__", __TABLE_NAME__ + ) + + result = ip_with_dynamic_db.run_cell( + f"""%%sql +{create_table_statement}; +INSERT INTO {__TABLE_NAME__} (number) VALUES (1), (2), (3); +SELECT * FROM {__TABLE_NAME__}; +""" + ).result + + assert len(result) == 3 + + +@pytest.mark.parametrize( + "ip_with_dynamic_db, create_table_statement", + [ + ("ip_with_postgreSQL", CREATE_TABLE), + ("ip_with_postgreSQL", CREATE_TEMP_TABLE), + ("ip_with_mySQL", CREATE_TABLE), + ("ip_with_mySQL", CREATE_TEMPORARY_TABLE), + ("ip_with_mariaDB", CREATE_TABLE), + ("ip_with_mariaDB", CREATE_TEMPORARY_TABLE), + ("ip_with_SQLite", CREATE_TABLE), + ("ip_with_SQLite", CREATE_TEMP_TABLE), + ("ip_with_duckDB", CREATE_TABLE), + ("ip_with_duckDB", CREATE_TEMP_TABLE), + ("ip_with_duckDB_native", CREATE_TABLE), + pytest.param( + "ip_with_duckDB_native", + CREATE_TEMP_TABLE, + marks=pytest.mark.xfail( + reason="We're executing operations in different cursors" + ), + ), + ("ip_with_MSSQL", CREATE_TABLE), + pytest.param( + "ip_with_MSSQL", + CREATE_TEMP_TABLE, + marks=pytest.mark.xfail( + reason="We need to close all existing result sets for this to work" + ), + ), + pytest.param( + "ip_with_oracle", + CREATE_TABLE, + marks=pytest.mark.xfail(reason="Not working yet"), + ), + pytest.param( + "ip_with_oracle", + CREATE_GLOBAL_TEMPORARY_TABLE, + marks=pytest.mark.xfail(reason="Not working yet"), + ), + ("ip_with_Snowflake", CREATE_TABLE), + ("ip_with_Snowflake", CREATE_TEMPORARY_TABLE), + pytest.param( + "ip_with_clickhouse", + CREATE_TABLE, + marks=pytest.mark.xfail(reason="Not working yet"), + ), + ("ip_with_spark", CREATE_TABLE), + ], +) +def test_autocommit_create_table_multiple_cells( + ip_with_dynamic_db, request, create_table_statement +): + ip_with_dynamic_db = request.getfixturevalue(ip_with_dynamic_db) + ip_with_dynamic_db.run_cell("%config SqlMagic.autocommit=True") + __TABLE_NAME__ = f"table_{str(uuid4())[:8]}" + create_table_statement = create_table_statement.replace( + "__TABLE_NAME__", __TABLE_NAME__ + ) + + ip_with_dynamic_db.run_cell( + f"""%%sql +{create_table_statement} +""" + ) + + ip_with_dynamic_db.run_cell( + f"""%%sql +INSERT INTO {__TABLE_NAME__} (number) VALUES (1), (2), (3); +""" + ) + + result = ip_with_dynamic_db.run_cell( + f"""%%sql +SELECT * FROM {__TABLE_NAME__}; +""" + ).result + + assert len(result) == 3 + + +@pytest.mark.parametrize( + "ip_with_dynamic_db, snippet_name, error_msgs, error_type", + [ + ( + "ip_with_postgreSQL", + "mysnippet", + [ + "function not_a_function(text) does not exist", + "No function matches the given name and argument types", + ], + "RuntimeError", + ), + ( + "ip_with_postgreSQL", + "mysnip", + [ + "If using snippets, you may pass the --with argument explicitly.", + 'relation "mysnip" does not exist', + ], + "RuntimeError", + ), + ( + "ip_with_mySQL", + "mysnippet", + [ + "FUNCTION db.not_a_function does not exist", + ], + "RuntimeError", + ), + ( + "ip_with_mySQL", + "mysnip", + [ + "If using snippets, you may pass the --with argument explicitly.", + "Table 'db.mysnip' doesn't exist", + ], + "RuntimeError", + ), + ( + "ip_with_mariaDB", + "mysnippet", + [ + "FUNCTION db.not_a_function does not exist", + ], + "RuntimeError", + ), + ( + "ip_with_mariaDB", + "mysnip", + [ + "If using snippets, you may pass the --with argument explicitly.", + "Table 'db.mysnip' doesn't exist", + ], + "RuntimeError", + ), + ( + "ip_with_MSSQL", + "mysnippet", + [ + "not_a_function' is not a recognized built-in function name", + ], + "RuntimeError", + ), + pytest.param( + "ip_with_MSSQL", + "mysnip", + [ + "If using snippets, you may pass the --with argument explicitly.", + ], + "RuntimeError", + marks=pytest.mark.xfail( + reason="MSSQL prioritizes function error over table error" + ), + ), + ( + "ip_with_Snowflake", + "mysnippet", + [ + "Unknown function NOT_A_FUNCTION", + ], + "RuntimeError", + ), + ( + "ip_with_Snowflake", + "mysnip", + [ + "If using snippets, you may pass the --with argument explicitly.", + ], + "RuntimeError", + ), + ( + "ip_with_oracle", + "mysnippet", + [ + '"NOT_A_FUNCTION": invalid identifier', + ], + "RuntimeError", + ), + ( + "ip_with_oracle", + "mysnip", + [ + 'table or view "PLOOMBER_APP"."MYSNIP" does not exist', + ], + "RuntimeError", + ), + ( + "ip_with_clickhouse", + "mysnippet", + [ + "Unknown function not_a_function: While processing " + "not_a_function(taxi_driver_name)", + ], + "RuntimeError", + ), + ( + "ip_with_clickhouse", + "mysnip", + [ + "If using snippets, you may pass the --with argument explicitly.", + ], + "RuntimeError", + ), + ( + "ip_with_redshift", + "mysnippet", + [ + "function not_a_function(character varying) does not exist", + ], + "RuntimeError", + ), + ( + "ip_with_redshift", + "mysnip", + [ + "If using snippets, you may pass the --with argument explicitly.", + ], + "RuntimeError", + ), + ( + "ip_with_duckDB_native", + "mysnippet", + [ + "Scalar Function with name not_a_function does not exist!", + ], + "RuntimeError", + ), + ( + "ip_with_duckDB_native", + "mysnip", + ["Table with name mysnip does not exist!"], + "RuntimeError", + ), + ( + "ip_with_spark", + "mysnippet", + [ + "Cannot resolve function `not_a_function` on search path", + ], + "RuntimeError", + ), + ( + "ip_with_spark", + "mysnip", + ["Cannot resolve function `not_a_function` on search path"], + "RuntimeError", + ), + ], + ids=[ + "no-typo-postgreSQL", + "with-typo-postgreSQL", + "no-typo-mySQL", + "with-typo-mySQL", + "no-typo-mariaDB", + "with-typo-mariaDB", + "no-typo-MSSQL", + "with-typo-MSSQL", + "no-typo-Snowflake", + "with-typo-Snowflake", + "no-typo-oracle", + "with-typo-oracle", + "no-typo-clickhouse", + "with-typo-clickhouse", + "no-typo-redshift", + "with-typo-redshift", + "no-typo-duckDB-native", + "with-typo-duckDB-native", + "no-typo-spark", + "with-typo-spark", + ], +) +def test_query_snippet_invalid_function_error_message( + request, + ip_with_dynamic_db, + snippet_name, + error_msgs, + error_type, + test_table_name_dict, +): + # Set up snippet + ip_with_dynamic_db = request.getfixturevalue(ip_with_dynamic_db) + ip_with_dynamic_db.run_cell( + f""" + %%sql --save mysnippet + SELECT * FROM {test_table_name_dict['taxi']} + """ + ) + + # Run query + with pytest.raises(UsageError) as excinfo: + ip_with_dynamic_db.run_cell( + f"%sql SELECT not_a_function(taxi_driver_name) FROM {snippet_name}" + ) + + # Save result and test error message + result_error = excinfo.value.error_type + result_msg = str(excinfo.value) + print(result_msg) + assert error_type == result_error + assert all(msg in result_msg for msg in error_msgs) + + +@pytest.mark.parametrize( + "ip_with_dynamic_db, args", + [ + ("ip_with_postgreSQL", ""), + ("ip_with_duckDB", ""), + # snowflake does not support "CREATE INDEX", so we need to + # pass --no-index + ("ip_with_Snowflake", "--no-index"), + pytest.param( + "ip_with_mySQL", + "", + marks=pytest.mark.xfail(reason="Access denied for user"), + ), + pytest.param( + "ip_with_mariaDB", + "", + marks=pytest.mark.xfail(reason="Access denied for user"), + ), + pytest.param( + "ip_with_SQLite", "", marks=pytest.mark.xfail(reason="schema not supported") + ), + pytest.param( + "ip_with_duckDB_native", + "", + marks=pytest.mark.xfail( + reason="'duckdb.DuckDBPyConnection' object has no attribute 'rowcount'" + ), + ), + pytest.param( + "ip_with_redshift", + "", + marks=pytest.mark.xfail(reason="permission denied for database dev"), + ), + pytest.param( + "ip_with_clickhouse", + "", + marks=pytest.mark.xfail( + reason="sqlalchemy.exc.CompileError: " + "No engine for table " + ), + ), + ("ip_with_spark", "--no-index"), + ], +) +def test_persist_in_schema(ip_with_dynamic_db, args, request, test_table_name_dict): + limit = 15 + expected = 15 + + ip_with_dynamic_db = request.getfixturevalue(ip_with_dynamic_db) + # Clean up + + ip_with_dynamic_db.run_cell("%config SqlMagic.displaylimit = 0") + + ip_with_dynamic_db.run_cell("%sql CREATE SCHEMA IF NOT EXISTS schema1;") + + ip_with_dynamic_db.run_cell( + f"%sql DROP TABLE IF EXISTS " + f"schema1.{test_table_name_dict['new_table_from_df']}" + ) + + # Prepare DF + ip_with_dynamic_db.run_cell( + f"results = %sql SELECT * FROM {test_table_name_dict['taxi']}\ + LIMIT {limit}" + ) + + # Prepare expected df + expected_df = ip_with_dynamic_db.run_cell( + f"%sql SELECT * FROM {test_table_name_dict['taxi']}\ + LIMIT {limit}" + ) + + ip_with_dynamic_db.run_cell( + f"{test_table_name_dict['new_table_from_df']} = results.DataFrame()" + ) + # Create table from DF + persist_out = ip_with_dynamic_db.run_cell( + f"%sql --persist schema1.{test_table_name_dict['new_table_from_df']} {args}" + ) + out_df = ip_with_dynamic_db.run_cell( + f"%sql SELECT * FROM schema1.{test_table_name_dict['new_table_from_df']}" + ) + assert persist_out.error_in_exec is None and out_df.error_in_exec is None + assert len(out_df.result) == expected + + expected_df_ = expected_df.result.DataFrame() + out_df_ = out_df.result.DataFrame() + + assert expected_df_.equals(out_df_.loc[:, out_df_.columns != "level_0"]) diff --git a/src/tests/integration/test_mssql.py b/src/tests/integration/test_mssql.py new file mode 100644 index 000000000..04db8f1b6 --- /dev/null +++ b/src/tests/integration/test_mssql.py @@ -0,0 +1,91 @@ +import pytest +from matplotlib import pyplot as plt +from IPython.core.error import UsageError + + +def test_create_table_with_indexed_df(ip_with_MSSQL, test_table_name_dict): + ip_with_MSSQL.run_cell("%config SqlMagic.displaylimit = 0") + + try: + ip_with_MSSQL.run_cell( + f"%sql DROP TABLE {test_table_name_dict['new_table_from_df']}" + ) + except UsageError: + pass + + # Prepare DF + ip_with_MSSQL.run_cell( + f"""results = %sql\ + SELECT TOP 15 *\ + FROM {test_table_name_dict['taxi']} + """ + ) + ip_with_MSSQL.run_cell( + f"{test_table_name_dict['new_table_from_df']} = results.DataFrame()" + ) + # Create table from DF + persist_out = ip_with_MSSQL.run_cell( + f"%sql --persist {test_table_name_dict['new_table_from_df']}" + ) + query_out = ip_with_MSSQL.run_cell( + f"%sql SELECT * FROM {test_table_name_dict['new_table_from_df']}" + ) + assert persist_out.error_in_exec is None and query_out.error_in_exec is None + assert len(query_out.result) == 15 + + +@pytest.mark.xfail(reason="Known sqlglot issue, addressing in: jupysql/issues/307") +@pytest.mark.parametrize( + "cell", + [ + ("%sqlplot histogram --table plot_something --column x"), + ("%sqlplot hist --table plot_something --column x"), + ("%sqlplot histogram --table plot_something --column x --bins 10"), + ], + ids=[ + "histogram", + "hist", + "histogram-bins", + ], +) +def test_sqlplot_histogram(ip_with_MSSQL, cell): + # clean current Axes + plt.cla() + + ip_with_MSSQL.run_cell( + "%sql --save plot_something_subset" + " --no-execute SELECT TOP 3 * from plot_something " + ) + out = ip_with_MSSQL.run_cell(cell) + + assert type(out.result).__name__ in {"Axes", "AxesSubplot"} + + +@pytest.mark.xfail(reason="Known sqlglot issue, addressing in: jupysql/issues/307") +@pytest.mark.parametrize( + "cell", + [ + "%sqlplot boxplot --table plot_something --column x", + "%sqlplot box --table plot_something --column x", + "%sqlplot boxplot --table plot_something --column x --orient h", + "%sqlplot boxplot --with plot_something_subset --table " + "plot_something_subset --column x", + ], + ids=[ + "boxplot", + "box", + "boxplot-horizontal", + "boxplot-with", + ], +) +def test_sqlplot_boxplot(ip_with_MSSQL, cell): + # clean current Axes + plt.cla() + ip_with_MSSQL.run_cell( + "%sql --save plot_something_subset" + " --no-execute SELECT TOP 3 * from plot_something" + ) + + out = ip_with_MSSQL.run_cell(cell) + + assert type(out.result).__name__ in {"Axes", "AxesSubplot"} diff --git a/src/tests/integration/test_oracle.py b/src/tests/integration/test_oracle.py new file mode 100644 index 000000000..75f2605bf --- /dev/null +++ b/src/tests/integration/test_oracle.py @@ -0,0 +1,102 @@ +from matplotlib import pyplot as plt +import pytest + + +@pytest.mark.xfail(reason="Some issue with checking isidentifier part in persist") +def test_create_table_with_indexed_df(ip_with_oracle, test_table_name_dict): + ip_with_oracle.run_cell("%config SqlMagic.displaylimit = 0") + + # Prepare DF + ip_with_oracle.run_cell( + f"""results = %sql SELECT * FROM {test_table_name_dict['taxi']} \ + FETCH FIRST 3 ROWS ONLY""" + ) + ip_with_oracle.run_cell( + f"{test_table_name_dict['new_table_from_df']} = results.DataFrame()" + ) + # Create table from DF + persist_out = ip_with_oracle.run_cell( + f"%sql --persist {test_table_name_dict['new_table_from_df']}" + ) + query_out = ip_with_oracle.run_cell( + f"%sql SELECT * FROM {test_table_name_dict['new_table_from_df']}" + ) + assert persist_out.error_in_exec is None and query_out.error_in_exec is None + assert len(query_out.result) == 15 + + +@pytest.mark.xfail( + reason="Known table parameter issue with oracledb, \ + addressing in #506" +) +@pytest.mark.parametrize( + "cell", + [ + ( + "%sqlplot histogram --with plot_something_subset \ + --table plot_something_subset --column x" + ), + ( + "%sqlplot hist --with plot_something_subset \ + --table plot_something_subset --column x" + ), + ( + "%sqlplot histogram --with plot_something_subset \ + --table plot_something_subset --column x --bins 10" + ), + ], + ids=[ + "histogram", + "hist", + "histogram-bins", + ], +) +def test_sqlplot_histogram(ip_with_oracle, cell, request, test_table_name_dict): + # clean current Axes + plt.cla() + + ip_with_oracle.run_cell( + f"%sql --save plot_something_subset\ + --no-execute SELECT * from {test_table_name_dict['plot_something']} \ + FETCH FIRST 3 ROWS ONLY" + ) + out = ip_with_oracle.run_cell(cell) + + assert type(out.result).__name__ in {"Axes", "AxesSubplot"} + + +@pytest.mark.xfail( + reason="Known table parameter issue with oracledb, \ + addressing in #506" +) +@pytest.mark.parametrize( + "cell", + [ + "%sqlplot boxplot --with plot_something_subset \ + --table plot_something_subset --column x", + "%sqlplot box --with plot_something_subset \ + --table plot_something_subset --column x", + "%sqlplot boxplot --with plot_something_subset \ + --table plot_something_subset --column x --orient h", + "%sqlplot boxplot --with plot_something_subset \ + --table plot_something_subset --column x", + ], + ids=[ + "boxplot", + "box", + "boxplot-with-horizontal", + "boxplot-with", + ], +) +def test_sqlplot_boxplot(ip_with_oracle, cell, request, test_table_name_dict): + # clean current Axes + plt.cla() + ip_with_oracle.run_cell( + f"%sql --save plot_something_subset --no-execute\ + SELECT * from {test_table_name_dict['plot_something']} \ + FETCH FIRST 3 ROWS ONLY" + ) + + out = ip_with_oracle.run_cell(cell) + + assert type(out.result).__name__ in {"Axes", "AxesSubplot"} diff --git a/src/tests/integration/test_postgreSQL.py b/src/tests/integration/test_postgreSQL.py new file mode 100644 index 000000000..fa37e7a42 --- /dev/null +++ b/src/tests/integration/test_postgreSQL.py @@ -0,0 +1,132 @@ +import pytest +from IPython.core.error import UsageError + + +def test_meta_cmd_display(ip_with_postgreSQL, test_table_name_dict): + out = ip_with_postgreSQL.run_cell("%sql \d") # noqa: W605 + assert len(out.result) > 0 + assert ( + "public", + test_table_name_dict["taxi"], + "table", + "ploomber_app", + ) in out.result + + +def test_auto_commit_mode_on(ip_with_postgreSQL, capsys): + ip_with_postgreSQL.run_cell("%config SqlMagic.autocommit=True") + out_after_creating = ip_with_postgreSQL.run_cell("%sql CREATE DATABASE new_db") + out_all_dbs = ip_with_postgreSQL.run_cell("%sql \l").result # noqa: W605 + out, _ = capsys.readouterr() + assert out_after_creating.error_in_exec is None + assert any(row[0] == "new_db" for row in out_all_dbs) + assert "CREATE DATABASE cannot run inside a transaction block" not in out + + +def test_postgres_error(ip_empty, postgreSQL_config_incorrect_pwd): + alias, url = postgreSQL_config_incorrect_pwd + + with pytest.raises(UsageError) as excinfo: + ip_empty.run_cell("%sql " + url + " --alias " + alias) + + assert "Review our DB connection via URL strings guide" in str(excinfo.value) + assert "Original error message from DB driver" in str(excinfo.value) + assert ( + "If you need help solving this issue, " + "send us a message: https://ploomber.io/community" in str(excinfo.value) + ) + + +# 'pgspecial<2' +def test_pgspecial(ip_with_postgreSQL): + out = ip_with_postgreSQL.run_cell("%sql \l").result # noqa: W605 + + assert "postgres" in out.dict()["Name"] + + +@pytest.mark.parametrize( + "query, expected", + [ + ( + "%sql select '{\"a\": 1}'::jsonb -> 'a';", + 1, + ), + ( + '%sql select \'[{"b": "c"}]\'::jsonb -> 0;', + {"b": "c"}, + ), + ( + "%sql select '{\"a\": 1}'::jsonb ->> 'a';", + "1", + ), + ( + '%sql select \'[{"b": "c"}]\'::jsonb ->> 0;', + '{"b": "c"}', + ), + ( + "%sql select '{\"a\": 1}'::json -> 'a';", + 1, + ), + ( + '%sql select \'[{"b": "c"}]\'::json ->> 0;', + '{"b": "c"}', + ), + ( + "%sql select '{\"a\": 1}'::json -> 'a';", + 1, + ), + ( + '%sql select \'[{"b": "c"}]\'::json -> 0;', + {"b": "c"}, + ), + ( + "%sql select '{\"a\": 1}'::jsonb ->> 'a';", + "1", + ), + ( + """%%sql select '{\"a\": 1}'::jsonb + -> + 'a';""", + 1, + ), + ( + """%%sql select '[{\"b\": \"c\"}]'::jsonb + -> + 0;""", + {"b": "c"}, + ), + ( + """%%sql select '{\"a\": 1}'::jsonb + ->> + 'a';""", + "1", + ), + ( + """%%sql + select + \'[{"b": "c"}]\'::jsonb + ->> + 0;""", + '{"b": "c"}', + ), + ], + ids=[ + "single-key-jsonb", + "single-index-jsonb", + "double-key-jsonb", + "double-index-jsonb", + "single-key-json", + "double-index-json", + "single-key-single-tab-json", + "single-index-multi-tab-json", + "double-key-multi-space", + "single-key-multi-line", + "single-index-multi-line-tab", + "double-key-multi-line-space", + "double-index-multi-line", + ], +) +def test_json_arrow_operators(ip_with_postgreSQL, query, expected): + result = ip_with_postgreSQL.run_cell(query).result + result = list(result.dict().values())[0][0] + assert result == expected diff --git a/src/tests/integration/test_questDB.py b/src/tests/integration/test_questDB.py new file mode 100644 index 000000000..cea75a899 --- /dev/null +++ b/src/tests/integration/test_questDB.py @@ -0,0 +1,665 @@ +import pytest +import time +from dockerctx import new_container +from contextlib import contextmanager +import pandas as pd +import urllib.request +import requests +from sql.ggplot import ggplot, aes, geom_histogram, facet_wrap +from sql.connection import ConnectionManager + +from matplotlib.testing.decorators import image_comparison, _cleanup_cm +from sql.connection import DBAPIConnection +from IPython.core.error import UsageError + +""" +This test class includes all QuestDB-related tests and specifically focuses +on testing the custom engine initialization. + +TODO: We should generelize these tests to check different engines/connections. +""" + +QUESTDB_CONNECTION_STRING = ( + "dbname='qdb' user='admin' host='127.0.0.1' port='8812' password='quest'" +) + + +@pytest.fixture +def penguins_data(tmpdir): + """ + Downloads penguins dataset + """ + file_path_str = str(tmpdir.join("penguins.csv")) + + urllib.request.urlretrieve( + "https://raw.githubusercontent.com/mwaskom/seaborn-data/master/penguins.csv", # noqa breaks the check-for-broken-links + file_path_str, + ) + + yield file_path_str + + +@pytest.fixture +def diamonds_data(tmpdir): + """ + Downloads diamonds dataset + """ + file_path_str = "diamonds.csv" + + urllib.request.urlretrieve( + "https://raw.githubusercontent.com/tidyverse/ggplot2/main/data-raw/diamonds.csv", # noqa breaks the check-for-broken-links + file_path_str, + ) + + yield file_path_str + + +def import_data(file_name, table_name): + """ + Loads csv file to questdb container + """ + url = "http://127.0.0.1:9000" + query_url = f"{url}/imp" + + df = pd.read_csv(file_name, sep=",") + df.drop_duplicates(subset=None, inplace=True) + df.to_csv(file_name, index=False) + + with open(file_name, "rb") as csv: + file_data = csv.read() + files = {"data": (table_name, file_data)} + requests.post(query_url, files=files) + + +def custom_database_ready( + dbapi_connection, + timeout=20, + poll_freq=0.5, +): + """Wait until the container is ready to receive connections. + + + :type host: str + :type port: int + :type timeout: float + :type poll_freq: float + """ + + errors = [] + + t0 = time.time() + while time.time() - t0 < timeout: + try: + dbapi_connection() + return True + except Exception as e: + errors.append(str(e)) + + time.sleep(poll_freq) + + # print all the errors so we know what's going on since failing to connect might be + # to some misconfiguration error + errors_ = "\n".join(errors) + print(f"ERRORS: {errors_}") + + return False + + +@contextmanager +def questdb_container(is_bypass_init=False): + if is_bypass_init: + yield None + return + + def test_questdb_connection(): + import psycopg as pg + + engine = pg.connect(QUESTDB_CONNECTION_STRING) + engine.close() + + with new_container( + image_name="questdb/questdb", + ports={"8812": "8812", "9000": "9000", "9009": "9009"}, + ready_test=lambda: custom_database_ready(test_questdb_connection), + healthcheck={ + "interval": 10000000000, + "timeout": 5000000000, + "retries": 5, + }, + ) as container: + yield container + + +@pytest.fixture +def ip_questdb(diamonds_data, penguins_data, ip_empty): + """ + Initializes questdb database container and loads it with data + """ + with questdb_container(): + ip_empty.run_cell( + f""" + import psycopg2 as pg + engine = pg.connect( + "{QUESTDB_CONNECTION_STRING}" + ) + %sql engine + """ + ) + + # Load pre-defined datasets + import_data(penguins_data, "penguins.csv") + import_data(diamonds_data, "diamonds.csv") + yield ip_empty + + +@pytest.fixture +def penguins_no_nulls_questdb(ip_questdb): + ip_questdb.run_cell( + """ +%%sql --save no_nulls --no-execute +SELECT * +FROM penguins.csv +WHERE body_mass_g IS NOT NULL and +sex IS NOT NULL + """ + ).result + + +# ggplot and %sqlplot + + +@_cleanup_cm() +@image_comparison( + baseline_images=["custom_engine_histogram"], + extensions=["png"], + remove_text=False, +) +def test_ggplot_histogram(ip_questdb, penguins_no_nulls_questdb): + ( + ggplot( + table="no_nulls", + with_="no_nulls", + mapping=aes(x=["bill_length_mm", "bill_depth_mm"]), + ) + + geom_histogram(bins=50) + ) + + +@pytest.mark.parametrize( + "x", + [ + "price", + ["price"], + ], +) +@_cleanup_cm() +@image_comparison( + baseline_images=["histogram_stacked_default"], + extensions=["png"], + remove_text=True, +) +def test_example_histogram_stacked_default(ip_questdb, diamonds_data, x): + (ggplot(diamonds_data, aes(x=x)) + geom_histogram(bins=10, fill="cut")) + + +@_cleanup_cm() +@image_comparison( + baseline_images=["custom_engine_histogram"], + extensions=["png"], + remove_text=False, +) +def test_sqlplot_histogram(ip_questdb, penguins_no_nulls_questdb): + ip_questdb.run_cell( + """%sqlplot histogram --column bill_length_mm bill_depth_mm --table no_nulls --with no_nulls""" # noqa + ) + + +@_cleanup_cm() +@image_comparison( + baseline_images=["histogram_stacked_custom_cmap"], + extensions=["png"], + remove_text=True, +) +def test_example_histogram_stacked_custom_cmap(ip_questdb, diamonds_data): + ( + ggplot(diamonds_data, aes(x="price")) + + geom_histogram(bins=10, fill="cut", cmap="plasma") + ) + + +@_cleanup_cm() +@image_comparison( + baseline_images=["histogram_stacked_custom_color"], + extensions=["png"], + remove_text=True, +) +def test_example_histogram_stacked_custom_color(ip_questdb, diamonds_data): + ( + ggplot(diamonds_data, aes(x="price", color="k")) + + geom_histogram(bins=10, cmap="plasma", fill="cut") + ) + + +@_cleanup_cm() +@image_comparison( + baseline_images=["histogram_stacked_custom_color_and_fill"], + extensions=["png"], + remove_text=True, +) +def test_example_histogram_stacked_custom_color_and_fill(ip_questdb, diamonds_data): + ( + ggplot(diamonds_data, aes(x="price", color="white", fill="red")) + + geom_histogram(bins=10, cmap="plasma", fill="cut") + ) + + +@_cleanup_cm() +@image_comparison( + baseline_images=["histogram_stacked_custom_color_and_fill"], + extensions=["png"], + remove_text=True, +) +def test_ggplot_geom_histogram_fill_with_multi_color_warning(ip_questdb, diamonds_data): + with pytest.warns(UserWarning): + ( + ggplot(diamonds_data, aes(x="price", color="white", fill=["red", "blue"])) + + geom_histogram(bins=10, cmap="plasma", fill="cut") + ) + + +@_cleanup_cm() +@image_comparison( + baseline_images=["histogram_stacked_large_bins"], + extensions=["png"], + remove_text=True, +) +def test_example_histogram_stacked_with_large_bins(ip_questdb, diamonds_data): + (ggplot(diamonds_data, aes(x="price")) + geom_histogram(bins=400, fill="cut")) + + +@_cleanup_cm() +@image_comparison( + baseline_images=["histogram_categorical"], + extensions=["png"], + remove_text=True, +) +def test_categorical_histogram(ip_questdb, diamonds_data): + (ggplot(diamonds_data, aes(x=["cut"])) + geom_histogram()) + + +@_cleanup_cm() +@image_comparison( + baseline_images=["histogram_categorical_combined"], + extensions=["png"], + remove_text=True, +) +def test_categorical_histogram_combined(ip_questdb, diamonds_data): + (ggplot(diamonds_data, aes(x=["color", "carat"])) + geom_histogram(bins=10)) + + +@_cleanup_cm() +@image_comparison( + baseline_images=["histogram_numeric_categorical_combined"], + extensions=["png"], + remove_text=True, +) +def test_categorical_and_numeric_histogram_combined(ip_questdb, diamonds_data): + (ggplot(diamonds_data, aes(x=["color", "carat"])) + geom_histogram(bins=20)) + + +@_cleanup_cm() +@image_comparison( + baseline_images=["histogram_numeric_categorical_combined_custom_fill"], + extensions=["png"], + remove_text=True, +) +def test_categorical_and_numeric_histogram_combined_custom_fill( + ip_questdb, diamonds_data +): + ( + ggplot(diamonds_data, aes(x=["color", "carat"], fill="red")) + + geom_histogram(bins=20) + ) + + +@_cleanup_cm() +@image_comparison( + baseline_images=["histogram_numeric_categorical_combined_custom_multi_fill"], + extensions=["png"], + remove_text=True, +) +def test_categorical_and_numeric_histogram_combined_custom_multi_fill( + ip_questdb, diamonds_data +): + ( + ggplot(diamonds_data, aes(x=["color", "carat"], fill=["red", "blue"])) + + geom_histogram(bins=20) + ) + + +@_cleanup_cm() +@image_comparison( + baseline_images=["histogram_numeric_categorical_combined_custom_multi_color"], + extensions=["png"], + remove_text=True, +) +def test_categorical_and_numeric_histogram_combined_custom_multi_color( + ip_questdb, diamonds_data +): + ( + ggplot(diamonds_data, aes(x=["color", "carat"], color=["green", "magenta"])) + + geom_histogram(bins=20) + ) + + +@_cleanup_cm() +@image_comparison( + baseline_images=["facet_wrap_default"], + extensions=["png"], + remove_text=False, +) +def test_facet_wrap_default(ip_questdb, penguins_no_nulls_questdb): + ( + ggplot(table="no_nulls", with_="no_nulls", mapping=aes(x=["bill_depth_mm"])) + + geom_histogram(bins=10) + + facet_wrap("sex") + ) + + +@_cleanup_cm() +@image_comparison( + baseline_images=["facet_wrap_default_no_legend"], + extensions=["png"], + remove_text=False, +) +def test_facet_wrap_default_no_legend(ip_questdb, penguins_no_nulls_questdb): + ( + ggplot(table="no_nulls", with_="no_nulls", mapping=aes(x=["bill_depth_mm"])) + + geom_histogram(bins=10) + + facet_wrap("sex", legend=False) + ) + + +@_cleanup_cm() +@image_comparison( + baseline_images=["facet_wrap_custom_fill"], + extensions=["png"], + remove_text=False, +) +def test_facet_wrap_custom_fill(ip_questdb, penguins_no_nulls_questdb): + ( + ggplot( + table="no_nulls", + with_="no_nulls", + mapping=aes(x=["bill_depth_mm"], fill=["red"]), + ) + + geom_histogram(bins=10) + + facet_wrap("sex") + ) + + +@_cleanup_cm() +@image_comparison( + baseline_images=["facet_wrap_custom_fill_and_color"], + extensions=["png"], + remove_text=False, +) +def test_facet_wrap_custom_fill_and_color(ip_questdb, penguins_no_nulls_questdb): + ( + ggplot( + table="no_nulls", + with_="no_nulls", + mapping=aes(x=["bill_depth_mm"], color="#fff", fill=["red"]), + ) + + geom_histogram(bins=10) + + facet_wrap("sex") + ) + + +@_cleanup_cm() +@image_comparison( + baseline_images=["facet_wrap_custom_stacked_histogram"], + extensions=["png"], + remove_text=False, +) +def test_facet_wrap_stacked_histogram(ip_questdb, diamonds_data): + ( + ggplot(diamonds_data, aes(x=["price"])) + + geom_histogram(bins=10, fill="color") + + facet_wrap("cut") + ) + + +@_cleanup_cm() +@image_comparison( + baseline_images=["facet_wrap_custom_stacked_histogram_cmap"], + extensions=["png"], + remove_text=False, +) +def test_facet_wrap_stacked_histogram_cmap(ip_questdb, diamonds_data): + ( + ggplot(diamonds_data, aes(x=["price"])) + + geom_histogram(bins=10, fill="color", cmap="plasma") + + facet_wrap("cut") + ) + + +@_cleanup_cm() +@image_comparison( + baseline_images=["histogram_breaks"], + extensions=["png"], + remove_text=True, +) +def test_histogram_breaks(ip_questdb, diamonds_data): + ( + ggplot(diamonds_data, aes(x="price")) + + geom_histogram(breaks=[0, 3000, 5000, 6000, 10000]) + ) + + +@_cleanup_cm() +@image_comparison( + baseline_images=["histogram_stacked_breaks"], + extensions=["png"], + remove_text=True, +) +def test_histogram_stacked_breaks(ip_questdb, diamonds_data): + ( + ggplot(diamonds_data, aes(x="price")) + + geom_histogram(breaks=[0, 3000, 5000, 6000, 10000], fill="color") + ) + + +@_cleanup_cm() +@image_comparison( + baseline_images=["histogram_breaks_over_max"], + extensions=["png"], + remove_text=True, +) +def test_histogram_breaks_over_max(ip_questdb, diamonds_data): + ( + ggplot(diamonds_data, aes(x="price")) + + geom_histogram(breaks=[15000, 17000, 20000, 21000]) + ) + + +@_cleanup_cm() +@image_comparison( + baseline_images=["histogram_with_binwidth"], + extensions=["png"], + remove_text=True, +) +def test_histogram_with_binwidth(ip_questdb, penguins_no_nulls_questdb): + ( + ggplot(table="no_nulls", with_="no_nulls", mapping=aes(x="body_mass_g")) + + geom_histogram(binwidth=150) + ) + + +@_cleanup_cm() +@image_comparison( + baseline_images=["histogram_stacked_with_binwidth"], + extensions=["png"], + remove_text=True, +) +def test_histogram_stacked_with_binwidth(ip_questdb, penguins_no_nulls_questdb): + ( + ggplot(table="no_nulls", with_="no_nulls", mapping=aes(x="body_mass_g")) + + geom_histogram(binwidth=150, fill="species") + ) + + +@_cleanup_cm() +@image_comparison( + baseline_images=["histogram_binwidth_with_multiple_cols"], + extensions=["png"], + remove_text=True, +) +def test_histogram_binwidth_with_multiple_cols(ip_questdb, penguins_no_nulls_questdb): + ( + ggplot( + table="no_nulls", + with_="no_nulls", + mapping=aes(x=["bill_length_mm", "bill_depth_mm"]), + ) + + geom_histogram(binwidth=1.5) + ) + + +@_cleanup_cm() +@image_comparison( + baseline_images=["histogram_with_narrow_binwidth"], + extensions=["png"], + remove_text=True, +) +def test_histogram_with_narrow_binwidth(ip_questdb, penguins_no_nulls_questdb): + ( + ggplot(table="no_nulls", with_="no_nulls", mapping=aes(x="body_mass_g")) + + geom_histogram(binwidth=10) + ) + + +@_cleanup_cm() +@pytest.mark.parametrize( + "x, expected_error, expected_error_message", + [ + ([], ValueError, "Column name has not been specified"), + ([""], ValueError, "Column name has not been specified"), + (None, ValueError, "Column name has not been specified"), + ("", ValueError, "Column name has not been specified"), + ([None, None], ValueError, "please ensure that you specify only one column"), + ( + ["price", "table"], + ValueError, + "please ensure that you specify only one column", + ), + ( + ["price", "table", "color"], + ValueError, + "please ensure that you specify only one column", + ), + ([None], TypeError, "expected str instance, NoneType found"), + ], +) +def test_example_histogram_stacked_input_error( + diamonds_data, ip_questdb, x, expected_error, expected_error_message +): + with pytest.raises(expected_error) as error: + (ggplot(diamonds_data, aes(x=x)) + geom_histogram(bins=500, fill="cut")) + + assert expected_error_message in str(error.value) + + +def test_histogram_no_bins_error(ip_questdb, diamonds_data): + with pytest.raises(ValueError) as error: + (ggplot(diamonds_data, aes(x=["price"])) + geom_histogram()) + + assert "Please specify a valid number of bins." in str(error.value) + + +@pytest.mark.parametrize( + "query, expected_results", + [ + ( + "select * from penguins.csv limit 2", + [ + ("Adelie", "Torgersen", 39.1, 18.7, 181, 3750, "MALE"), + ("Adelie", "Torgersen", 39.5, 17.4, 186, 3800, "FEMALE"), + ], + ), + ( + "select * from penguins.csv where sex = 'MALE' limit 2", + [ + ("Adelie", "Torgersen", 39.1, 18.7, 181, 3750, "MALE"), + ("Adelie", "Torgersen", 39.3, 20.6, 190, 3650, "MALE"), + ], + ), + ( + "select species, island from penguins.csv where sex = 'MALE' limit 2", + [("Adelie", "Torgersen"), ("Adelie", "Torgersen")], + ), + ], +) +def test_sql(ip_questdb, query, expected_results): + resultSet = ip_questdb.run_cell(f"%sql {query}").result + for i, row in enumerate(resultSet): + assert row == expected_results[i] + + +# NOT SUPPORTED ERRORS + + +NOT_SUPPORTED_SUFFIX = ( + "is only supported with SQLAlchemy connections, not with DBAPI connections" +) + + +@pytest.mark.parametrize( + "query, command", + [ + ("%sqlcmd tables", "tables"), + ("%sqlcmd tables --schema some_schema", "tables"), + ("%sqlcmd columns --table penguins.csv", "columns"), + ("%sqlcmd test", "test"), + ("%sqlcmd test --table penguins.csv", "test"), + ], +) +def test_sqlcmd_not_supported_error(ip_questdb, query, command, capsys): + expected_error_message = f"%sqlcmd {command} {NOT_SUPPORTED_SUFFIX}" + + with pytest.raises(UsageError) as excinfo: + ip_questdb.run_cell(query) + + error_message = str(excinfo.value) + assert str(expected_error_message).lower() in error_message.lower() + + +# Utils +@pytest.mark.parametrize( + "alias", + [ + "Connection", + "test_alias", + ], +) +def test_dbapi_connection(ip_questdb, alias): + import psycopg as pg + + engine = pg.connect(QUESTDB_CONNECTION_STRING) + + expected_connection_name = "Connection" + + connection = DBAPIConnection(engine, alias) + + assert isinstance(connection, DBAPIConnection) + assert connection.name is expected_connection_name + assert connection.dialect is None + assert connection.alias is alias + assert len(ConnectionManager.connections) > 0 + + if alias: + stored_connection = ConnectionManager.connections[alias] + else: + stored_connection = ConnectionManager.connections[expected_connection_name] + + assert isinstance(stored_connection, DBAPIConnection) diff --git a/src/tests/integration/test_resultset.py b/src/tests/integration/test_resultset.py new file mode 100644 index 000000000..aa802d7f3 --- /dev/null +++ b/src/tests/integration/test_resultset.py @@ -0,0 +1,38 @@ +from sql.connection import DBAPIConnection +from sql.run.resultset import ResultSet + +from sql import _testing + + +class Config: + autopandas = None + autopolars = None + autocommit = True + feedback = True + polars_dataframe_kwargs = {} + style = "DEFAULT" + autolimit = 0 + displaylimit = 10 + + +def test_resultset(setup_postgreSQL): + import psycopg2 + + config = _testing.DatabaseConfigHelper.get_database_config("postgreSQL") + + conn_raw = psycopg2.connect( + database=config["database"], + user=config["username"], + password=config["password"], + host=config["host"], + port=config["port"], + ) + conn = DBAPIConnection(conn_raw) + + statement = "SELECT 'hello' AS greeting;" + results = conn.raw_execute(statement) + + rs = ResultSet(results, Config, statement, conn) + + assert rs.keys == ["greeting"] + assert rs._is_dbapi_results diff --git a/src/tests/integration/test_run.py b/src/tests/integration/test_run.py new file mode 100644 index 000000000..79e2eb300 --- /dev/null +++ b/src/tests/integration/test_run.py @@ -0,0 +1,180 @@ +import uuid +from functools import partial + +import pytest +from sqlalchemy import create_engine +import sqlalchemy + +from sql.connection import SQLAlchemyConnection, DBAPIConnection +from sql.run.run import run_statements +from sql import _testing + + +SQLALCHEMY_VERSION = int(sqlalchemy.__version__.split(".")[0]) + + +@pytest.fixture +def psycopg2_factory(): + import psycopg2 + + config = _testing.DatabaseConfigHelper.get_database_config("postgreSQL") + + return partial( + psycopg2.connect, + database=config["database"], + user=config["username"], + password=config["password"], + host=config["host"], + port=config["port"], + ) + + +class ConfigAutocommit: + autopandas = None + autopolars = None + autocommit = True + feedback = True + polars_dataframe_kwargs = {} + style = "DEFAULT" + autolimit = 0 + displaylimit = 10 + + +class ConfigNoAutocommit(ConfigAutocommit): + autocommit = False + + +# TODO: refactor the fixtures so each test can use its own database +# and we don't have to worry about unique table names +def gen_name(): + return f"table_{str(uuid.uuid4())[:8]}" + + +@pytest.mark.skipif( + SQLALCHEMY_VERSION == 1, reason="this is failing with sqlalchemy 1.x" +) +def test_duckdb_sqlalchemy_doesnt_commit_by_default(tmp_empty): + """ + This test checks that duckdb doesn't commit by default so we're sure that the + commit behavior comes from our code + """ + url = "duckdb:///my.db" + + conn_one = create_engine(url).connect() + conn_two = create_engine(url).connect() + + name = gen_name() + conn_one.execute(sqlalchemy.text(f"CREATE TABLE {name} (id int)")) + + with pytest.raises(sqlalchemy.exc.ProgrammingError) as excinfo: + conn_two.execute(sqlalchemy.text(f"SELECT * FROM {name}")) + + assert f"Table with name {name} does not exist!" in str(excinfo.value) + + +def test_postgres_dbapi_doesnt_commit_by_default(setup_postgreSQL, psycopg2_factory): + """ + This test checks that postgres doesn't commit by default so we're sure that the + commit behavior comes from our code + """ + import psycopg2 + + conn_one = psycopg2_factory() + conn_two = psycopg2_factory() + + name = gen_name() + + with conn_one.cursor() as c: + c.execute(f"CREATE TABLE {name} (id int)") + + with pytest.raises(psycopg2.errors.UndefinedTable): + with conn_two.cursor() as c: + c.execute(f"SELECT * FROM {name}") + + +# TODO: duckdb-engine does not support isolation level so we need to test with +# a database that does (but first verify that this is the case) + + +@pytest.mark.skipif( + SQLALCHEMY_VERSION == 1, reason="this is failing with sqlalchemy 1.x" +) +def test_autocommit_off_with_sqlalchemy_connection(tmp_empty): + url = "duckdb:///my.db" + + engine_one = create_engine(url) + engine_two = create_engine(url) + + conn_one = SQLAlchemyConnection(engine_one, config=ConfigNoAutocommit) + conn_two = SQLAlchemyConnection(engine_two, config=ConfigNoAutocommit) + + name = gen_name() + + run_statements(conn_one, f"CREATE TABLE {name} (id int)", ConfigNoAutocommit) + + with pytest.raises(sqlalchemy.exc.ProgrammingError) as excinfo: + run_statements(conn_two, f"SELECT * FROM {name}", ConfigNoAutocommit) + + assert f"Table with name {name} does not exist!" in str(excinfo.value) + + +def test_autocommit_with_sqlalchemy_connection_manual_commit(tmp_empty): + """Test case when we manually call .commit() on the connection""" + url = "duckdb:///my.db" + + engine_one = create_engine(url) + engine_two = create_engine(url) + + conn_one = SQLAlchemyConnection(engine_one) + conn_two = SQLAlchemyConnection(engine_two) + + name = gen_name() + + run_statements(conn_one, f"CREATE TABLE {name} (id int)", ConfigAutocommit) + run_statements(conn_two, f"SELECT * FROM {name}", ConfigAutocommit) + + +def test_autocommit_with_sqlalchemy_that_supports_isolation_level(setup_postgreSQL): + """Test case when we use sqlalchemy to set the isolation level for autocommit""" + url = _testing.DatabaseConfigHelper.get_database_url("postgreSQL") + + conn_one = SQLAlchemyConnection(create_engine(url)) + conn_two = SQLAlchemyConnection(create_engine(url)) + + name = gen_name() + + run_statements(conn_one, f"CREATE TABLE {name} (id int)", ConfigAutocommit) + run_statements(conn_two, f"SELECT * FROM {name}", ConfigAutocommit) + + +# TODO: add create table test to generic operations +def test_autocommit_off_with_dbapi_connection(setup_postgreSQL, psycopg2_factory): + import psycopg2 + + conn_raw_one = psycopg2_factory() + conn_raw_two = psycopg2_factory() + conn_one = DBAPIConnection(conn_raw_one, config=ConfigNoAutocommit) + conn_two = DBAPIConnection(conn_raw_two, config=ConfigNoAutocommit) + + name = gen_name() + + run_statements(conn_one, f"CREATE TABLE {name} (id int)", ConfigNoAutocommit) + + with pytest.raises(psycopg2.errors.UndefinedTable): + run_statements(conn_two, f"SELECT * FROM {name}", ConfigNoAutocommit) + + +def test_autocommit_with_dbapi_connection(setup_postgreSQL, psycopg2_factory): + conn_raw_one = psycopg2_factory() + conn_raw_two = psycopg2_factory() + + conn_one = DBAPIConnection(conn_raw_one, config=ConfigAutocommit) + conn_two = DBAPIConnection(conn_raw_two, config=ConfigAutocommit) + + name = gen_name() + + run_statements(conn_one, f"CREATE TABLE {name} (id int)", ConfigAutocommit) + run_statements(conn_two, f"SELECT * FROM {name}", ConfigAutocommit) + + +# TODO: do not commit if on blacklist diff --git a/src/tests/integration/test_stats.py b/src/tests/integration/test_stats.py new file mode 100644 index 000000000..fe33b18f5 --- /dev/null +++ b/src/tests/integration/test_stats.py @@ -0,0 +1,48 @@ +import pytest + +from sql.stats import _summary_stats +from sql.connection import SQLAlchemyConnection, SparkConnectConnection + + +@pytest.mark.parametrize( + "fixture_name", + [ + "setup_duckDB", + "setup_MSSQL", + "setup_postgreSQL", + "setup_redshift", + ], +) +def test_summary_stats(fixture_name, request, test_table_name_dict): + engine = request.getfixturevalue(fixture_name) + conn = SQLAlchemyConnection(engine) + table = test_table_name_dict["plot_something"] + column = "x" + + assert _summary_stats(conn, table, column) == { + "q1": 1.0, + "med": 2.0, + "q3": 3.0, + "mean": 2.0, + "N": 5.0, + } + + +@pytest.mark.parametrize( + "fixture_name", + [ + "setup_spark", + ], +) +def test_summary_stats_spark(fixture_name, request, test_table_name_dict): + conn = SparkConnectConnection(request.getfixturevalue(fixture_name)) + table = test_table_name_dict["plot_something"] + column = "x" + + assert _summary_stats(conn, table, column) == { + "q1": 1.0, + "med": 2.0, + "q3": 3.0, + "mean": 2.0, + "N": 5.0, + } diff --git a/src/tests/mock_pymysql.py b/src/tests/mock_pymysql.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/tests/test_column_guesser.py b/src/tests/test_column_guesser.py index 0df2cce29..6c01a60f3 100644 --- a/src/tests/test_column_guesser.py +++ b/src/tests/test_column_guesser.py @@ -1,11 +1,10 @@ -import re -import sys - import pytest from sql.magic import SqlMagic +from sql import _current +from IPython.core.interactiveshell import InteractiveShell -ip = get_ipython() +ip = InteractiveShell() class SqlEnv(object): @@ -22,6 +21,7 @@ def query(self, txt): @pytest.fixture def tbl(): sqlmagic = SqlMagic(shell=ip) + _current._set_sql_magic(sqlmagic) ip.register_magics(sqlmagic) creator = """ DROP TABLE IF EXISTS manycoltbl; diff --git a/src/tests/test_command.py b/src/tests/test_command.py new file mode 100644 index 000000000..65eaf90ae --- /dev/null +++ b/src/tests/test_command.py @@ -0,0 +1,245 @@ +from pathlib import Path +from IPython.core.error import UsageError + +import pytest +from sqlalchemy import create_engine + +from sql.command import SQLCommand + + +@pytest.fixture +def sql_magic(ip): + return ip.magics_manager.lsmagic()["line"]["sql"].__self__ + + +@pytest.mark.parametrize( + ( + "line, cell, parsed_sql, parsed_connection, parsed_result_var," + "parsed_return_result_var" + ), + [ + ("something --no-execute", "", "something", "", None, False), + ("sqlite://", "", "", "sqlite://", None, False), + ("SELECT * FROM TABLE", "", "SELECT * FROM TABLE", "", None, False), + ("SELECT * FROM", "TABLE", "SELECT * FROM\nTABLE", "", None, False), + ( + "my_var << SELECT * FROM table", + "", + "SELECT * FROM table", + "", + "my_var", + False, + ), + ( + "my_var << SELECT *", + "FROM table", + "SELECT *\nFROM table", + "", + "my_var", + False, + ), + ( + "my_var= << SELECT * FROM table", + "", + "SELECT * FROM table", + "", + "my_var", + True, + ), + ("[db]", "", "", "sqlite://", None, False), + ("--persist df", "", "df", "", None, False), + ], + ids=[ + "arg-with-option", + "connection-string", + "sql-query", + "sql-query-in-line-and-cell", + "parsed-var-single-line", + "parsed-var-multi-line", + "parsed-return-var-single-line", + "config", + "persist-dataframe", + ], +) +def test_parsed( + ip, + sql_magic, + line, + cell, + parsed_sql, + parsed_connection, + parsed_result_var, + parsed_return_result_var, + tmp_empty, +): + ip.run_cell("%config SqlMagic.dsn_filename = 'odbc.ini'") + + # needed for the last test case + Path("odbc.ini").write_text( + """ +[db] +drivername = sqlite +""" + ) + + cmd = SQLCommand(sql_magic, ip.user_ns, line, cell) + + assert cmd.parsed == { + "connection": parsed_connection, + "result_var": parsed_result_var, + "return_result_var": parsed_return_result_var, + "sql": parsed_sql, + "sql_original": parsed_sql, + } + + assert cmd.connection == parsed_connection + assert cmd.sql == parsed_sql + assert cmd.sql_original == parsed_sql + + +def test_parsed_sql_when_using_with(ip, sql_magic): + ip.run_cell_magic( + "sql", + "--save author_one", + """ + SELECT * FROM author LIMIT 1 + """, + ) + + cmd = SQLCommand( + sql_magic, ip.user_ns, line="--with author_one", cell="SELECT * FROM author_one" + ) + + sql = ( + "WITH `author_one` AS (\n\n SELECT * FROM author LIMIT " + "1\n)\nSELECT * FROM author_one" + ) + + sql_original = "\nSELECT * FROM author_one" + + assert cmd.parsed == { + "connection": "", + "result_var": None, + "return_result_var": False, + "sql": sql, + "sql_original": sql_original, + } + + assert cmd.connection == "" + assert cmd.sql == sql + assert cmd.sql_original == sql_original + + +def test_parsed_sql_when_using_file(ip, sql_magic, tmp_empty): + Path("query.sql").write_text("SELECT * FROM author") + cmd = SQLCommand(sql_magic, ip.user_ns, "--file query.sql", "") + + assert cmd.parsed == { + "connection": "", + "result_var": None, + "return_result_var": False, + "sql": "SELECT * FROM author\n", + "sql_original": "SELECT * FROM author\n", + } + + assert cmd.connection == "" + assert cmd.sql == "SELECT * FROM author\n" + assert cmd.sql_original == "SELECT * FROM author\n" + + +def test_args(ip, sql_magic): + ip.run_cell_magic( + "sql", + "--save author_one", + """ + SELECT * FROM author LIMIT 1 + """, + ) + + cmd = SQLCommand(sql_magic, ip.user_ns, line="--with author_one", cell="") + + assert cmd.args.__dict__ == { + "alias": None, + "line": "", + "connections": False, + "close": None, + "creator": None, + "section": None, + "persist": False, + "persist_replace": False, + "no_index": False, + "append": False, + "connection_arguments": None, + "file": None, + "interact": None, + "save": None, + "with_": ["author_one"], + "no_execute": False, + } + + +@pytest.mark.parametrize( + "line", + [ + "my_engine", + " my_engine", + "my_engine ", + ], +) +def test_parse_sql_when_passing_engine(ip, sql_magic, tmp_empty, line): + engine = create_engine("sqlite:///my.db") + ip.user_global_ns["my_engine"] = engine + + cmd = SQLCommand(sql_magic, ip.user_ns, line, cell="SELECT * FROM author") + + sql_expected = "\nSELECT * FROM author" + + assert cmd.parsed == { + "connection": engine, + "result_var": None, + "return_result_var": False, + "sql": sql_expected, + "sql_original": sql_expected, + } + + assert cmd.connection is engine + assert cmd.sql == sql_expected + assert cmd.sql_original == sql_expected + + +def test_variable_substitution_double_curly_cell_magic(ip, sql_magic): + ip.user_global_ns["username"] = "some-user" + + cmd = SQLCommand( + sql_magic, + ip.user_ns, + line="", + cell="GRANT CONNECT ON DATABASE postgres TO {{username}};", + ) + + assert cmd.parsed["sql"] == "\nGRANT CONNECT ON DATABASE postgres TO some-user;" + + +def test_variable_substitution_double_curly_line_magic(ip, sql_magic): + ip.user_global_ns["limit_number"] = 5 + ip.user_global_ns["column_name"] = "first_name" + cmd = SQLCommand( + sql_magic, + ip.user_ns, + line="SELECT {{column_name}} FROM author LIMIT {{limit_number}};", + cell="", + ) + + assert cmd.parsed["sql"] == "SELECT first_name FROM author LIMIT 5;" + + +def test_with_contains_dash_show_warning_message(ip, sql_magic, capsys): + with pytest.raises(UsageError) as error: + ip.run_cell_magic( + "sql", + "--save author-sub", + "SELECT last_name FROM author WHERE year_of_death > 1900", + ) + + assert error.value.error_type == "UsageError" + assert "Using hyphens (-) in save argument isn't allowed" in str(error.value) diff --git a/src/tests/test_config.py b/src/tests/test_config.py new file mode 100644 index 000000000..7b47354d3 --- /dev/null +++ b/src/tests/test_config.py @@ -0,0 +1,652 @@ +import os +import re +from pathlib import Path + +import pytest +import sys +from unittest.mock import Mock + +from sql.magic import load_ipython_extension +from sql.connection import ConnectionManager +from sql.util import get_default_configs, CONFIGURATION_DOCS_STR +from sql import display +from IPython.core.error import UsageError + + +def get_current_configs(magic): + cfg = magic.trait_values() + del cfg["parent"] + del cfg["config"] + return cfg + + +def get_default_testing_configs(sql): + """ + Returns a dictionary of SqlMagic configuration settings users can set + with their default values. + """ + cfg = get_default_configs(sql) + # we're overriding this in conftest.py + cfg["dsn_filename"] = "default.ini" + return cfg + + +def test_dsn_filename_default_value(sql_magic): + assert sql_magic.dsn_filename == str( + Path("~/.jupysql/connections.ini").expanduser() + ) + + +def test_dsn_filename_resolves_user_directory(sql_magic): + sql_magic.dsn_filename = "~/connections.ini" + + path = Path("~/connections.ini").expanduser() + expected = str(path) + + # setting the value should not create the file + assert not path.exists() + + # but it should resolve the path + assert sql_magic.dsn_filename == expected + + +def test_no_error_if_connection_file_doesnt_exist(tmp_empty, ip_no_magics): + ip_no_magics.run_cell("%config SqlMagic.dsn_filename = 'connections.ini'") + + load_ipython_extension(ip_no_magics) + + assert not Path("connections.ini").exists() + + +def test_no_error_if_connection_file_doesnt_have_default_section( + tmp_empty, ip_no_magics +): + Path("connections.ini").write_text( + """ +[duck] +drivername = sqlite +""" + ) + + ip_no_magics.run_cell("%config SqlMagic.dsn_filename = 'connections.ini'") + + load_ipython_extension(ip_no_magics) + + assert not ConnectionManager.connections + + +def test_start_ini_default_connection_if_any(tmp_empty, ip_no_magics): + Path("connections.ini").write_text( + """ +[default] +drivername = sqlite +""" + ) + + ip_no_magics.run_cell("%config SqlMagic.dsn_filename = 'connections.ini'") + + load_ipython_extension(ip_no_magics) + + assert set(ConnectionManager.connections) == {"default"} + assert ConnectionManager.current.dialect == "sqlite" + + +def test_config_loads_query_element_as_url_params(tmp_empty, ip_no_magics): + Path("connections.ini").write_text( + """ +[default] +drivername = sqlite +query = {'param1': 'value1', 'param2': 'value2'} +""" + ) + ip_no_magics.run_cell("%config SqlMagic.dsn_filename = 'connections.ini'") + + load_ipython_extension(ip_no_magics) + + assert set(ConnectionManager.connections) == {"default"} + assert ConnectionManager.current.dialect == "sqlite" + assert ConnectionManager.current.url == "sqlite://?param1=value1¶m2=value2" + + +def test_load_home_toml_if_no_pyproject_toml( + tmp_empty, ip_no_magics, capsys, monkeypatch +): + monkeypatch.setattr( + Path, "expanduser", lambda path: Path(str(path).replace("~", tmp_empty)) + ) + home_toml = Path("~/.jupysql/config").expanduser() + home_toml.parent.mkdir(exist_ok=True) + home_toml.write_text( + """ +[tool.jupysql.SqlMagic] +autocommit = false +autolimit = 1 +style = "RANDOM" +""" + ) + + expect = [ + "Settings changed:", + r"autocommit\s*\|\s*False", + r"autolimit\s*\|\s*1", + r"style\s*\|\s*RANDOM", + ] + + config_expected = {"autocommit": False, "autolimit": 1, "style": "RANDOM"} + + os.mkdir("sub") + os.chdir("sub") + + load_ipython_extension(ip_no_magics) + magic = ip_no_magics.find_magic("sql").__self__ + combined = {**get_default_testing_configs(magic), **config_expected} + out, _ = capsys.readouterr() + assert all(re.search(substring, out) for substring in expect) + assert get_current_configs(magic) == combined + + +def test_load_home_toml_if_sqlmagic_section_not_in_pyproject_toml( + tmp_empty, ip_no_magics, capsys, monkeypatch +): + monkeypatch.setattr( + Path, "expanduser", lambda path: Path(str(path).replace("~", tmp_empty)) + ) + home_toml = Path("~/.jupysql/config").expanduser() + home_toml.parent.mkdir(exist_ok=True) + home_toml.write_text( + """ +[tool.jupysql.SqlMagic] +autocommit = false +autolimit = 1 +style = "RANDOM" +""" + ) + + Path("pyproject.toml").write_text( + """ +[tool.jupysql] +""" + ) + + expect = [ + "Settings changed:", + r"autocommit\s*\|\s*False", + r"autolimit\s*\|\s*1", + r"style\s*\|\s*RANDOM", + ] + + config_expected = {"autocommit": False, "autolimit": 1, "style": "RANDOM"} + + os.mkdir("sub") + os.chdir("sub") + + load_ipython_extension(ip_no_magics) + magic = ip_no_magics.find_magic("sql").__self__ + combined = {**get_default_testing_configs(magic), **config_expected} + out, _ = capsys.readouterr() + assert all(re.search(substring, out) for substring in expect) + assert get_current_configs(magic) == combined + + +def test_start_ini_default_connection_using_toml_if_any(tmp_empty, ip_no_magics): + Path("pyproject.toml").write_text( + """ +[tool.jupysql.SqlMagic] +dsn_filename = 'myconnections.ini' +""" + ) + + Path("myconnections.ini").write_text( + """ +[default] +drivername = duckdb +""" + ) + + load_ipython_extension(ip_no_magics) + + assert set(ConnectionManager.connections) == {"default"} + assert ConnectionManager.current.dialect == "duckdb" + + +def test_magic_initialization_when_default_connection_fails( + tmp_empty, ip_no_magics, capsys +): + ip_no_magics.run_cell("%config SqlMagic.dsn_filename = 'connections.ini'") + + Path("connections.ini").write_text( + """ +[default] +drivername = someunknowndriver +""" + ) + + load_ipython_extension(ip_no_magics) + + captured = capsys.readouterr() + assert "Cannot start default connection" in captured.out + + +def test_magic_initialization_with_no_toml(tmp_empty, ip_no_magics): + load_ipython_extension(ip_no_magics) + + +def test_magic_initialization_with_corrupted_pyproject_toml( + tmp_empty, ip_no_magics, capsys +): + Path("pyproject.toml").write_text( + """ +[tool.jupysql.SqlMagic] +dsn_filename = myconnections.ini +""" + ) + + load_ipython_extension(ip_no_magics) + + captured = capsys.readouterr() + assert "Could not load configuration file" in captured.out + + +def test_magic_initialization_with_corrupted_home_toml( + tmp_empty, ip_no_magics, capsys, monkeypatch +): + monkeypatch.setattr( + Path, "expanduser", lambda path: Path(str(path).replace("~", tmp_empty)) + ) + home_toml = Path("~/.jupysql/config").expanduser() + home_toml.parent.mkdir(exist_ok=True) + home_toml.write_text( + """ +[tool.jupysql.SqlMagic] +dsn_filename = myconnections.ini +""" + ) + + load_ipython_extension(ip_no_magics) + + captured = capsys.readouterr() + assert "Could not load configuration file" in captured.out + + +def test_loading_valid_pyproject_toml_shows_feedback_and_modifies_config( + tmp_empty, ip_no_magics, capsys +): + Path("pyproject.toml").write_text( + """ +[tool.jupysql.SqlMagic] +autocommit = false +autolimit = 1 +style = "RANDOM" +""" + ) + + expect = [ + "Loading configurations from {path}", + "Settings changed:", + r"autocommit\s*\|\s*False", + r"autolimit\s*\|\s*1", + r"style\s*\|\s*RANDOM", + ] + + config_expected = {"autocommit": False, "autolimit": 1, "style": "RANDOM"} + + toml_path = str(Path(os.getcwd()).joinpath("pyproject.toml")) + + os.mkdir("sub") + os.chdir("sub") + + load_ipython_extension(ip_no_magics) + magic = ip_no_magics.find_magic("sql").__self__ + combined = {**get_default_testing_configs(magic), **config_expected} + out, _ = capsys.readouterr() + expect[0] = expect[0].format(path=re.escape(toml_path)) + assert all(re.search(substring, out) for substring in expect) + assert get_current_configs(magic) == combined + + +def test_loading_valid_home_toml_shows_feedback_and_modifies_config( + tmp_empty, ip_no_magics, capsys, monkeypatch +): + monkeypatch.setattr( + Path, "expanduser", lambda path: Path(str(path).replace("~", tmp_empty)) + ) + home_toml = Path("~/.jupysql/config").expanduser() + home_toml.parent.mkdir(exist_ok=True) + home_toml.write_text( + """ +[tool.jupysql.SqlMagic] +autocommit = false +autolimit = 1 +style = "RANDOM" +""" + ) + + expect = [ + "Loading configurations from {path}", + "Settings changed:", + r"autocommit\s*\|\s*False", + r"autolimit\s*\|\s*1", + r"style\s*\|\s*RANDOM", + ] + + config_expected = {"autocommit": False, "autolimit": 1, "style": "RANDOM"} + + os.mkdir("sub") + os.chdir("sub") + + load_ipython_extension(ip_no_magics) + magic = ip_no_magics.find_magic("sql").__self__ + combined = {**get_default_testing_configs(magic), **config_expected} + out, _ = capsys.readouterr() + expect[0] = expect[0].format(path=re.escape(str(home_toml))) + assert all(re.search(substring, out) for substring in expect) + assert get_current_configs(magic) == combined + + +@pytest.mark.parametrize( + "file_content, expected_message", + [ + ( + """ +[tool.jupysql.SqlMagic] +""", + "[tool.jupysql.SqlMagic] present in {primary_path} but empty.", + ), + ("", "Tip: You may define configurations in {primary_path} or {alt_path}."), + ], + ids=["empty_sqlmagic_key", "missing_sqlmagic_key"], +) +def test_loading_toml_display_configuration_docs_link( + tmp_empty, capsys, ip_no_magics, file_content, expected_message, monkeypatch +): + Path("pyproject.toml").write_text(file_content) + toml_path = Path(os.getcwd()).joinpath("pyproject.toml") + config_path = Path("~/.jupysql/config").expanduser() + + os.mkdir("sub") + os.chdir("sub") + + mock = Mock() + monkeypatch.setattr(display, "message_html", mock) + load_ipython_extension(ip_no_magics) + out, _ = capsys.readouterr() + + param = ( + f"Please review our " + f"configuration guideline." + ) + + expected_message = expected_message.format( + primary_path=str(toml_path), alt_path=str(config_path) + ) + + assert expected_message in out + mock.assert_called_once_with(param) + + +@pytest.mark.parametrize( + "file_content", + [ + ( + """ +[test] +github = "ploomber/jupysql" +""" + ), + ( + """ +[tool.pkgmt] +github = "ploomber/jupysql" +""" + ), + ( + """ +[tool.jupysql.test] +github = "ploomber/jupysql" +""" + ), + ], +) +def test_load_toml_user_configurations_not_specified( + tmp_empty, ip_no_magics, capsys, file_content +): + Path("pyproject.toml").write_text(file_content) + os.mkdir("sub") + os.chdir("sub") + + load_ipython_extension(ip_no_magics) + out, _ = capsys.readouterr() + assert "Loading configurations from" not in out + + +@pytest.mark.parametrize( + "file_content, error_msg", + [ + ( + """ +[tool.jupysql.SqlMagic] +autocommit = true +autocommit = true +""", + "Duplicate key found: 'autocommit' in {path}", + ), + ( + """ +[tool.jupysql.SQLMagics] +autocommit = true +""", + "[tool.jupysql.SQLMagics] is an invalid section name in {path}. " + "Did you mean [tool.jupysql.SqlMagic]?", + ), + ( + """ +[tool.jupysql.SqlMagic] +autocommit = True +""", + ( + "Invalid value 'True' in 'autocommit = True' in {path}. " + "Valid boolean values: true, false" + ), + ), + ( + """ +[tool.jupysql.SqlMagic] +autocommit = invalid +""", + ( + "Invalid value 'invalid' in 'autocommit = invalid' in {path}. " + "To use str value, enclose it with ' or \"." + ), + ), + ], +) +def test_error_on_toml_parsing( + tmp_empty, ip_no_magics, capsys, file_content, error_msg +): + Path("pyproject.toml").write_text(file_content) + toml_path = str(Path(os.getcwd()).joinpath("pyproject.toml")) + os.makedirs("sub") + os.chdir("sub") + + with pytest.raises(UsageError) as excinfo: + load_ipython_extension(ip_no_magics) + + out, _ = capsys.readouterr() + + assert excinfo.value.error_type == "ConfigurationError" + assert str(excinfo.value) == error_msg.format(path=toml_path) + + +def test_valid_and_invalid_configs(tmp_empty, ip_no_magics, capsys): + Path("pyproject.toml").write_text( + """ +[tool.jupysql.SqlMagic] +autocomm = true +autop = false +autolimit = "text" +invalid = false +displaycon = false +""" + ) + toml_path = str(Path(os.getcwd()).joinpath("pyproject.toml")) + os.makedirs("sub") + os.chdir("sub") + + load_ipython_extension(ip_no_magics) + out, _ = capsys.readouterr() + expect = [ + f"Loading configurations from {re.escape(toml_path)}", + "'autocomm' is an invalid configuration. Did you mean 'autocommit'?", + ( + "'autop' is an invalid configuration. " + "Did you mean 'autopandas', or 'autopolars'?" + ), + ( + "'text' is an invalid value for 'autolimit'. " + "Please use int value instead." + ), + r"displaycon\s*\|\s*False", + ] + assert all(re.search(substring, out) for substring in expect) + + # confirm the correct changes are applied + confirm = {"displaycon": False, "autolimit": 0} + sql = ip_no_magics.find_cell_magic("sql").__self__ + assert all([getattr(sql, config) == value for config, value in confirm.items()]) + + +def test_toml_optional_message(tmp_empty, monkeypatch, ip, capsys): + monkeypatch.setitem(sys.modules, "toml", None) + Path("pyproject.toml").write_text( + """ +[tool.jupysql.SqlMagic] +autocommit = true +""" + ) + + ip.run_cell("%load_ext sql") + out, _ = capsys.readouterr() + assert ( + "The 'toml' package isn't installed. " + "To load settings from pyproject.toml or ~/.jupysql/config, " + "install with: pip install toml" + ) in out + + +@pytest.mark.parametrize( + "pyproject_content, config_content, expected_messages", + [ + ( + "", + "", + [ + ( + "Tip: You may define configurations in " + "{pyproject_path} or {config_path}." + ), + "Did not find user configurations in {pyproject_path}.", + "Did not find user configurations in {config_path}.", + ], + ), + ( + "", + "[tool.jupysql.SqlMagic]", + [ + ( + "Tip: You may define configurations in " + "{pyproject_path} or {config_path}." + ), + "Did not find user configurations in {pyproject_path}.", + "[tool.jupysql.SqlMagic] present in {config_path} but empty.", + ], + ), + ( + "", + """ +[tool.jupysql.SqlMagic] +feedback=True +autopandas=True +""", + [ + ( + "Tip: You may define configurations in " + "{pyproject_path} or {config_path}." + ), + "Did not find user configurations in {pyproject_path}.", + ], + ), + ( + "[tool.jupysql.SqlMagic]", + "", + [ + "[tool.jupysql.SqlMagic] present in {pyproject_path} but empty.", + "Did not find user configurations in {config_path}.", + ], + ), + ( + "[tool.jupysql.SqlMagic]", + "[tool.jupysql.SqlMagic]", + [ + "[tool.jupysql.SqlMagic] present in {pyproject_path} but empty.", + "[tool.jupysql.SqlMagic] present in {config_path} but empty.", + ], + ), + ( + "[tool.jupysql.SqlMagic]", + """ +[tool.jupysql.SqlMagic] +feedback=True +autopandas=True +""", + [ + "[tool.jupysql.SqlMagic] present in {pyproject_path} but empty.", + ], + ), + ( + "[tool.JupySQL.SqlMagic]", + "", + [ + "Hint: We found 'tool.JupySQL' in {pyproject_path}. " + "Did you mean 'tool.jupysql'?", + ], + ), + ], +) +def test_user_config_load_sequence_and_messages( + tmp_empty, + ip_no_magics, + monkeypatch, + capsys, + pyproject_content, + config_content, + expected_messages, +): + toml_path = Path("pyproject.toml") + toml_path.touch(exist_ok=True) + toml_path.write_text(pyproject_content) + + Path("~/.jupysql").expanduser().mkdir(parents=True, exist_ok=True) + config_path = Path("~/.jupysql/config").expanduser() + config_path.touch(exist_ok=True) + config_path.write_text(config_content) + + toml_path = str(Path(os.getcwd()).joinpath("pyproject.toml")) + config_path = str(Path("~/.jupysql/config").expanduser()) + + mock = Mock() + monkeypatch.setattr(display, "message_html", mock) + load_ipython_extension(ip_no_magics) + out, _ = capsys.readouterr() + + param = ( + f"Please review our " + f"configuration guideline." + ) + + for message in expected_messages: + expected_message = message.format( + pyproject_path=str(toml_path), config_path=str(config_path) + ) + assert expected_message in out + + mock.assert_called_once_with(param) diff --git a/src/tests/test_connection.py b/src/tests/test_connection.py new file mode 100644 index 000000000..ae1e0e4a8 --- /dev/null +++ b/src/tests/test_connection.py @@ -0,0 +1,1319 @@ +import os +import sys +from unittest.mock import ANY, Mock, patch +import pytest + + +from IPython.core.error import UsageError +import duckdb +import sqlglot +import sqlalchemy +import sqlite3 +from sqlalchemy import create_engine +from sqlalchemy.engine import Engine +from sqlalchemy import exc + + +from sql.connection import connection as connection_module +import sql.connection +from sql.connection import ( + SQLAlchemyConnection, + DBAPIConnection, + ConnectionManager, + is_pep249_compliant, + default_alias_for_engine, + is_spark, + ResultSetCollection, + detect_duckdb_summarize_or_select, +) +from sql.warnings import JupySQLRollbackPerformed +from sql.connection import error_handling + + +@pytest.fixture +def cleanup(): + yield + ConnectionManager.connections = {} + + +@pytest.fixture +def mock_database(monkeypatch, cleanup): + monkeypatch.setitem(sys.modules, "some_driver", Mock()) + monkeypatch.setattr(Engine, "connect", Mock()) + monkeypatch.setattr(sqlalchemy, "create_engine", Mock()) + + +def mock_sparksession(): + mock = Mock( + spec=[ + "table", + "read", + "createDataFrame", + "sql", + "stop", + "catalog", + "version", + ] + ) + return mock + + +def mock_not_sparksession(): + mock = Mock( + spec=[ + "read", + "readStream", + "createDataFrame", + "sql", + "version", + ] + ) + return mock + + +@pytest.fixture +def mock_postgres(monkeypatch, cleanup): + monkeypatch.setitem(sys.modules, "psycopg2", Mock()) + monkeypatch.setattr(Engine, "connect", Mock()) + + +def test_password_isnt_displayed(mock_postgres): + ConnectionManager.from_connect_str("postgresql://user:topsecret@somedomain.com/db") + + table = ConnectionManager.connections_table() + + assert "topsecret" not in str(table) + assert "topsecret" not in table._repr_html_() + + +def test_connection_name(mock_postgres): + conn = ConnectionManager.from_connect_str( + "postgresql://user:topsecret@somedomain.com/db" + ) + + assert conn.name == "user@db" + + +def test_alias(cleanup): + ConnectionManager.from_connect_str("sqlite://", alias="some-alias") + + assert list(ConnectionManager.connections) == ["some-alias"] + + +def test_get_database_information(): + engine = create_engine("sqlite://") + conn = SQLAlchemyConnection(engine=engine) + + assert conn._get_database_information() == { + "dialect": "sqlite", + "driver": "pysqlite", + "server_version_info": ANY, + } + + +def test_get_sqlglot_dialect_no_curr_connection(mock_database, monkeypatch): + conn = SQLAlchemyConnection(engine=sqlalchemy.create_engine("someurl://")) + monkeypatch.setattr(conn, "_get_database_information", lambda: {"dialect": None}) + assert conn._get_sqlglot_dialect() is None + + +@pytest.mark.parametrize( + "sqlalchemy_connection_info, expected_sqlglot_dialect", + [ + ( + { + "dialect": "duckdb", + "driver": "duckdb_engine", + "server_version_info": [8, 0], + }, + "duckdb", + ), + ( + { + "dialect": "mysql", + "driver": "pymysql", + "server_version_info": [10, 10, 3, 10, 3], + }, + "mysql", + ), + # sqlalchemy and sqlglot have different dialect name, test the mapping dict + ( + { + "dialect": "sqlalchemy_mock_dialect_name", + "driver": "sqlalchemy_mock_driver_name", + "server_version_info": [0], + }, + "sqlglot_mock_dialect", + ), + ( + { + "dialect": "only_support_in_sqlalchemy_dialect", + "driver": "sqlalchemy_mock_driver_name", + "server_version_info": [0], + }, + "only_support_in_sqlalchemy_dialect", + ), + ], +) +def test_get_sqlglot_dialect( + monkeypatch, sqlalchemy_connection_info, expected_sqlglot_dialect, mock_database +): + """To test if we can get the dialect name in sqlglot package scope + + Args: + monkeypatch (fixture): A convenient fixture for monkey-patching + sqlalchemy_connection_info (dict): The metadata about the current dialect + expected_sqlglot_dialect (str): Expected sqlglot dialect name + """ + conn = SQLAlchemyConnection(engine=sqlalchemy.create_engine("someurl://")) + + monkeypatch.setattr( + conn, + "_get_database_information", + lambda: sqlalchemy_connection_info, + ) + monkeypatch.setattr( + sql.connection.connection, + "DIALECT_NAME_SQLALCHEMY_TO_SQLGLOT_MAPPING", + {"sqlalchemy_mock_dialect_name": "sqlglot_mock_dialect"}, + ) + assert conn._get_sqlglot_dialect() == expected_sqlglot_dialect + + +@pytest.mark.parametrize( + "cur_dialect, expected_support_backtick", + [ + ("mysql", True), + ("sqlite", True), + ("postgres", False), + ], +) +def test_is_use_backtick_template( + mock_database, cur_dialect, expected_support_backtick, monkeypatch +): + """To test if we can get the backtick supportive information from different dialects + + Args: + monkeypatch (fixture): A convenient fixture for monkey-patching + cur_dialect (bool): Patched dialect name + expected_support_backtick (bool): Excepted boolean value to indicate + if the dialect supports backtick identifier + """ + # conn = Connection(engine=create_engine(sqlalchemy_url)) + conn = SQLAlchemyConnection(engine=sqlalchemy.create_engine("someurl://")) + monkeypatch.setattr(conn, "_get_sqlglot_dialect", lambda: cur_dialect) + assert conn.is_use_backtick_template() == expected_support_backtick + + +def test_is_use_backtick_template_sqlglot_missing_dialect_ValueError( + mock_database, monkeypatch +): + """Since accessing missing dialect will raise ValueError from sqlglot, we assume + that's not support case + """ + conn = SQLAlchemyConnection(engine=create_engine("sqlite://")) + + monkeypatch.setattr(conn, "_get_sqlglot_dialect", lambda: "something_weird_dialect") + assert conn.is_use_backtick_template() is False + + +def test_is_use_backtick_template_sqlglot_missing_tokenizer_AttributeError( + mock_database, monkeypatch +): + """Since accessing the dialect without Tokenizer Class will raise AttributeError + from sqlglot, we assume that's not support case + """ + conn = SQLAlchemyConnection(engine=create_engine("sqlite://")) + + monkeypatch.setattr(conn, "_get_sqlglot_dialect", lambda: "mysql") + monkeypatch.setattr(sqlglot.dialects.mysql.MySQL, "Tokenizer", None) + + assert conn.is_use_backtick_template() is False + + +def test_is_use_backtick_template_sqlglot_missing_identifiers_TypeError( + mock_database, monkeypatch +): + """Since accessing the IDENTIFIERS list of the dialect's Tokenizer Class + will raise TypeError from sqlglot, we assume that's not support case + """ + conn = SQLAlchemyConnection(engine=create_engine("sqlite://")) + + monkeypatch.setattr(conn, "_get_sqlglot_dialect", lambda: "mysql") + monkeypatch.setattr( + sqlglot.Dialect.get_or_raise("mysql").Tokenizer, "IDENTIFIERS", None + ) + assert conn.is_use_backtick_template() is False + + +def test_is_use_backtick_template_sqlglot_empty_identifiers(mock_database, monkeypatch): + """Since looking up the "`" symbol in IDENTIFIERS list of the dialect's + Tokenizer Class will raise TypeError from sqlglot, we assume that's not support case + """ + conn = SQLAlchemyConnection(engine=create_engine("sqlite://")) + + monkeypatch.setattr(conn, "_get_sqlglot_dialect", lambda: "mysql") + monkeypatch.setattr( + sqlglot.Dialect.get_or_raise("mysql").Tokenizer, "IDENTIFIERS", [] + ) + assert conn.is_use_backtick_template() is False + + +# Mock the missing package +# Ref: https://stackoverflow.com/a/28361013 +def test_missing_duckdb_dependencies(cleanup, monkeypatch): + with patch.dict(sys.modules): + sys.modules["duckdb"] = None + sys.modules["duckdb_engine"] = None + + with pytest.raises(UsageError) as excinfo: + ConnectionManager.from_connect_str("duckdb://") + + assert excinfo.value.error_type == "MissingPackageError" + assert "try to install package: duckdb-engine" + str(excinfo.value) + + +@pytest.mark.parametrize( + "connect_str, pkg_missing, pkg_in_install_command", + [ + # MySQL + MariaDB + ("mysql+pymysql://", "pymysql", "pymysql"), + ("mysql+mysqldb://", "mysqlclient", "mysqlclient"), + ("mariadb+mariadbconnector://", "mariadb", "mariadb"), + ("mysql+mysqlconnector://", "mysql-connector-python", "mysql-connector-python"), + ("mysql+asyncmy://", "asyncmy", "asyncmy"), + ("mysql+aiomysql://", "aiomysql", "aiomysql"), + ("mysql+cymysql://", "cymysql", "cymysql"), + ("mysql+pyodbc://", "pyodbc", "pyodbc"), + # PostgreSQL + ("postgresql://", "psycopg2", "psycopg2"), + ("postgresql+psycopg2://", "psycopg2", "psycopg2"), + ("postgresql+psycopg://", "psycopg", "psycopg"), + ("postgresql+pg8000://", "pg8000", "pg8000"), + ("postgresql+asyncpg://", "asyncpg", "asyncpg"), + ("postgresql+psycopg2cffi://", "psycopg2cffi", "psycopg2cffi"), + # Oracle + ("oracle+cx_oracle://", "cx_oracle", "cx_oracle"), + ("oracle+oracledb://", "oracledb", "oracledb"), + # MSSQL + ("mssql+pyodbc://", "pyodbc", "pyodbc"), + ("mssql+pymssql://", "pymssql", "pymssql"), + ], +) +def test_error_when_missing_driver( + connect_str, pkg_missing, pkg_in_install_command, monkeypatch +): + # psycopg2 returns %conda install if conda is installed + monkeypatch.setattr(error_handling, "_CONDA_INSTALLED", False) + + with patch.dict(sys.modules): + sys.modules[pkg_missing] = None + + with pytest.raises(UsageError) as excinfo: + ConnectionManager.from_connect_str(connect_str) + + assert excinfo.value.error_type == "MissingPackageError" + expected = f"run this in your notebook: %pip install {pkg_in_install_command}" + assert expected in str(excinfo.value) + + +@pytest.mark.parametrize( + "connect_str, dialect, pkg_in_install_command", + [ + ("duckdb://", "duckdb", "duckdb-engine"), + ("snowflake://", "snowflake", "snowflake-sqlalchemy"), + ], +) +def test_error_when_cannot_load_plugin( + connect_str, dialect, pkg_in_install_command, monkeypatch +): + mock = Mock( + side_effect=exc.NoSuchModuleError( + f"Can't load plugin: sqlalchemy.dialects:{dialect}" + ) + ) + monkeypatch.setattr(connection_module.sqlalchemy, "create_engine", mock) + + with pytest.raises(UsageError) as excinfo: + ConnectionManager.from_connect_str(connect_str) + + assert excinfo.value.error_type == "MissingPackageError" + expected = f"run this in your notebook: %pip install {pkg_in_install_command}" + assert expected in str(excinfo.value) + + +@pytest.mark.parametrize( + "missing_pkg, except_missing_pkg_suggestion, connect_str", + [ + ("psycopg2", "psycopg2", "postgresql+psycopg2://"), + ], +) +def test_error_when_missing_driver_with_conda( + monkeypatch, missing_pkg, except_missing_pkg_suggestion, connect_str +): + # psycopg2 returns %conda install if conda is installed + monkeypatch.setattr(error_handling, "_CONDA_INSTALLED", True) + + with patch.dict(sys.modules): + sys.modules[missing_pkg] = None + + with pytest.raises(UsageError) as excinfo: + ConnectionManager.from_connect_str(connect_str) + + assert excinfo.value.error_type == "MissingPackageError" + expected = f"run this in your notebook: %conda install {missing_pkg}" + assert expected in str(excinfo.value) + + +@pytest.mark.parametrize( + "missing_pkg, section_name, connect_str", + [ + ("psycopg2", "postgresql", "postgresql+psycopg2://"), + ], +) +def test_error_shows_link_to_installation_instructions_when_missing_package( + monkeypatch, missing_pkg, section_name, connect_str +): + with patch.dict(sys.modules): + sys.modules[missing_pkg] = None + + with pytest.raises(UsageError) as excinfo: + ConnectionManager.from_connect_str(connect_str) + + assert excinfo.value.error_type == "MissingPackageError" + expected = f"howto/db-drivers.html#{section_name}" + assert expected in str(excinfo.value) + + +@pytest.mark.parametrize( + "missing_pkg, dialect, connect_str", + [ + ("duckdb_engine", "duckdb", "duckdb://"), + ], +) +def test_error_shows_link_to_installation_instructions_when_missing_dialect( + monkeypatch, missing_pkg, dialect, connect_str +): + mock = Mock( + side_effect=exc.NoSuchModuleError( + f"Can't load plugin: sqlalchemy.dialects:{dialect}" + ) + ) + monkeypatch.setattr(connection_module.sqlalchemy, "create_engine", mock) + + with pytest.raises(UsageError) as excinfo: + ConnectionManager.from_connect_str(connect_str) + + assert excinfo.value.error_type == "MissingPackageError" + expected = f"howto/db-drivers.html#{dialect}" + assert expected in str(excinfo.value) + + +def test_get_connections(): + SQLAlchemyConnection(engine=create_engine("sqlite://")) + SQLAlchemyConnection(engine=create_engine("duckdb://")) + + assert ConnectionManager._get_connections() == [ + { + "url": "duckdb://", + "current": True, + "alias": "duckdb://", + "key": "duckdb://", + "connection": ANY, + }, + { + "url": "sqlite://", + "current": False, + "alias": "sqlite://", + "key": "sqlite://", + "connection": ANY, + }, + ] + + +def test_display_current_connection(capsys): + SQLAlchemyConnection(engine=create_engine("duckdb://")) + ConnectionManager.display_current_connection() + + captured = capsys.readouterr() + assert captured.out == "Running query in 'duckdb://'\n" + + +def test_connections_table(): + SQLAlchemyConnection(engine=create_engine("sqlite://")) + SQLAlchemyConnection(engine=create_engine("duckdb://")) + + connections = ConnectionManager.connections_table() + assert connections._headers == ["current", "url", "alias"] + assert connections._rows == [ + ["*", "duckdb://", "duckdb://"], + ["", "sqlite://", "sqlite://"], + ] + + +def test_properties(mock_postgres): + conn = ConnectionManager.from_connect_str( + "postgresql://user:topsecret@somedomain.com/db" + ) + + assert "topsecret" not in conn.url + assert "***" in conn.url + assert conn.name == "user@db" + assert conn.dialect + assert conn.connection_sqlalchemy + assert conn.connection_sqlalchemy is conn._connection + + +@pytest.mark.parametrize( + "conn, expected", + [ + [sqlite3.connect(""), True], + [duckdb.connect(""), True], + [create_engine("sqlite://"), False], + [object(), False], + ["not_a_valid_connection", False], + [0, False], + ], + ids=[ + "sqlite3-connection", + "duckdb-connection", + "sqlalchemy-engine", + "dummy-object", + "string", + "int", + ], +) +def test_is_pep249_compliant(conn, expected): + assert is_pep249_compliant(conn) is expected + + +@pytest.mark.parametrize( + "descriptor, expected", + [ + [sqlite3.connect(""), False], + [duckdb.connect(""), False], + [create_engine("sqlite://"), False], + [mock_sparksession(), True], + [mock_not_sparksession(), False], + [None, False], + [object(), False], + ["not_a_valid_connection", False], + [0, False], + ], +) +def test_is_spark(descriptor, expected): + assert is_spark(descriptor) is expected + + +def test_close_all(ip_empty, monkeypatch): + connections = {} + monkeypatch.setattr(ConnectionManager, "connections", connections) + + ip_empty.run_cell("%sql duckdb://") + ip_empty.run_cell("%sql sqlite://") + + connections_copy = ConnectionManager.connections.copy() + + ConnectionManager.close_all() + + with pytest.raises(exc.ResourceClosedError): + connections_copy["sqlite://"].execute("").fetchall() + + with pytest.raises(exc.ResourceClosedError): + connections_copy["duckdb://"].execute("").fetchall() + + assert not ConnectionManager.connections + + +@pytest.mark.parametrize( + "old_alias, new_alias", + [ + (None, "duck1"), + ("duck1", "duck2"), + (None, None), + ], +) +def test_new_connection_with_alias(ip_empty, old_alias, new_alias): + """Test if a new connection with the same url but a + new alias is registered for different cases of old alias + """ + ip_empty.run_cell(f"%sql duckdb:// --alias {old_alias}") + ip_empty.run_cell(f"%sql duckdb:// --alias {new_alias}") + table = ip_empty.run_cell("sql --connections").result + if old_alias is None and new_alias is None: + assert new_alias not in table + else: + connection = table[new_alias] + assert connection + assert connection.url == "duckdb://" + assert connection == ConnectionManager.current + + +@pytest.mark.parametrize( + "url, expected", + [ + [ + "postgresql+psycopg2://scott:tiger@localhost:5432/mydatabase", + "scott@mydatabase", + ], + ["duckdb://tmp/my.db", "duckdb://tmp/my.db"], + ["duckdb:///my.db", "duckdb:///my.db"], + ], +) +def test_default_alias_for_engine(url, expected, monkeypatch): + monkeypatch.setitem(sys.modules, "psycopg2", Mock()) + + engine = create_engine(url) + assert default_alias_for_engine(engine) == expected + + +@pytest.mark.parametrize( + "url", + [ + "duckdb://", + "sqlite://", + ], +) +def test_create_connection_from_url(monkeypatch, url): + connections = {} + monkeypatch.setattr(ConnectionManager, "connections", connections) + + conn = ConnectionManager.set(url, displaycon=False) + + assert connections == {url: conn} + assert ConnectionManager.current == conn + + +@pytest.mark.parametrize( + "url", + [ + "duckdb://", + "sqlite://", + ], +) +def test_set_existing_connection(monkeypatch, url): + connections = {} + monkeypatch.setattr(ConnectionManager, "connections", connections) + + ConnectionManager.set(url, displaycon=False) + conn = ConnectionManager.set(url, displaycon=False) + + assert connections == {url: conn} + assert ConnectionManager.current == conn + + +@pytest.mark.parametrize( + "url", + [ + "duckdb://", + "sqlite://", + ], +) +def test_set_engine(monkeypatch, url): + connections = {} + monkeypatch.setattr(ConnectionManager, "connections", connections) + + engine = create_engine(url) + + conn = ConnectionManager.set(engine, displaycon=False) + + assert connections == {url: conn} + assert ConnectionManager.current == conn + + +@pytest.mark.parametrize( + "callable_, key", + [ + [sqlite3.connect, "Connection"], + [duckdb.connect, "DuckDBPyConnection"], + ], +) +def test_set_dbapi(monkeypatch, callable_, key): + connections = {} + monkeypatch.setattr(ConnectionManager, "connections", connections) + + conn = ConnectionManager.set(callable_(""), displaycon=False) + + assert connections == {key: conn} + assert ConnectionManager.current == conn + + +@pytest.mark.parametrize( + "spark, key", + [ + [mock_sparksession(), "Mock"], + ], +) +def test_set_spark(monkeypatch, spark, key): + connections = {} + monkeypatch.setattr(ConnectionManager, "connections", connections) + + conn = ConnectionManager.set(spark, displaycon=False) + + assert connections == {key: conn} + assert ConnectionManager.current == conn + + +def test_set_with_alias(monkeypatch): + connections = {} + monkeypatch.setattr(ConnectionManager, "connections", connections) + + conn = ConnectionManager.set("sqlite://", displaycon=False, alias="some-sqlite-db") + + assert connections == {"some-sqlite-db": conn} + assert ConnectionManager.current == conn + + +def test_set_and_load_with_alias(monkeypatch): + connections = {} + monkeypatch.setattr(ConnectionManager, "connections", connections) + + ConnectionManager.set("sqlite://", displaycon=False, alias="some-sqlite-db") + conn = ConnectionManager.set("some-sqlite-db", displaycon=False) + + assert connections == {"some-sqlite-db": conn} + assert ConnectionManager.current == conn + + +def test_set_same_url_different_alias(monkeypatch): + connections = {} + monkeypatch.setattr(ConnectionManager, "connections", connections) + + some = ConnectionManager.set("sqlite://", displaycon=False, alias="some-sqlite-db") + another = ConnectionManager.set( + "sqlite://", displaycon=False, alias="another-sqlite-db" + ) + conn = ConnectionManager.set("some-sqlite-db", displaycon=False) + + assert connections == {"some-sqlite-db": some, "another-sqlite-db": another} + assert ConnectionManager.current == conn + assert some is conn + + +# NOTE: not sure what the use case for this one is but adding it since the logic +# is implemented this way +def test_same_alias(monkeypatch): + connections = {} + monkeypatch.setattr(ConnectionManager, "connections", connections) + + conn = ConnectionManager.set("sqlite://", displaycon=False, alias="mydb") + second = ConnectionManager.set("mydb", displaycon=False, alias="mydb") + + assert connections == {"mydb": conn} + assert ConnectionManager.current == conn + assert second is conn + + +def test_set_no_descriptor_and_no_active_connection(monkeypatch): + connections = {} + monkeypatch.setattr(ConnectionManager, "connections", connections) + + with pytest.raises(UsageError) as excinfo: + ConnectionManager.set(descriptor=None, displaycon=False, alias=None) + + assert "No active connection." in str(excinfo.value) + + +def test_set_no_descriptor_database_url(monkeypatch): + connections = {} + monkeypatch.setitem(os.environ, "DATABASE_URL", "sqlite://") + monkeypatch.setattr(ConnectionManager, "connections", connections) + + conn = ConnectionManager.set(descriptor=None, displaycon=False) + + assert connections == {"sqlite://": conn} + assert ConnectionManager.current == conn + + +@pytest.mark.parametrize("feedback", [1, 2]) +def test_feedback_when_switching_connection_with_alias( + ip_empty, tmp_empty, capsys, feedback +): + ip_empty.run_cell(f"%config SqlMagic.feedback = {feedback}") + + ip_empty.run_cell("%load_ext sql") + ip_empty.run_cell("%sql duckdb:// --alias one") + ip_empty.run_cell("%sql duckdb:// --alias two") + ip_empty.run_cell("%sql one") + + captured = capsys.readouterr() + assert "Switching to connection 'one'" == captured.out.splitlines()[-1] + + +def test_feedback_when_switching_connection_with_descriptors( + ip_empty, tmp_empty, capsys +): + ip_empty.run_cell("%load_ext sql") + ip_empty.run_cell("%sql duckdb://") + ip_empty.run_cell("%sql sqlite://") + + captured = capsys.readouterr() + assert ( + "Connecting and switching to connection 'sqlite://'" + == captured.out.splitlines()[-1] + ) + + +@pytest.mark.parametrize("feedback", [1, 2]) +def test_feedback_when_switching_connection_without_alias( + ip_empty, tmp_empty, capsys, feedback +): + ip_empty.run_cell(f"%config SqlMagic.feedback = {feedback}") + + ip_empty.run_cell("%load_ext sql") + ip_empty.run_cell("%sql duckdb://") + ip_empty.run_cell("%sql duckdb:// --alias one") + ip_empty.run_cell("%sql duckdb:// --alias two") + ip_empty.run_cell("%sql duckdb://") + + captured = capsys.readouterr() + assert "Switching to connection 'duckdb://'" == captured.out.splitlines()[-1] + + +def test_feedback_when_switching_connection_with_existing_connection( + ip_empty, tmp_empty, capsys +): + ip_empty.run_cell("%load_ext sql") + ip_empty.run_cell("%sql duckdb:// --alias one") + ip_empty.run_cell("%sql duckdb:// --alias two") + ip_empty.run_cell("%sql one") + + captured = capsys.readouterr() + assert "Switching to connection 'one'" == captured.out.splitlines()[-1] + + +@pytest.mark.parametrize( + "connection, identifier, feedback", + [ + ("duckdb://", "duckdb://", 1), + ("duckdb:// --alias one", "one", 1), + ("duckdb://", "duckdb://", 2), + ("duckdb:// --alias one", "one", 2), + ], +) +def test_feedback_when_connecting_to_new_connection( + ip_empty, capsys, connection, identifier, feedback +): + ip_empty.run_cell("%load_ext sql") + ip_empty.run_cell(f"%config SqlMagic.feedback = {feedback}") + ip_empty.run_cell(f"%sql {connection}") + + captured = capsys.readouterr() + assert f"Connecting to '{identifier}'" == captured.out.splitlines()[-1] + + +def test_no_connecting_and_switching_connection_feedback_if_disabled(ip_empty, capsys): + ip_empty.run_cell("%config SqlMagic.feedback = 0") + + ip_empty.run_cell("%sql duckdb://") + ip_empty.run_cell("%sql duckdb:// --alias one") + ip_empty.run_cell("%sql duckdb:// --alias two") + ip_empty.run_cell("%sql duckdb://") + + captured = capsys.readouterr() + assert captured.out == "" + + +@pytest.mark.parametrize( + "alias, expected", + [(None, "postgresql://user:***@somedomain.com/db"), ("alias", "alias")], +) +def test_password_in_feedback_when_connecting_to_new_connection( + mock_postgres, ip_empty, capsys, alias, expected +): + url = "postgresql://user:topsecret@somedomain.com/db" + _ = ConnectionManager.set(url, displaycon=False, alias=alias) + captured = capsys.readouterr() + assert f"Connecting to '{expected}'" in captured.out.strip() + + +@pytest.mark.parametrize( + "alias, expected", + [(None, "postgresql://user:***@somedomain.com/db"), ("alias", "alias")], +) +def test_password_in_feedback_when_connecting_and_switching_connection( + mock_postgres, ip_empty, capsys, alias, expected +): + ip_empty.run_cell("%sql duckdb://") + url = "postgresql://user:topsecret@somedomain.com/db" + _ = ConnectionManager.set(url, displaycon=False, alias=alias) + captured = capsys.readouterr() + assert ( + f"Connecting and switching to connection '{expected}'" + in captured.out.splitlines()[-1] + ) + + +@pytest.fixture +def conn_sqlalchemy_duckdb(): + conn = SQLAlchemyConnection(engine=create_engine("duckdb://")) + yield conn + conn.close() + + +@pytest.fixture +def conn_dbapi_duckdb(): + conn = DBAPIConnection(duckdb.connect()) + yield conn + conn.close() + + +@pytest.fixture +def mock_sqlalchemy_raw_execute(conn_sqlalchemy_duckdb, monkeypatch): + mock = Mock() + monkeypatch.setattr(conn_sqlalchemy_duckdb, "_connection_sqlalchemy", mock) + # mock the dialect to pretend we're using tsql + monkeypatch.setattr(conn_sqlalchemy_duckdb, "_get_sqlglot_dialect", lambda: "tsql") + + yield mock.execute, conn_sqlalchemy_duckdb + + +@pytest.fixture +def mock_dbapi_raw_execute(monkeypatch, conn_dbapi_duckdb): + mock = Mock() + monkeypatch.setattr(conn_dbapi_duckdb, "_connection", mock) + # mock the dialect to pretend we're using tsql + monkeypatch.setattr(conn_dbapi_duckdb, "_get_sqlglot_dialect", lambda: "tsql") + + yield mock.cursor().execute, conn_dbapi_duckdb + + +@pytest.mark.parametrize( + "fixture_name", + [ + "mock_sqlalchemy_raw_execute", + "mock_dbapi_raw_execute", + ], +) +def test_raw_execute_doesnt_transpile_sql_query(fixture_name, request): + mock_execute, conn = request.getfixturevalue(fixture_name) + + # to prevent the "SET python_scan_all_frames=true" call, since we don't want to + # test that here + conn._is_duckdb_native = False + + conn.raw_execute("CREATE TABLE foo (bar INT)") + conn.raw_execute("INSERT INTO foo VALUES (42), (43)") + conn.raw_execute("SELECT * FROM foo LIMIT 1") + + calls = [ + str(call[0][0]) + for call in mock_execute.call_args_list + # if running on sqlalchemy 1.x, the commit call is done via .execute, + # ignore them + if str(call[0][0]) != "commit" + ] + + expected_number_of_calls = 3 + expected_calls = [ + "CREATE TABLE foo (bar INT)", + "INSERT INTO foo VALUES (42), (43)", + "SELECT * FROM foo LIMIT 1", + ] + + assert len(calls) == expected_number_of_calls + assert calls == expected_calls + + +@pytest.fixture +def mock_sqlalchemy_execute(monkeypatch): + conn = SQLAlchemyConnection(engine=create_engine("duckdb://")) + + mock = Mock() + monkeypatch.setattr(conn._connection, "execute", mock) + # mock the dialect to pretend we're using tsql + monkeypatch.setattr(conn, "_get_sqlglot_dialect", lambda: "tsql") + + yield mock, conn + + +@pytest.fixture +def mock_dbapi_execute(monkeypatch): + conn = DBAPIConnection(duckdb.connect()) + + mock = Mock() + monkeypatch.setattr(conn, "_connection", mock) + # mock the dialect to pretend we're using tsql + monkeypatch.setattr(conn, "_get_sqlglot_dialect", lambda: "tsql") + + yield mock.cursor().execute, conn + + +@pytest.mark.parametrize( + "fixture_name", + [ + "mock_sqlalchemy_execute", + "mock_dbapi_execute", + ], + ids=[ + "sqlalchemy", + "dbapi", + ], +) +def test_execute_transpiles_sql_query(fixture_name, request): + mock_execute, conn = request.getfixturevalue(fixture_name) + + # to prevent the "SET python_scan_all_frames=true" call, since we don't want to + # test that here + conn._is_duckdb_native = False + + conn.execute("CREATE TABLE foo (bar INT)") + conn.execute("INSERT INTO foo VALUES (42), (43)") + conn.execute("SELECT * FROM foo LIMIT 1") + + calls = [ + str(call[0][0]) + for call in mock_execute.call_args_list + # if running on sqlalchemy 1.x, the commit call is done via .execute, + # ignore them + if str(call[0][0]) != "commit" + ] + + expected_number_of_calls = 3 + expected_calls = [ + "CREATE TABLE foo (bar INTEGER)", + "INSERT INTO foo VALUES (42), (43)", + # since we're transpiling, we should see TSQL code + "SELECT TOP 1 * FROM foo", + ] + + assert len(calls) == expected_number_of_calls + assert calls == expected_calls + + +@pytest.mark.parametrize( + "fixture_name", + [ + "conn_sqlalchemy_duckdb", + "conn_dbapi_duckdb", + ], +) +@pytest.mark.parametrize("execute_method", ["execute", "raw_execute"]) +def test_error_if_trying_to_execute_multiple_statements( + monkeypatch, execute_method, fixture_name, request +): + conn = request.getfixturevalue(fixture_name) + + with pytest.raises(NotImplementedError) as excinfo: + method = getattr(conn, execute_method) + method( + """ +CREATE TABLE foo (bar INT); +INSERT INTO foo VALUES (42), (43); +SELECT * FROM foo LIMIT 1; +""" + ) + + assert str(excinfo.value) == "Only one statement is supported." + + +@pytest.mark.parametrize( + "fixture_name", + [ + "conn_sqlalchemy_duckdb", + "conn_dbapi_duckdb", + ], +) +@pytest.mark.parametrize( + "query_input,query_output", + [ + ( + """ +SELECT * FROM foo LIMIT 1; +""", + "SELECT TOP 1 * FROM foo", + ), + ( + """ +CREATE TABLE foo (bar INT); +INSERT INTO foo VALUES (42), (43); +SELECT * FROM foo LIMIT 1; +""", + ( + "CREATE TABLE foo (bar INTEGER);\n" + "INSERT INTO foo VALUES (42), (43);\n" + "SELECT TOP 1 * FROM foo" + ), + ), + ], + ids=[ + "one_statement", + "multiple_statements", + ], +) +def test_transpile_query(monkeypatch, fixture_name, request, query_input, query_output): + conn = request.getfixturevalue(fixture_name) + monkeypatch.setattr(conn, "_get_sqlglot_dialect", lambda: "tsql") + + transpiled = conn._transpile_query(query_input) + + assert transpiled == query_output + + +def test_transpile_query_doesnt_transpile_if_it_doesnt_need_to(monkeypatch): + conn = SQLAlchemyConnection(engine=create_engine("duckdb://")) + + query_input = """ + SELECT + percentile_disc([0.25, 0.50, 0.75]) WITHIN GROUP (ORDER BY "column") +AS percentiles + FROM "table" +""" + + transpiled = conn._transpile_query(query_input) + + assert transpiled == query_input + + +def test_result_set_collection_append(): + collection = ResultSetCollection() + collection.append(1) + collection.append(2) + + assert collection._result_sets == [1, 2] + + +def test_result_set_collection_iterate(): + collection = ResultSetCollection() + collection.append(1) + collection.append(2) + + assert list(collection) == [1, 2] + + +def test_result_set_collection_is_last(): + collection = ResultSetCollection() + first, second = object(), object() + collection.append(first) + + assert len(collection) == 1 + assert collection.is_last(first) + + collection.append(second) + + assert len(collection) == 2 + assert not collection.is_last(first) + assert collection.is_last(second) + + collection.append(first) + + assert len(collection) == 2 + assert collection.is_last(first) + assert not collection.is_last(second) + + +def test_execute_rollback_if_pendingrollbackerror_is_raised(monkeypatch): + conn = SQLAlchemyConnection(engine=create_engine("duckdb://")) + + mock_execute = Mock( + side_effect=[ + exc.PendingRollbackError("rollback"), + "RESULTS", + ] + ) + mock_rollback = Mock() + + conn._connection_sqlalchemy.execute = mock_execute + conn._connection_sqlalchemy.rollback = mock_rollback + + with pytest.warns(JupySQLRollbackPerformed) as record: + results = conn.execute("SELECT * FROM table") + + assert results == "RESULTS" + assert len(record) == 1 + assert ( + record[0].message.args[0] + == "Found invalid transaction. JupySQL executed a ROLLBACK operation." + ) + mock_rollback.assert_called_once_with() + + +def test_execute_rollback_if_current_transaction_aborted(monkeypatch): + conn = SQLAlchemyConnection(engine=create_engine("duckdb://")) + + class InFailedSqlTransaction: + def __str__(self) -> str: + return ( + "current transaction is aborted, " + "commands ignored until end of transaction block" + ) + + orig = InFailedSqlTransaction() + sqlalchemy_error = exc.InternalError("internal error", params={}, orig=orig) + + mock_execute = Mock( + side_effect=[ + sqlalchemy_error, + "RESULTS", + ] + ) + mock_rollback = Mock() + + conn._connection_sqlalchemy.execute = mock_execute + conn._connection_sqlalchemy.rollback = mock_rollback + + with pytest.warns(JupySQLRollbackPerformed) as record: + results = conn.execute("SELECT * FROM table") + + assert results == "RESULTS" + assert len(record) == 1 + assert ( + record[0].message.args[0] + == "Current transaction is aborted. JupySQL executed a ROLLBACK operation." + ) + mock_rollback.assert_called_once_with() + + +def test_execute_rollback_if_server_closes_connection(monkeypatch): + conn = SQLAlchemyConnection(engine=create_engine("duckdb://")) + + class OperationalError: + def __str__(self) -> str: + return "server closed the connection unexpectedly" + + orig = OperationalError() + sqlalchemy_error = exc.OperationalError("internal error", params={}, orig=orig) + + mock_execute = Mock( + side_effect=[ + sqlalchemy_error, + "RESULTS", + ] + ) + mock_rollback = Mock() + + conn._connection_sqlalchemy.execute = mock_execute + conn._connection_sqlalchemy.rollback = mock_rollback + + with pytest.warns(JupySQLRollbackPerformed) as record: + results = conn.execute("SELECT * FROM table") + + assert results == "RESULTS" + assert len(record) == 1 + assert ( + record[0].message.args[0] + == "Server closed connection. JupySQL executed a ROLLBACK operation." + ) + mock_rollback.assert_called_once_with() + + +def test_ignore_internalerror_if_it_doesnt_match_the_selected_patterns(monkeypatch): + conn = SQLAlchemyConnection(engine=create_engine("duckdb://")) + + class SomeError: + def __str__(self) -> str: + return "message" + + orig = SomeError() + internal_error = exc.InternalError("internal error", params={}, orig=orig) + + mock_execute = Mock(side_effect=internal_error) + conn._connection_sqlalchemy.execute = mock_execute + + with pytest.raises(exc.InternalError) as excinfo: + conn.execute("SELECT * FROM table") + + assert "(test_connection.SomeError) message" in str(excinfo.value) + assert isinstance(excinfo.value.orig, SomeError) + assert str(excinfo.value.orig) == "message" + + +def test_ignore_operationalerror_if_it_doesnt_match_the_selected_patterns(monkeypatch): + conn = SQLAlchemyConnection(engine=create_engine("duckdb://")) + + class SomeError: + def __str__(self) -> str: + return "message" + + orig = SomeError() + internal_error = exc.OperationalError("internal error", params={}, orig=orig) + + mock_execute = Mock(side_effect=internal_error) + conn._connection_sqlalchemy.execute = mock_execute + + with pytest.raises(exc.OperationalError) as excinfo: + conn.execute("SELECT * FROM table") + + assert "(test_connection.SomeError) message" in str(excinfo.value) + assert isinstance(excinfo.value.orig, SomeError) + assert str(excinfo.value.orig) == "message" + + +@pytest.mark.parametrize( + "uri, expected", + [ + ( + "sqlite:///path/to.db", + "unable to open database file", + ), + ( + "duckdb:///path/to.db", + "Cannot open file", + ), + ], +) +def test_database_in_directory_that_doesnt_exist(tmp_empty, uri, expected): + with pytest.raises(UsageError) as excinfo: + SQLAlchemyConnection(engine=create_engine(uri)) + + assert expected in str(excinfo.value) + + +@pytest.mark.parametrize( + "query, expected_output", + [ + ("SELECT * FROM table", True), + ("SUMMARIZE table", True), + ("FROM table SELECT *", True), + ("UPDATE table SET column=value", False), + ("INSERT INTO table (column) VALUES (value)", False), + ("INSERT INTO table SELECT * FROM table2", False), + ( + "UPDATE table SET column=10 WHERE column IN (SELECT column FROM table2)", + False, + ), + ("WITH x AS (SELECT * FROM table) SELECT * FROM x", True), + ("WITH x AS (SELECT * FROM table) INSERT INTO y SELECT * FROM x", False), + ("", False), + ("DELETE FROM table", False), + ("WITH summarize AS (SELECT * FROM table) SELECT * FROM summarize", True), + ( + """ + WITH summarize AS (SELECT * FROM table) + INSERT INTO y SELECT * FROM summarize + """, + False, + ), + ("UPDATE table SET column='SELECT'", False), + ("CREATE TABLE SELECT (id INT)", False), + ("CREATE TABLE x (SELECT VARCHAR(100))", False), + ('INSTALL "x"', False), + ("SELECT SUM(column) FILTER (WHERE column > 10) FROM table", True), + ("SELECT column FROM (SELECT * FROM table WHERE column = 'SELECT') AS x", True), + # Invalid SQL returns false + ("INSERT INTO table (column) VALUES ('SELECT')", False), + # Comments have no effect + ("-- SELECT * FROM table", False), + ("-- SELECT * FROM table\nSELECT * FROM table", True), + ("-- SELECT * FROM table\nINSERT INTO table SELECT * FROM table2", False), + ("-- FROM table SELECT *", False), + ("-- FROM table SELECT *\n/**/FROM/**/ table SELECT */**/", True), + ("-- FROM table SELECT *\nINSERT INTO table FROM table2 SELECT *", False), + ( + """ + -- INSERT INTO table SELECT * FROM table2 + SELECT /**/ * FROM tbl /**/ + """, + True, + ), + ( + """ + -- INSERT INTO table SELECT * FROM table2 + /**/SUMMARIZE/**/ /**//**/tbl/**/ + """, + True, + ), + ], +) +def test_detect_duckdb_summarize_or_select(query, expected_output): + assert detect_duckdb_summarize_or_select(query) == expected_output diff --git a/src/tests/test_display.py b/src/tests/test_display.py new file mode 100644 index 000000000..2c52caa7f --- /dev/null +++ b/src/tests/test_display.py @@ -0,0 +1,21 @@ +from sql import display +from sql.display import Message, Link, message_html + + +def test_html_escaping(): + message = display.Message("<>") + + assert "<>" in str(message) + assert "<>" in message._repr_html_() + + +def test_message_html_with_list_input(capsys): + message_html(["go to our", Link("home", "https://ploomber.io"), "page"]) + out, _ = capsys.readouterr() + assert "go to our home (https://ploomber.io) page" in out + + +def test_message_with_link_object(): + assert "go to our home (https://ploomber.io) page" == str( + Message(["go to our", Link("home", "https://ploomber.io"), "page"]) + ) diff --git a/src/tests/test_dsn_config.ini b/src/tests/test_dsn_config.ini index 29c17282d..8d02cfadc 100644 --- a/src/tests/test_dsn_config.ini +++ b/src/tests/test_dsn_config.ini @@ -11,4 +11,12 @@ drivername = mysql host = 127.0.0.1 database = dolfin username = thefin -password = fishputsfishonthetable \ No newline at end of file +password = fishputsfishonthetable + +[DB_CONFIG_3] +drivername = sqlite +host = 127.0.0.1 +database = dolfin +username = thefin +password = dafish +query = {'sound': 'squeek', 'color': 'grey'} diff --git a/src/tests/test_extract_tables.py b/src/tests/test_extract_tables.py new file mode 100644 index 000000000..ea3298db7 --- /dev/null +++ b/src/tests/test_extract_tables.py @@ -0,0 +1,85 @@ +import pytest +from sql.util import extract_tables_from_query + + +@pytest.mark.parametrize( + "query, expected", + [ + ( + """ + SELECT t.* + FROM tracks_with_info t + JOIN genres_fav + ON t.GenreId = genres_fav.GenreId + """, + ["tracks_with_info", "genres_fav"], + ), + ( + """ + SELECT city FROM Customers + UNION + SELECT city FROM Suppliers""", + ["Customers", "Suppliers"], + ), + ( + """ + SELECT OrderID, Quantity, +CASE + WHEN Quantity > 30 THEN 'The quantity is greater than 30' + WHEN Quantity = 30 THEN 'The quantity is 30' + ELSE 'The quantity is under 30' +END AS QuantityText +FROM OrderDetails;""", + ["OrderDetails"], + ), + ( + """ +SELECT COUNT(CustomerID), Country +FROM Customers +GROUP BY Country +HAVING COUNT(CustomerID) > 5;""", + ["Customers"], + ), + ( + """ +SELECT LEFT(sub.date, 2) AS cleaned_month, + sub.day_of_week, + AVG(sub.incidents) AS average_incidents + FROM ( + SELECT day_of_week, + date, + COUNT(incidnt_num) AS incidents + FROM tutorial.sf_crime_incidents_2014_01 + GROUP BY 1,2 + ) sub + GROUP BY 1,2 + ORDER BY 1,2""", + ["sf_crime_incidents_2014_01"], + ), + ( + """ + SELECT incidents.*, + sub.incidents AS incidents_that_day + FROM tutorial.sf_crime_incidents_2014_01 incidents + JOIN ( SELECT date, + COUNT(incidnt_num) AS incidents + FROM tutorial.sf_crime_incidents_2014_01 + GROUP BY 1 + ) sub + ON incidents.date = sub.date + ORDER BY sub.incidents DESC, time + """, + ["sf_crime_incidents_2014_01", "sf_crime_incidents_2014_01"], + ), + ], + ids=["join", "union", "case", "groupby", "subquery", "subquery_join"], +) +def test_extract(query, expected): + tables = extract_tables_from_query(query) + assert expected == tables + + +def test_invalid_query(): + query = "SELECT city frm Customers" + tables = extract_tables_from_query(query) + assert [] == tables diff --git a/src/tests/test_ggplot.py b/src/tests/test_ggplot.py new file mode 100644 index 000000000..fcb970b20 --- /dev/null +++ b/src/tests/test_ggplot.py @@ -0,0 +1,688 @@ +from sql.ggplot import ggplot, aes, geom_boxplot, geom_histogram, facet_wrap +from matplotlib.testing.decorators import image_comparison, _cleanup_cm +import pytest +from pathlib import Path +from urllib.request import urlretrieve +from IPython.core.error import UsageError + + +@pytest.fixture +def short_trips_data(ip, yellow_trip_data): + ip.run_cell( + """ + %sql duckdb:// + """ + ) + + ip.run_cell( + f""" + %%sql --save short_trips --no-execute + select * from "{yellow_trip_data}" + WHERE trip_distance < 6.3 + """ + ).result + + +@pytest.fixture +def yellow_trip_data(ip, tmpdir): + ip.run_cell( + """ + %sql duckdb:// + """ + ) + + file_path_str = str(tmpdir.join("yellow_tripdata_2021-01.parquet")) + + if not Path(file_path_str).is_file(): + urlretrieve( + "https://d37ci6vzurychx.cloudfront.net/trip-data/" + "yellow_tripdata_2021-01.parquet", + file_path_str, + ) + + yield file_path_str + + +@pytest.fixture +def diamonds_data(ip, tmpdir): + ip.run_cell( + """ + %sql duckdb:// + """ + ) + + file_path_str = str(tmpdir.join("diamonds.csv")) + + if not Path(file_path_str).is_file(): + urlretrieve( + "https://raw.githubusercontent.com/tidyverse/ggplot2/main/data-raw/diamonds.csv", # noqa breaks the check-for-broken-links + file_path_str, + ) + + yield file_path_str + + +@pytest.fixture +def penguins_data(ip, tmpdir): + file_path_str = str(tmpdir.join("penguins.csv")) + + ip.run_cell( + """ + %sql duckdb:// + """ + ) + + if not Path(file_path_str).is_file(): + urlretrieve( + "https://raw.githubusercontent.com/mwaskom/seaborn-data/master/penguins.csv", # noqa breaks the check-for-broken-links + file_path_str, + ) + + yield file_path_str + + +@pytest.fixture +def nulls_data(ip, tmpdir): + if not Path("data_nulls.csv").is_file(): + Path("data_nulls.csv").write_text( + ( + "name,age,model\n" + "Dan,33,BMW\nBob,19,BMW\nSheri,15,Audi\nVin,33,\nMick,93,Audi\n" + "Jay,33,BMW\nSky,33,\nKay,48,BMW\nJan,86,Audi\n\nMike,,Audi" + ) + ) + ip.run_cell("%sql duckdb://") + + +@pytest.fixture +def penguins_no_nulls(ip, penguins_data): + ip.run_cell( + """ + %sql duckdb:// + """ + ) + + ip.run_cell( + f""" +%%sql --save no_nulls --no-execute +SELECT * +FROM "{penguins_data}" +WHERE body_mass_g IS NOT NULL and +sex IS NOT NULL + """ + ).result + + +@_cleanup_cm() +@image_comparison(baseline_images=["boxplot"], extensions=["png"], remove_text=True) +def test_ggplot_geom_boxplot(yellow_trip_data): + (ggplot(yellow_trip_data, aes(x="trip_distance")) + geom_boxplot()) + + +@_cleanup_cm() +@image_comparison( + baseline_images=["histogram_default"], extensions=["png"], remove_text=True +) +def test_ggplot_geom_histogram(yellow_trip_data): + ( + ggplot(yellow_trip_data, aes(x="trip_distance", color="white")) + + geom_histogram(bins=10) + ) + + +@_cleanup_cm() +@image_comparison( + baseline_images=["histogram_with_default"], extensions=["png"], remove_text=True +) +def test_ggplot_geom_histogram_with(short_trips_data): + ( + ggplot(table="short_trips", with_="short_trips", mapping=aes(x="trip_distance")) + + geom_histogram(bins=10) + ) + + +@_cleanup_cm() +@image_comparison( + baseline_images=["histogram_custom_color"], extensions=["png"], remove_text=True +) +def test_ggplot_geom_histogram_edge_color(short_trips_data): + ( + ggplot( + table="short_trips", + with_="short_trips", + mapping=aes(x="trip_distance", color="white"), + ) + + geom_histogram(bins=10) + ) + + +@_cleanup_cm() +@image_comparison( + baseline_images=["histogram_custom_fill"], extensions=["png"], remove_text=True +) +def test_ggplot_geom_histogram_fill(short_trips_data): + ( + ggplot( + table="short_trips", + with_="short_trips", + mapping=aes(x="trip_distance", fill="red"), + ) + + geom_histogram(bins=10) + ) + + +@_cleanup_cm() +@image_comparison( + baseline_images=["histogram_custom_fill_and_color"], + extensions=["png"], + remove_text=True, +) +def test_ggplot_geom_histogram_fill_and_color(short_trips_data): + ( + ggplot( + table="short_trips", + with_="short_trips", + mapping=aes(x="trip_distance", fill="red", color="#fff"), + ) + + geom_histogram(bins=10) + ) + + +@pytest.mark.parametrize( + "x", + [ + "price", + ["price"], + ], +) +@_cleanup_cm() +@image_comparison( + baseline_images=["histogram_stacked_default"], + extensions=["png"], + remove_text=True, +) +def test_example_histogram_stacked_default(diamonds_data, x): + (ggplot(diamonds_data, aes(x=x)) + geom_histogram(bins=10, fill="cut")) + + +@_cleanup_cm() +@image_comparison( + baseline_images=["histogram_stacked_custom_cmap"], + extensions=["png"], + remove_text=True, +) +def test_example_histogram_stacked_custom_cmap(diamonds_data): + ( + ggplot(diamonds_data, aes(x="price")) + + geom_histogram(bins=10, fill="cut", cmap="plasma") + ) + + +@_cleanup_cm() +@image_comparison( + baseline_images=["histogram_stacked_custom_color"], + extensions=["png"], + remove_text=True, +) +def test_example_histogram_stacked_custom_color(diamonds_data): + ( + ggplot(diamonds_data, aes(x="price", color="k")) + + geom_histogram(bins=10, cmap="plasma", fill="cut") + ) + + +@_cleanup_cm() +@image_comparison( + baseline_images=["histogram_stacked_custom_color_and_fill"], + extensions=["png"], + remove_text=True, +) +def test_example_histogram_stacked_custom_color_and_fill(diamonds_data): + ( + ggplot(diamonds_data, aes(x="price", color="white", fill="red")) + + geom_histogram(bins=10, cmap="plasma", fill="cut") + ) + + +@_cleanup_cm() +@image_comparison( + baseline_images=["histogram_stacked_custom_color_and_fill"], + extensions=["png"], + remove_text=True, +) +def test_ggplot_geom_histogram_fill_with_multi_color_warning(diamonds_data): + with pytest.warns(UserWarning): + ( + ggplot(diamonds_data, aes(x="price", color="white", fill=["red", "blue"])) + + geom_histogram(bins=10, cmap="plasma", fill="cut") + ) + + +@_cleanup_cm() +@image_comparison( + baseline_images=["histogram_stacked_large_bins"], + extensions=["png"], + remove_text=True, +) +def test_example_histogram_stacked_with_large_bins(diamonds_data): + (ggplot(diamonds_data, aes(x="price")) + geom_histogram(bins=400, fill="cut")) + + +@_cleanup_cm() +@image_comparison( + baseline_images=["histogram_categorical"], + extensions=["png"], + remove_text=True, +) +def test_categorical_histogram(diamonds_data): + (ggplot(diamonds_data, aes(x=["cut"])) + geom_histogram()) + + +@_cleanup_cm() +@image_comparison( + baseline_images=["histogram_categorical_combined"], + extensions=["png"], + remove_text=True, +) +def test_categorical_histogram_combined(diamonds_data): + (ggplot(diamonds_data, aes(x=["color", "carat"])) + geom_histogram(bins=10)) + + +@_cleanup_cm() +@image_comparison( + baseline_images=["histogram_numeric_categorical_combined"], + extensions=["png"], + remove_text=True, +) +def test_categorical_and_numeric_histogram_combined(diamonds_data): + (ggplot(diamonds_data, aes(x=["color", "carat"])) + geom_histogram(bins=20)) + + +@_cleanup_cm() +@image_comparison( + baseline_images=["histogram_numeric_categorical_combined_custom_fill"], + extensions=["png"], + remove_text=True, +) +def test_categorical_and_numeric_histogram_combined_custom_fill(diamonds_data): + ( + ggplot(diamonds_data, aes(x=["color", "carat"], fill="red")) + + geom_histogram(bins=20) + ) + + +@_cleanup_cm() +@image_comparison( + baseline_images=["histogram_numeric_categorical_combined_custom_multi_fill"], + extensions=["png"], + remove_text=True, +) +def test_categorical_and_numeric_histogram_combined_custom_multi_fill(diamonds_data): + ( + ggplot(diamonds_data, aes(x=["color", "carat"], fill=["red", "blue"])) + + geom_histogram(bins=20) + ) + + +@_cleanup_cm() +@image_comparison( + baseline_images=["histogram_numeric_categorical_combined_custom_multi_color"], + extensions=["png"], + remove_text=True, +) +def test_categorical_and_numeric_histogram_combined_custom_multi_color(diamonds_data): + ( + ggplot(diamonds_data, aes(x=["color", "carat"], color=["green", "magenta"])) + + geom_histogram(bins=20) + ) + + +@_cleanup_cm() +@image_comparison( + baseline_images=["facet_wrap_default"], + extensions=["png"], + remove_text=False, +) +def test_facet_wrap_default(penguins_no_nulls): + ( + ggplot(table="no_nulls", with_="no_nulls", mapping=aes(x=["bill_depth_mm"])) + + geom_histogram(bins=10) + + facet_wrap("sex") + ) + + +@_cleanup_cm() +@image_comparison( + baseline_images=["facet_wrap_default_no_legend"], + extensions=["png"], + remove_text=False, +) +def test_facet_wrap_default_no_legend(penguins_no_nulls): + ( + ggplot(table="no_nulls", with_="no_nulls", mapping=aes(x=["bill_depth_mm"])) + + geom_histogram(bins=10) + + facet_wrap("sex", legend=False) + ) + + +@_cleanup_cm() +@image_comparison( + baseline_images=["facet_wrap_custom_fill"], + extensions=["png"], + remove_text=False, +) +def test_facet_wrap_custom_fill(penguins_no_nulls): + ( + ggplot( + table="no_nulls", + with_="no_nulls", + mapping=aes(x=["bill_depth_mm"], fill=["red"]), + ) + + geom_histogram(bins=10) + + facet_wrap("sex") + ) + + +@_cleanup_cm() +@image_comparison( + baseline_images=["facet_wrap_custom_fill_and_color"], + extensions=["png"], + remove_text=False, +) +def test_facet_wrap_custom_fill_and_color(penguins_no_nulls): + ( + ggplot( + table="no_nulls", + with_="no_nulls", + mapping=aes(x=["bill_depth_mm"], color="#fff", fill=["red"]), + ) + + geom_histogram(bins=10) + + facet_wrap("sex") + ) + + +@_cleanup_cm() +@image_comparison( + baseline_images=["facet_wrap_custom_stacked_histogram"], + extensions=["png"], + remove_text=False, +) +def test_facet_wrap_stacked_histogram(diamonds_data): + ( + ggplot(diamonds_data, aes(x=["price"])) + + geom_histogram(bins=10, fill="color") + + facet_wrap("cut") + ) + + +@_cleanup_cm() +@image_comparison( + baseline_images=["facet_wrap_custom_stacked_histogram_cmap"], + extensions=["png"], + remove_text=False, +) +def test_facet_wrap_stacked_histogram_cmap(diamonds_data): + ( + ggplot(diamonds_data, aes(x=["price"])) + + geom_histogram(bins=10, fill="color", cmap="plasma") + + facet_wrap("cut") + ) + + +@_cleanup_cm() +@image_comparison( + baseline_images=["facet_wrap_default_with_nulls"], + extensions=["png"], + remove_text=False, +) +def test_facet_wrap_default_with_nulls(penguins_data): + ( + ggplot(table=penguins_data, mapping=aes(x=["bill_depth_mm"])) + + geom_histogram(bins=10) + + facet_wrap("sex") + ) + + +@_cleanup_cm() +@image_comparison( + baseline_images=["facet_wrap_nulls_data"], + extensions=["png"], + remove_text=False, +) +def test_facet_wrap_default_with_dummy(nulls_data): + ( + ggplot(table="data_nulls.csv", mapping=aes(x=["age"])) + + geom_histogram(bins=10) + + facet_wrap("model") + ) + + +@_cleanup_cm() +@image_comparison( + baseline_images=["histogram_with_breaks"], + extensions=["png"], + remove_text=True, +) +def test_histogram_with_breaks(penguins_data): + ( + ggplot(table=penguins_data, mapping=aes(x="body_mass_g")) + + geom_histogram(breaks=[3000, 3100, 3300, 3700, 4000, 4600]) + ) + + +@_cleanup_cm() +@image_comparison( + baseline_images=["histogram_stacked_with_breaks"], + extensions=["png"], + remove_text=True, +) +def test_histogram_stacked_with_breaks(penguins_data): + ( + ggplot(table=penguins_data, mapping=aes(x="body_mass_g")) + + geom_histogram(breaks=[3000, 3100, 3300, 3700, 4000, 4600], fill="species") + ) + + +@_cleanup_cm() +@image_comparison( + baseline_images=["histogram_stacked_with_extreme_breaks"], + extensions=["png"], + remove_text=True, +) +def test_histogram_with_extreme_breaks(penguins_data): + ( + ggplot(table=penguins_data, mapping=aes(x="body_mass_g")) + + geom_histogram(breaks=[1000, 2000, 2500, 2700, 3000], fill="species") + ) + + +@_cleanup_cm() +@image_comparison( + baseline_images=["histogram_with_binwidth"], + extensions=["png"], + remove_text=True, +) +def test_histogram_with_binwidth(penguins_data): + ( + ggplot(table=penguins_data, mapping=aes(x="body_mass_g")) + + geom_histogram(binwidth=150) + ) + + +@_cleanup_cm() +@image_comparison( + baseline_images=["histogram_stacked_with_binwidth"], + extensions=["png"], + remove_text=True, +) +def test_histogram_stacked_with_binwidth(penguins_data): + ( + ggplot(table=penguins_data, mapping=aes(x="body_mass_g")) + + geom_histogram(binwidth=150, fill="species") + ) + + +@_cleanup_cm() +@image_comparison( + baseline_images=["histogram_binwidth_with_multiple_cols"], + extensions=["png"], + remove_text=True, +) +def test_histogram_binwidth_with_multiple_cols(penguins_data): + ( + ggplot(table=penguins_data, mapping=aes(x=["bill_length_mm", "bill_depth_mm"])) + + geom_histogram(binwidth=1.5) + ) + + +@_cleanup_cm() +@image_comparison( + baseline_images=["histogram_binwidth_facet_wrap"], + extensions=["png"], + remove_text=True, +) +def test_histogram_binwidth_facet_wrap(penguins_data): + ( + ggplot(table=penguins_data, mapping=aes(x=["body_mass_g"])) + + geom_histogram(binwidth=150) + + facet_wrap("species") + ) + + +@_cleanup_cm() +@image_comparison( + baseline_images=["histogram_with_narrow_binwidth"], + extensions=["png"], + remove_text=True, +) +def test_histogram_with_narrow_binwidth(penguins_data): + ( + ggplot(table=penguins_data, mapping=aes(x="body_mass_g")) + + geom_histogram(binwidth=10) + ) + + +@pytest.mark.parametrize( + "x, expected_error, expected_error_message", + [ + ([], ValueError, "Column name has not been specified"), + ([""], ValueError, "Column name has not been specified"), + (None, ValueError, "Column name has not been specified"), + ("", ValueError, "Column name has not been specified"), + ([None, None], ValueError, "please ensure that you specify only one column"), + ( + ["price", "table"], + ValueError, + "please ensure that you specify only one column", + ), + ( + ["price", "table", "color"], + ValueError, + "please ensure that you specify only one column", + ), + ([None], TypeError, "expected str instance, NoneType found"), + ], +) +def test_example_histogram_stacked_input_error( + diamonds_data, x, expected_error, expected_error_message +): + with pytest.raises(expected_error) as error: + (ggplot(diamonds_data, aes(x=x)) + geom_histogram(bins=500, fill="cut")) + + assert expected_error_message in str(error.value) + + +def test_histogram_no_bins_error(diamonds_data): + with pytest.raises(ValueError) as error: + (ggplot(diamonds_data, aes(x=["price"])) + geom_histogram()) + + assert "Please specify a valid number of bins." in str(error.value) + + +@pytest.mark.parametrize( + "bins, breaks, error_message", + [ + ( + None, + [3000.0], + ( + "Breaks given : [3000.0]. When using breaks, " + "please ensure to specify at least two points." + ), + ), + ( + None, + [3000.0, 4000.0, 3999.0], + ( + "Breaks given : [3000.0, 4000.0, 3999.0]. When using breaks, " + "please ensure that breaks are strictly increasing." + ), + ), + ( + 40, + [3000.0, 4000.0, 5000.0], + "'bins', and 'breaks' are specified. You can only specify one of them.", + ), + ], +) +def test_hist_breaks_error(penguins_data, bins, breaks, error_message): + with pytest.raises(UsageError) as error: + ( + ggplot(penguins_data, aes(x="body_mass_g")) + + geom_histogram(bins=bins, breaks=breaks) + ) + + assert error.value.error_type == "ValueError" + assert error_message in str(error.value) + + +@pytest.mark.parametrize( + "bins, breaks, binwidth, error_message", + [ + ( + None, + [1000, 2000, 3000], + 150, + ( + "'binwidth', and 'breaks' are specified. " + "You can only specify one of them." + ), + ), + ( + 50, + [1000, 2000, 3000], + 150, + ( + "'bins', 'binwidth', and 'breaks' are specified. " + "You can only specify one of them." + ), + ), + ( + None, + None, + "invalid", + ( + "Binwidth given : invalid. When using binwidth, " + "please ensure to pass a numeric value." + ), + ), + ( + None, + None, + 0, + ( + "Binwidth given : 0. When using binwidth, " + "please ensure to pass a positive value." + ), + ), + ], +) +def test_hist_binwidth_error(penguins_data, bins, breaks, binwidth, error_message): + with pytest.raises(UsageError) as error: + ( + ggplot(penguins_data, aes(x="body_mass_g")) + + geom_histogram(bins=bins, breaks=breaks, binwidth=binwidth) + ) + + assert error.value.error_type == "ValueError" + assert error_message in str(error.value) diff --git a/src/tests/test_inspect.py b/src/tests/test_inspect.py new file mode 100644 index 000000000..0d57fc349 --- /dev/null +++ b/src/tests/test_inspect.py @@ -0,0 +1,550 @@ +from pathlib import Path +from unittest.mock import Mock + + +import pytest +from functools import partial + +from IPython.core.error import UsageError +from prettytable import PrettyTable + +from sql import inspect, connection + + +EXPECTED_SUGGESTIONS_MESSAGE = "Did you mean:" +EXPECTED_NO_TABLE_IN_SCHEMA = "There is no table with name {0!r} in schema {1!r}" +EXPECTED_NO_TABLE_IN_DEFAULT_SCHEMA = ( + "There is no table with name {0!r} in the default schema" +) + + +@pytest.fixture +def sample_db(ip_empty, tmp_empty): + ip_empty.run_cell("%sql sqlite:///first.db --alias first") + ip_empty.run_cell("%sql CREATE TABLE one (x INT, y TEXT)") + ip_empty.run_cell("%sql CREATE TABLE another (i INT, j TEXT)") + ip_empty.run_cell("%sql sqlite:///second.db --alias second") + ip_empty.run_cell("%sql CREATE TABLE uno (x INT, y TEXT)") + ip_empty.run_cell("%sql CREATE TABLE dos (i INT, j TEXT)") + ip_empty.run_cell("%sql --close second") + ip_empty.run_cell("%sql first") + ip_empty.run_cell("%sql ATTACH DATABASE 'second.db' AS schema") + + yield + + ip_empty.run_cell("%sql --close first") + Path("first.db").unlink() + Path("second.db").unlink() + + +@pytest.mark.parametrize( + "function", + [ + inspect.get_table_names, + partial(inspect.get_columns, name="some_name"), + inspect.get_schema_names, + ], +) +def test_no_active_session(function, monkeypatch): + monkeypatch.setattr(connection.ConnectionManager, "current", None) + + with pytest.raises(UsageError, match="No active connection") as excinfo: + function() + + assert excinfo.value.error_type == "RuntimeError" + + +@pytest.mark.parametrize( + "first, second, schema", + [ + ["one", "another", None], + ["uno", "dos", "schema"], + ], +) +def test_tables(sample_db, first, second, schema): + tables = inspect.get_table_names(schema=schema) + + assert "Name" in repr(tables) + assert first in repr(tables) + assert second in repr(tables) + + assert "" in tables._repr_html_() + assert "Name" in tables._repr_html_() + assert first in tables._repr_html_() + assert second in tables._repr_html_() + + +@pytest.mark.parametrize( + "name, first, second, schema", + [ + ["one", "x", "y", None], + ["another", "i", "j", None], + ["uno", "x", "y", "schema"], + ["dos", "i", "j", "schema"], + ], +) +def test_get_column(sample_db, name, first, second, schema): + columns = inspect.get_columns(name, schema=schema) + + assert "name" in repr(columns) + assert first in repr(columns) + assert second in repr(columns) + + assert "
" in columns._repr_html_() + assert "name" in columns._repr_html_() + assert first in columns._repr_html_() + assert second in columns._repr_html_() + + +@pytest.mark.parametrize( + "table, offset, n_rows, expected_rows, expected_columns", + [ + ("number_table", 0, 0, [], ["x", "y"]), + ("number_table", 5, 0, [], ["x", "y"]), + ("number_table", 50, 0, [], ["x", "y"]), + ("number_table", 50, 10, [], ["x", "y"]), + ( + "number_table", + 2, + 10, + [(2, 4), (0, 2), (-5, -1), (-2, -3), (-2, -3), (-4, 2), (2, -5), (4, 3)], + ["x", "y"], + ), + ( + "number_table", + 2, + 100, + [(2, 4), (0, 2), (-5, -1), (-2, -3), (-2, -3), (-4, 2), (2, -5), (4, 3)], + ["x", "y"], + ), + ("number_table", 0, 2, [(4, -2), (-5, 0)], ["x", "y"]), + ("number_table", 2, 2, [(2, 4), (0, 2)], ["x", "y"]), + ( + "number_table", + 2, + 5, + [(2, 4), (0, 2), (-5, -1), (-2, -3), (-2, -3)], + ["x", "y"], + ), + ("empty_table", 2, 5, [], ["column", "another"]), + ], +) +def test_fetch_sql_with_pagination_no_sort( + ip, table, offset, n_rows, expected_rows, expected_columns +): + rows, columns = inspect.fetch_sql_with_pagination(table, offset, n_rows) + + assert rows == expected_rows + assert columns == expected_columns + + +@pytest.mark.parametrize( + "name, schema, error", + [ + [ + "some_table", + "schema", + "There is no table with name 'some_table' in schema 'schema'", + ], + [ + "name", + None, + "There is no table with name 'name' in the default schema", + ], + ], +) +def test_nonexistent_table(sample_db, name, schema, error): + with pytest.raises(UsageError) as excinfo: + inspect.get_columns(name, schema) + + assert excinfo.value.error_type == "TableNotFoundError" + assert error.lower() in str(excinfo.value).lower() + + +def test_get_schema_names(ip): + ip.run_cell( + """%%sql sqlite:///my.db +CREATE TABLE IF NOT EXISTS test_table (id INT) +""" + ) + + ip.run_cell( + """%%sql +ATTACH DATABASE 'my.db' AS test_schema +""" + ) + + expected_schema_names = ["main", "test_schema"] + schema_names = inspect.get_schema_names() + for schema in schema_names: + assert schema in expected_schema_names + + +@pytest.mark.parametrize( + "get_columns, rows, field_names, name, schema", + [ + [ + [ + {"column_a": "a", "column_b": "b"}, + # the second row does not have column_b + { + "column_a": "a2", + }, + ], + [["a", "b"], ["a2", ""]], + ["column_a", "column_b"], + "test_table", + None, + ], + [ + [ + {"column_a": "a", "column_b": "b"}, + # the second row does not have column_b + { + "column_a": "a2", + }, + ], + [["a", "b"], ["a2", ""]], + ["column_a", "column_b"], + "another_table", + "another_schema", + ], + [ + [ + { + "column_a": "a2", + }, + # contains an extra column + {"column_a": "a", "column_b": "b"}, + ], + [["a2", ""], ["a", "b"]], + ["column_a", "column_b"], + "test_table", + None, + ], + [ + [ + {"column_a": "a", "column_b": "b"}, + {"column_b": "b2", "column_a": "a2"}, + ], + [["a", "b"], ["a2", "b2"]], + ["column_a", "column_b"], + "test_table", + None, + ], + [ + [ + dict(), + dict(), + ], + [[], []], + [], + "test_table", + None, + ], + [ + None, + [], + [], + "test_table", + None, + ], + ], + ids=[ + "missing-val-second-row", + "missing-val-second-row-another-schema", + "extra-val-second-row", + "keeps-order", + "empty-dictionaries", + "none-return-value", + ], +) +def test_columns_with_missing_values( + tmp_empty, ip, monkeypatch, get_columns, rows, field_names, name, schema +): + mock = Mock() + mock.get_columns.return_value = get_columns + + monkeypatch.setattr(inspect, "_get_inspector", lambda _: mock) + + ip.run_cell( + """%%sql sqlite:///another.db +CREATE TABLE IF NOT EXISTS another_table (id INT) +""" + ) + + ip.run_cell( + """%%sql sqlite:///my.db +CREATE TABLE IF NOT EXISTS test_table (id INT) +""" + ) + + ip.run_cell( + """%%sql +ATTACH DATABASE 'another.db' as 'another_schema'; +""" + ) + + pt = PrettyTable(field_names=field_names) + pt.add_rows(rows) + + assert str(inspect.get_columns(name=name, schema=schema)) == str(pt) + + +@pytest.mark.parametrize( + "table", + ["no_such_table", ""], +) +def test_fetch_sql_with_pagination_no_table_error(ip, table): + with pytest.raises(UsageError) as excinfo: + inspect.fetch_sql_with_pagination(table, 0, 2) + + assert excinfo.value.error_type == "TableNotFoundError" + + +def test_fetch_sql_with_pagination_none_table(ip): + with pytest.raises(UsageError) as excinfo: + inspect.fetch_sql_with_pagination(None, 0, 2) + + assert excinfo.value.error_type == "UsageError" + + +@pytest.mark.parametrize( + "table, offset, n_rows, sort_by, order_by, expected_rows, expected_columns", + [ + ("number_table", 0, 0, "x", "DESC", [], ["x", "y"]), + ("number_table", 5, 0, "x", "DESC", [], ["x", "y"]), + ("number_table", 50, 0, "y", "ASC", [], ["x", "y"]), + ("number_table", 50, 10, "y", "ASC", [], ["x", "y"]), + ("number_table", 0, 2, "x", "DESC", [(4, -2), (4, 3)], ["x", "y"]), + ("number_table", 0, 2, "x", "ASC", [(-5, 0), (-5, -1)], ["x", "y"]), + ("empty_table", 2, 5, "column", "ASC", [], ["column", "another"]), + ("number_table", 2, 2, "x", "ASC", [(-4, 2), (-2, -3)], ["x", "y"]), + ("number_table", 2, 2, "x", "DESC", [(2, 4), (2, -5)], ["x", "y"]), + ( + "number_table", + 2, + 10, + "x", + "DESC", + [(2, 4), (2, -5), (0, 2), (-2, -3), (-2, -3), (-4, 2), (-5, 0), (-5, -1)], + ["x", "y"], + ), + ( + "number_table", + 2, + 100, + "x", + "DESC", + [(2, 4), (2, -5), (0, 2), (-2, -3), (-2, -3), (-4, 2), (-5, 0), (-5, -1)], + ["x", "y"], + ), + ( + "number_table", + 2, + 5, + "y", + "ASC", + [(-2, -3), (4, -2), (-5, -1), (-5, 0), (0, 2)], + ["x", "y"], + ), + ], +) +def test_fetch_sql_with_pagination_with_sort( + ip, table, offset, n_rows, sort_by, order_by, expected_rows, expected_columns +): + rows, columns = inspect.fetch_sql_with_pagination( + table, offset, n_rows, sort_by, order_by + ) + + assert rows == expected_rows + assert columns == expected_columns + + +@pytest.mark.parametrize( + "table, expected_result", + [ + ("number_table", True), + ("test", True), + ("author", True), + ("empty_table", True), + ("numbers1", False), + ("test1", False), + ("author1", False), + ("empty_table1", False), + (None, False), + ], +) +def test_is_table_exists_ignore_error(ip, table, expected_result): + assert expected_result is inspect.is_table_exists(table, ignore_error=True) + + +@pytest.mark.parametrize( + "table, expected_error, error_type", + [ + ("number_table", False, "TableNotFoundError"), + ("test", False, "TableNotFoundError"), + ("author", False, "TableNotFoundError"), + ("empty_table", False, "TableNotFoundError"), + ("numbers1", True, "TableNotFoundError"), + ("test1", True, "TableNotFoundError"), + ("author1", True, "TableNotFoundError"), + ("empty_table1", True, "TableNotFoundError"), + (None, True, "UsageError"), + ], +) +def test_is_table_exists(ip, table, expected_error, error_type): + if expected_error: + with pytest.raises(UsageError) as excinfo: + inspect.is_table_exists(table) + + assert excinfo.value.error_type == error_type + else: + inspect.is_table_exists(table) + + +@pytest.mark.parametrize( + "table, expected_error, expected_suggestions", + [ + ("number_table", None, []), + ("number_tale", UsageError, ["number_table"]), + ("_table", UsageError, ["number_table", "empty_table"]), + (None, UsageError, []), + ], +) +def test_is_table_exists_with(ip, table, expected_error, expected_suggestions): + with_ = ["temp"] + + ip.run_cell( + f""" + %%sql --save {with_[0]} --no-execute + SELECT * + FROM {table} + WHERE x > 2 + """ + ) + if expected_error: + with pytest.raises(expected_error) as error: + inspect.is_table_exists(table) + + error_suggestions_arr = str(error.value).split(EXPECTED_SUGGESTIONS_MESSAGE) + + if len(expected_suggestions) > 0: + assert len(error_suggestions_arr) > 1 + for suggestion in expected_suggestions: + assert suggestion in error_suggestions_arr[1] + else: + assert len(error_suggestions_arr) == 1 + else: + inspect.is_table_exists(table) + + +def test_get_list_of_existing_tables(ip): + expected = ["author", "empty_table", "number_table", "test", "website"] + list_of_tables = inspect._get_list_of_existing_tables() + for table in expected: + assert table in list_of_tables + + +@pytest.mark.parametrize( + "table, query, suggestions", + [ + ("tes", "%sqlcmd columns --table {}", ["test"]), + ("_table", "%sqlcmd columns --table {}", ["empty_table", "number_table"]), + ("no_similar_tables", "%sqlcmd columns --table {}", []), + ("tes", "%sqlcmd profile --table {}", ["test"]), + ("_table", "%sqlcmd profile --table {}", ["empty_table", "number_table"]), + ("no_similar_tables", "%sqlcmd profile --table {}", []), + ("tes", "%sqlplot histogram --table {} --column x", ["test"]), + ("tes", "%sqlplot boxplot --table {} --column x", ["test"]), + ], +) +def test_bad_table_error_message(ip, table, query, suggestions): + query = query.format(table) + + with pytest.raises(UsageError) as excinfo: + ip.run_cell(query) + + expected_error_message = EXPECTED_NO_TABLE_IN_DEFAULT_SCHEMA.format(table) + + error_message = str(excinfo.value) + assert str(expected_error_message).lower() in error_message.lower() + + error_suggestions_arr = error_message.split(EXPECTED_SUGGESTIONS_MESSAGE) + + if len(suggestions) > 0: + assert len(error_suggestions_arr) > 1 + for suggestion in suggestions: + assert suggestion in error_suggestions_arr[1] + + +@pytest.mark.parametrize( + "table, schema, query, suggestions", + [ + ( + "test_table", + "invalid_name_no_match", + "%sqlcmd columns --table {} --schema {}", + [], + ), + ( + "test_table", + "te_schema", + "%sqlcmd columns --table {} --schema {}", + ["test_schema"], + ), + ( + "invalid_name_no_match", + "test_schema", + "%sqlcmd columns --table {} --schema {}", + [], + ), + ( + "test_tabl", + "test_schema", + "%sqlcmd columns --table {} --schema {}", + ["test_table", "test"], + ), + ( + "invalid_name_no_match", + "invalid_name_no_match", + "%sqlcmd columns --table {} --schema {}", + [], + ), + ( + "_table", + "_schema", + "%sqlcmd columns --table {} --schema {}", + ["test_schema"], + ), + ], +) +def test_bad_table_error_message_with_schema(ip, query, suggestions, table, schema): + query = query.format(table, schema) + + expected_error_message = EXPECTED_NO_TABLE_IN_SCHEMA.format(table, schema) + + ip.run_cell( + """%%sql sqlite:///my.db +CREATE TABLE IF NOT EXISTS test_table (id INT) +""" + ) + + ip.run_cell( + """%%sql +ATTACH DATABASE 'my.db' AS test_schema +""" + ) + + with pytest.raises(UsageError) as excinfo: + ip.run_cell(query) + + error_message = str(excinfo.value) + assert str(expected_error_message).lower() in error_message.lower() + + error_suggestions_arr = error_message.split(EXPECTED_SUGGESTIONS_MESSAGE) + + if len(suggestions) > 0: + assert len(error_suggestions_arr) > 1 + for suggestion in suggestions: + assert suggestion in error_suggestions_arr[1] diff --git a/src/tests/test_magic.py b/src/tests/test_magic.py index 3b8615284..0b0c0af93 100644 --- a/src/tests/test_magic.py +++ b/src/tests/test_magic.py @@ -1,45 +1,48 @@ +from unittest.mock import ANY +import uuid +import logging +import platform +import sqlite3 +from decimal import Decimal +from pathlib import Path import os.path import re +import sys import tempfile +import sqlalchemy from textwrap import dedent +from unittest.mock import patch, Mock +import polars as pl +import pandas as pd import pytest +from sqlalchemy import create_engine +from IPython.core.error import UsageError +from sql.connection import ConnectionManager +from sql.magic import SqlMagic, get_query_type +from sql.run.resultset import ResultSet +from sql import magic +from sql.warnings import JupySQLQuotedNamedParametersWarning -from sql.magic import SqlMagic +from conftest import runsql +from sql.connection import PLOOMBER_DOCS_LINK_STR +from ploomber_core.exceptions import COMMUNITY +import psutil -def runsql(ip_session, statements): - if isinstance(statements, str): - statements = [statements] - for statement in statements: - result = ip_session.run_line_magic("sql", "sqlite:// %s" % statement) - return result # returns only last result +COMMUNITY = COMMUNITY.strip() +DISPLAYLIMIT_LINK = ( + 'displaylimit' +) -@pytest.fixture -def ip(): - """Provides an IPython session in which tables have been created""" - - ip_session = get_ipython() - runsql( - ip_session, - [ - "CREATE TABLE test (n INT, name TEXT)", - "INSERT INTO test VALUES (1, 'foo')", - "INSERT INTO test VALUES (2, 'bar')", - "CREATE TABLE author (first_name, last_name, year_of_death)", - "INSERT INTO author VALUES ('William', 'Shakespeare', 1616)", - "INSERT INTO author VALUES ('Bertold', 'Brecht', 1956)", - ], - ) - yield ip_session - runsql(ip_session, "DROP TABLE test") - runsql(ip_session, "DROP TABLE author") +SQLALCHEMY_VERSION = int(sqlalchemy.__version__.split(".")[0]) def test_memory_db(ip): assert runsql(ip, "SELECT * FROM test;")[0][0] == 1 - assert runsql(ip, "SELECT * FROM test;")[1]["name"] == "bar" + assert runsql(ip, "SELECT * FROM test;")[1][1] == "bar" def test_html(ip): @@ -52,10 +55,19 @@ def test_print(ip): assert re.search(r"1\s+\|\s+foo", str(result)) -def test_plain_style(ip): - ip.run_line_magic("config", "SqlMagic.style = 'PLAIN_COLUMNS'") +@pytest.mark.parametrize( + "style, expected", + [ + ("'PLAIN_COLUMNS'", r"1\s+foo"), + ("'DEFAULT'", r" 1 \| foo \|\n\|"), + ("'SINGLE_BORDER'", r"│\n├───┼──────┤\n│ 1 │ foo │\n│"), + ("'MSWORD_FRIENDLY'", r"\n\| 1 \| foo \|\n\|"), + ], +) +def test_styles(ip, style, expected): + ip.run_line_magic("config", f"SqlMagic.style = {style}") result = runsql(ip, "SELECT * FROM test;") - assert re.search(r"1\s+\|\s+foo", str(result)) + assert re.search(expected, str(result)) @pytest.mark.skip @@ -71,7 +83,7 @@ def test_multi_sql(ip): assert "Shakespeare" in str(result) and "Brecht" in str(result) -def test_result_var(ip): +def test_result_var(ip, capsys): ip.run_cell_magic( "sql", "", @@ -82,7 +94,34 @@ def test_result_var(ip): """, ) result = ip.user_global_ns["x"] + out, _ = capsys.readouterr() + assert "Shakespeare" in str(result) and "Brecht" in str(result) + assert "Returning data to local variable" not in out + + +def test_result_var_link(ip): + ip.run_cell_magic( + "sql", + "", + """ + sqlite:// + x << + SELECT link FROM website; + """, + ) + result = ip.user_global_ns["x"] + + assert ( + "" + "https://en.wikipedia.org/wiki/Bertolt_Brecht" + ) in result._repr_html_() + + assert ( + "" + "https://en.wikipedia.org/wiki/William_Shakespeare" + ) in result._repr_html_() + assert "google_link" not in result._repr_html_() def test_result_var_multiline_shovel(ip): @@ -90,7 +129,7 @@ def test_result_var_multiline_shovel(ip): "sql", "", """ - sqlite:// x << SELECT last_name + sqlite:// x << SELECT last_name FROM author; """, ) @@ -98,10 +137,72 @@ def test_result_var_multiline_shovel(ip): assert "Shakespeare" in str(result) and "Brecht" in str(result) +@pytest.mark.parametrize( + "sql_statement, expected_result", + [ + ( + """ + sqlite:// + x << + SELECT last_name FROM author; + """, + None, + ), + ( + """ + sqlite:// + x= << + SELECT last_name FROM author; + """, + {"last_name": ("Shakespeare", "Brecht")}, + ), + ( + """ + sqlite:// + x = << + SELECT last_name FROM author; + """, + {"last_name": ("Shakespeare", "Brecht")}, + ), + ( + """ + sqlite:// + x = << + SELECT last_name FROM author; + """, + {"last_name": ("Shakespeare", "Brecht")}, + ), + ( + """ + sqlite:// + x = << + SELECT last_name FROM author; + """, + {"last_name": ("Shakespeare", "Brecht")}, + ), + ( + """ + sqlite:// + x = << + SELECT last_name FROM author; + """, + {"last_name": ("Shakespeare", "Brecht")}, + ), + ], +) +def test_return_result_var(ip, sql_statement, expected_result): + result = ip.run_cell_magic("sql", "", sql_statement) + var = ip.user_global_ns["x"] + assert "Shakespeare" in str(var) and "Brecht" in str(var) + if result is not None: + result = result.dict() + assert result == expected_result + + def test_access_results_by_keys(ip): assert runsql(ip, "SELECT * FROM author;")["William"] == ( - u"William", - u"Shakespeare", + "William", + "Shakespeare", 1616, ) @@ -115,16 +216,20 @@ def test_duplicate_column_names_accepted(ip): SELECT last_name, last_name FROM author; """, ) - assert (u"Brecht", u"Brecht") in result + assert ("Brecht", "Brecht") in result -def test_autolimit(ip): - ip.run_line_magic("config", "SqlMagic.autolimit = 0") - result = runsql(ip, "SELECT * FROM test;") - assert len(result) == 2 - ip.run_line_magic("config", "SqlMagic.autolimit = 1") - result = runsql(ip, "SELECT * FROM test;") - assert len(result) == 1 +def test_persist_missing_pandas(ip, monkeypatch): + monkeypatch.setattr(magic, "DataFrame", None) + + ip.run_cell("results = %sql SELECT * FROM test;") + ip.run_cell("results_dframe = results.DataFrame()") + + with pytest.raises(UsageError) as excinfo: + ip.run_cell("%sql --persist sqlite:// results_dframe") + + assert excinfo.value.error_type == "MissingPackageError" + assert "pip install pandas" in str(excinfo.value) def test_persist(ip): @@ -133,94 +238,500 @@ def test_persist(ip): ip.run_cell("results_dframe = results.DataFrame()") ip.run_cell("%sql --persist sqlite:// results_dframe") persisted = runsql(ip, "SELECT * FROM results_dframe") - assert "foo" in str(persisted) + assert persisted == [(0, 1, "foo"), (1, 2, "bar")] + + +def test_persist_in_schema(ip_empty): + ip_empty.run_cell("%sql duckdb://") + ip_empty.run_cell("%sql CREATE SCHEMA IF NOT EXISTS schema1;") + df = pd.DataFrame({"a": [1, 2, 3]}) + ip_empty.push({"df": df}) + ip_empty.run_cell("%sql --persist schema1.df") + persisted = ip_empty.run_cell("%sql SELECT * FROM schema1.df;").result.DataFrame() + assert persisted["a"].tolist() == [1, 2, 3] + + +def test_persist_replace_in_schema(ip_empty): + ip_empty.run_cell("%sql duckdb://") + ip_empty.run_cell("%sql CREATE SCHEMA IF NOT EXISTS schema1;") + df = pd.DataFrame({"a": [1, 2, 3]}) + ip_empty.push({"df": df}) + ip_empty.run_cell("%sql --persist schema1.df") + df = pd.DataFrame({"a": [6, 7]}) + ip_empty.push({"df": df}) + ip_empty.run_cell("%sql --perist-replace schema1.df") + persisted = ip_empty.run_cell("%sql SELECT * FROM schema1.df;").result.DataFrame() + assert persisted["a"].tolist() == [1, 2, 3] + + +def test_append_in_schema(ip_empty): + ip_empty.run_cell("%sql duckdb://") + ip_empty.run_cell("%sql CREATE SCHEMA IF NOT EXISTS schema1;") + df = pd.DataFrame({"a": [1, 2, 3]}) + ip_empty.push({"df": df}) + ip_empty.run_cell("%sql --persist schema1.df") + df = pd.DataFrame({"a": [6, 7]}) + ip_empty.push({"df": df}) + ip_empty.run_cell("%sql --append schema1.df") + persisted = ip_empty.run_cell("%sql SELECT * FROM schema1.df;").result.DataFrame() + assert persisted["a"].tolist() == [1, 2, 3, 6, 7] + + +def test_persist_no_index(ip): + runsql(ip, "") + ip.run_cell("results = %sql SELECT * FROM test;") + ip.run_cell("results_no_index = results.DataFrame()") + ip.run_cell("%sql --persist sqlite:// results_no_index --no-index") + persisted = runsql(ip, "SELECT * FROM results_no_index") + assert persisted == [(1, "foo"), (2, "bar")] + + +@pytest.mark.parametrize( + "sql_statement, expected_error", + [ + ("%%sql --arg\n SELECT * FROM test", "Unrecognized argument(s): --arg"), + ("%%sql -arg\n SELECT * FROM test", "Unrecognized argument(s): -arg"), + ("%%sql --persist '--some' \n SELECT * FROM test", "not a valid identifier"), + ], +) +def test_unrecognized_arguments_cell_magic(ip, sql_statement, expected_error): + with pytest.raises(UsageError) as excinfo: + ip.run_cell(sql_statement) + + assert expected_error in str(excinfo.value) + + +def test_ignore_argument_like_strings_if_they_come_after_the_sql_query(ip): + assert ip.run_cell("%sql select * FROM test --some") + + +def test_persist_invalid_identifier(ip): + with pytest.raises(UsageError) as excinfo: + ip.run_cell("%sql --persist sqlite:// not an identifier") + + assert "not a valid identifier" in str(excinfo.value) + + +def test_persist_undefined_variable(ip): + with pytest.raises(UsageError) as excinfo: + ip.run_cell("%sql --persist sqlite:// not_a_variable") + + assert "Expected 'not_a_variable' to be a pd.DataFrame but it's undefined" in str( + excinfo.value + ) + + +def test_persist_non_frame_raises(ip): + ip.run_cell("not_a_dataframe = 22") + + with pytest.raises(UsageError) as excinfo: + ip.run_cell("%sql --persist sqlite:// not_a_dataframe") + + assert "is not a Pandas DataFrame or Series" in str(excinfo.value) def test_append(ip): runsql(ip, "") ip.run_cell("results = %sql SELECT * FROM test;") - ip.run_cell("results_dframe = results.DataFrame()") - ip.run_cell("%sql --persist sqlite:// results_dframe") - persisted = runsql(ip, "SELECT COUNT(*) FROM results_dframe") - ip.run_cell("%sql --append sqlite:// results_dframe") - appended = runsql(ip, "SELECT COUNT(*) FROM results_dframe") + ip.run_cell("results_dframe_append = results.DataFrame()") + ip.run_cell("%sql --persist sqlite:// results_dframe_append") + persisted = runsql(ip, "SELECT COUNT(*) FROM results_dframe_append") + ip.run_cell("%sql --append sqlite:// results_dframe_append") + appended = runsql(ip, "SELECT COUNT(*) FROM results_dframe_append") assert appended[0][0] == persisted[0][0] * 2 -def test_persist_nonexistent_raises(ip): - runsql(ip, "") - result = ip.run_cell("%sql --persist sqlite:// no_such_dataframe") - assert result.error_in_exec +def test_persist_missing_argument(ip): + with pytest.raises(UsageError) as excinfo: + ip.run_cell("%sql --persist sqlite://") + assert "Expected '' to be a pd.DataFrame but it's not a valid identifier" in str( + excinfo.value + ) -def test_persist_non_frame_raises(ip): - ip.run_cell("not_a_dataframe = 22") - runsql(ip, "") - result = ip.run_cell("%sql --persist sqlite:// not_a_dataframe") - assert result.error_in_exec +def get_table_rows_as_dataframe(ip, table, name=None): + """The function will generate the pandas dataframe in the namespace + by querying the data by given table name""" + if name: + saved_df_name = name + else: + saved_df_name = f"df_{table}" + ip.run_cell(f"results = %sql SELECT * FROM {table} LIMIT 1;") + ip.run_cell(f"{saved_df_name} = results.DataFrame()") + return saved_df_name + + +@pytest.mark.parametrize( + "test_table, expected_result", + [ + ("test", [(0, 1, "foo")]), + ("author", [(0, "William", "Shakespeare", 1616)]), + ( + "website", + [ + ( + 0, + "Bertold Brecht", + "https://en.wikipedia.org/wiki/Bertolt_Brecht", + 1954, + ) + ], + ), + ("number_table", [(0, 4, -2)]), + ], +) +def test_persist_replace_abbr_no_override(ip, test_table, expected_result): + saved_df_name = get_table_rows_as_dataframe(ip, table=test_table) + ip.run_cell(f"%sql -P sqlite:// {saved_df_name}") + out = ip.run_cell(f"%sql SELECT * FROM {saved_df_name}") + assert out.result == expected_result + assert out.error_in_exec is None + + +@pytest.mark.parametrize( + "test_table, expected_result", + [ + ("test", [(0, 1, "foo")]), + ("author", [(0, "William", "Shakespeare", 1616)]), + ( + "website", + [ + ( + 0, + "Bertold Brecht", + "https://en.wikipedia.org/wiki/Bertolt_Brecht", + 1954, + ) + ], + ), + ("number_table", [(0, 4, -2)]), + ], +) +def test_persist_replace_no_override(ip, test_table, expected_result): + saved_df_name = get_table_rows_as_dataframe(ip, table=test_table) + ip.run_cell(f"%sql --persist-replace sqlite:// {saved_df_name}") + out = ip.run_cell(f"%sql SELECT * FROM {saved_df_name}") + assert out.result == expected_result + assert out.error_in_exec is None + + +@pytest.mark.parametrize( + "first_test_table, second_test_table, expected_result", + [ + ("test", "author", [(0, "William", "Shakespeare", 1616)]), + ("author", "test", [(0, 1, "foo")]), + ("test", "number_table", [(0, 4, -2)]), + ("number_table", "test", [(0, 1, "foo")]), + ], +) +def test_persist_replace_override( + ip, first_test_table, second_test_table, expected_result +): + saved_df_name = "dummy_df_name" + table_df = get_table_rows_as_dataframe( + ip, table=first_test_table, name=saved_df_name + ) + ip.run_cell(f"%sql --persist sqlite:// {table_df}") + table_df = get_table_rows_as_dataframe( + ip, table=second_test_table, name=saved_df_name + ) + # To test the second --persist-replace executes successfully + persist_replace_out = ip.run_cell(f"%sql --persist-replace sqlite:// {table_df}") + assert persist_replace_out.error_in_exec is None + + # To test the persisted data is from --persist + out = ip.run_cell(f"%sql SELECT * FROM {table_df}") + assert out.result == expected_result + assert out.error_in_exec is None + + +@pytest.mark.parametrize( + "first_test_table, second_test_table, expected_result", + [ + ("test", "author", [(0, 1, "foo")]), + ("author", "test", [(0, "William", "Shakespeare", 1616)]), + ("test", "number_table", [(0, 1, "foo")]), + ("number_table", "test", [(0, 4, -2)]), + ], +) +def test_persist_replace_override_reverted_order( + ip, first_test_table, second_test_table, expected_result +): + saved_df_name = "dummy_df_name" + table_df = get_table_rows_as_dataframe( + ip, table=first_test_table, name=saved_df_name + ) + ip.run_cell(f"%sql --persist-replace sqlite:// {table_df}") + table_df = get_table_rows_as_dataframe( + ip, table=second_test_table, name=saved_df_name + ) -def test_persist_bare(ip): - result = ip.run_cell("%sql --persist sqlite://") - assert result.error_in_exec + with pytest.raises(UsageError) as excinfo: + ip.run_cell(f"%sql --persist sqlite:// {table_df}") + # To test the second --persist executes not successfully + assert ( + f"Table '{saved_df_name}' already exists. Consider using \ +--persist-replace to drop the table before persisting the data frame" + in str(excinfo.value) + ) -def test_persist_frame_at_its_creation(ip): - ip.run_cell("results = %sql SELECT * FROM author;") - ip.run_cell("%sql --persist sqlite:// results.DataFrame()") - persisted = runsql(ip, "SELECT * FROM results") - assert "Shakespeare" in str(persisted) + # To test the persisted data is from --persist-replace + out = ip.run_cell(f"%sql SELECT * FROM {table_df}") + assert out.result == expected_result + + +@pytest.mark.parametrize( + "test_table", + [ + ("test"), + ("author"), + ("website"), + ("number_table"), + ], +) +def test_persist_and_append_use_together(ip, test_table): + # Test error message when use --persist and --append together + saved_df_name = get_table_rows_as_dataframe(ip, table=test_table) + + with pytest.raises(UsageError) as excinfo: + ip.run_cell(f"%sql --persist-replace --append sqlite:// {saved_df_name}") + + assert """You cannot simultaneously persist and append data to a dataframe; + please choose to utilize either one or the other.""" in str( + excinfo.value + ) + + +@pytest.mark.parametrize( + "test_table, expected_result", + [ + ("test", [(0, 1, "foo")]), + ("author", [(0, "William", "Shakespeare", 1616)]), + ( + "website", + [ + ( + 0, + "Bertold Brecht", + "https://en.wikipedia.org/wiki/Bertolt_Brecht", + 1954, + ) + ], + ), + ("number_table", [(0, 4, -2)]), + ], +) +def test_persist_and_persist_replace_use_together( + ip, capsys, test_table, expected_result +): + # Test error message when use --persist and --persist-replace together + saved_df_name = get_table_rows_as_dataframe(ip, table=test_table) + # check UserWarning is raised + with pytest.warns(UserWarning) as w: + ip.run_cell(f"%sql --persist --persist-replace sqlite:// {saved_df_name}") + + # check that the message matches + assert w[0].message.args[0] == "Please use either --persist or --persist-replace" + + # Test persist-replace is used + execute_out = ip.run_cell(f"%sql SELECT * FROM {saved_df_name}") + assert execute_out.result == expected_result + assert execute_out.error_in_exec is None + + +@pytest.mark.parametrize( + "first_test_table, second_test_table, expected_result", + [ + ("test", "author", [(0, "William", "Shakespeare", 1616)]), + ("author", "test", [(0, 1, "foo")]), + ("test", "number_table", [(0, 4, -2)]), + ("number_table", "test", [(0, 1, "foo")]), + ], +) +def test_persist_replace_twice( + ip, first_test_table, second_test_table, expected_result +): + saved_df_name = "dummy_df_name" + + table_df = get_table_rows_as_dataframe( + ip, table=first_test_table, name=saved_df_name + ) + ip.run_cell(f"%sql --persist-replace sqlite:// {table_df}") + + table_df = get_table_rows_as_dataframe( + ip, table=second_test_table, name=saved_df_name + ) + ip.run_cell(f"%sql --persist-replace sqlite:// {table_df}") + + out = ip.run_cell(f"%sql SELECT * FROM {table_df}") + # To test the persisted data is from --persist-replace + assert out.result == expected_result + assert out.error_in_exec is None def test_connection_args_enforce_json(ip): - result = ip.run_cell('%sql --connection_arguments {"badlyformed":true') - assert result.error_in_exec + with pytest.raises(UsageError) as excinfo: + ip.run_cell('%sql --connection_arguments {"badlyformed":true') + + expected_message = "Expecting ',' delimiter" + assert expected_message in str(excinfo.value) +@pytest.mark.skipif(platform.system() == "Windows", reason="failing on windows") def test_connection_args_in_connection(ip): ip.run_cell('%sql --connection_arguments {"timeout":10} sqlite:///:memory:') result = ip.run_cell("%sql --connections") assert "timeout" in result.result["sqlite:///:memory:"].connect_args +@pytest.mark.skipif(platform.system() == "Windows", reason="failing on windows") def test_connection_args_single_quotes(ip): ip.run_cell("%sql --connection_arguments '{\"timeout\": 10}' sqlite:///:memory:") result = ip.run_cell("%sql --connections") assert "timeout" in result.result["sqlite:///:memory:"].connect_args -def test_connection_args_double_quotes(ip): - ip.run_cell('%sql --connection_arguments "{\\"timeout\\": 10}" sqlite:///:memory:') - result = ip.run_cell("%sql --connections") - assert "timeout" in result.result["sqlite:///:memory:"].connect_args +def test_displaylimit_no_limit(ip): + ip.run_line_magic("config", "SqlMagic.displaylimit = 0") + + out = ip.run_cell("%sql SELECT * FROM number_table;") + assert out.result == [ + (4, -2), + (-5, 0), + (2, 4), + (0, 2), + (-5, -1), + (-2, -3), + (-2, -3), + (-4, 2), + (2, -5), + (4, 3), + ] + +def test_displaylimit_default(ip): + # Insert extra data to make number_table bigger (over 10 to see truncated string) + ip.run_cell("%sql INSERT INTO number_table VALUES (4, 3)") + ip.run_cell("%sql INSERT INTO number_table VALUES (4, 3)") -# TODO: support -# @with_setup(_setup_author, _teardown_author) -# def test_persist_with_connection_info(): -# ip.run_cell("results = %sql SELECT * FROM author;") -# ip.run_line_magic('sql', 'sqlite:// PERSIST results.DataFrame()') -# persisted = ip.run_line_magic('sql', 'SELECT * FROM results') -# assert 'Shakespeare' in str(persisted) + out = ip.run_cell("%sql SELECT * FROM number_table;").result + + assert f"Truncated to {DISPLAYLIMIT_LINK} of 10" in out._repr_html_() def test_displaylimit(ip): ip.run_line_magic("config", "SqlMagic.autolimit = None") - ip.run_line_magic("config", "SqlMagic.displaylimit = None") - result = runsql( - ip, - "SELECT * FROM (VALUES ('apple'), ('banana'), ('cherry')) AS Result ORDER BY 1;", - ) - assert "apple" in result._repr_html_() - assert "banana" in result._repr_html_() - assert "cherry" in result._repr_html_() + ip.run_line_magic("config", "SqlMagic.displaylimit = 1") - result = runsql( - ip, - "SELECT * FROM (VALUES ('apple'), ('banana'), ('cherry')) AS Result ORDER BY 1;", + result = runsql(ip, "SELECT * FROM author ORDER BY first_name;") + + assert "Brecht" in result._repr_html_() + assert "Shakespeare" not in result._repr_html_() + assert "Brecht" in repr(result) + assert "Shakespeare" not in repr(result) + + +@pytest.mark.parametrize("config_value, expected_length", [(3, 3), (6, 6)]) +def test_displaylimit_enabled_truncated_length(ip, config_value, expected_length): + # Insert extra data to make number_table bigger (over 10 to see truncated string) + ip.run_cell("%sql INSERT INTO number_table VALUES (4, 3)") + ip.run_cell("%sql INSERT INTO number_table VALUES (4, 3)") + + ip.run_cell(f"%config SqlMagic.displaylimit = {config_value}") + out = runsql(ip, "SELECT * FROM number_table;") + assert f"Truncated to {DISPLAYLIMIT_LINK} of {expected_length}" in out._repr_html_() + + +@pytest.mark.parametrize("config_value", [(None), (0)]) +def test_displaylimit_enabled_no_limit( + ip, + config_value, +): + # Insert extra data to make number_table bigger (over 10 to see truncated string) + ip.run_cell("%sql INSERT INTO number_table VALUES (4, 3)") + ip.run_cell("%sql INSERT INTO number_table VALUES (4, 3)") + + ip.run_cell(f"%config SqlMagic.displaylimit = {config_value}") + out = runsql(ip, "SELECT * FROM number_table;") + assert "Truncated to displaylimit of " not in out._repr_html_() + + +@pytest.mark.parametrize( + "config_value, expected_error_msg", + [ + (-1, "displaylimit cannot be a negative integer"), + (-2, "displaylimit cannot be a negative integer"), + (-2.5, "The 'displaylimit' trait of a SqlMagic instance expected an int"), + ( + "'some_string'", + "The 'displaylimit' trait of a SqlMagic instance expected an int", + ), + ], +) +def test_displaylimit_enabled_with_invalid_values( + ip, config_value, expected_error_msg, caplog +): + with caplog.at_level(logging.ERROR): + ip.run_cell(f"%config SqlMagic.displaylimit = {config_value}") + + assert expected_error_msg in caplog.text + + +@pytest.mark.parametrize( + "query_clause, expected_truncated_length", + [ + # With limit + ("SELECT * FROM number_table", 12), + ("SELECT * FROM number_table LIMIT 5", None), + ("SELECT * FROM number_table LIMIT 10", None), + ("SELECT * FROM number_table LIMIT 11", 11), + # With conditions + ("SELECT * FROM number_table WHERE x > 0", None), + ("SELECT * FROM number_table WHERE x < 0", None), + ("SELECT * FROM number_table WHERE y < 0", None), + ("SELECT * FROM number_table WHERE y > 0", None), + ], +) +@pytest.mark.parametrize("is_saved_by_cte", [(True, False)]) +def test_displaylimit_with_conditional_clause( + ip, query_clause, expected_truncated_length, is_saved_by_cte +): + # Insert extra data to make number_table bigger (over 10 to see truncated string) + ip.run_cell("%sql INSERT INTO number_table VALUES (4, 3)") + ip.run_cell("%sql INSERT INTO number_table VALUES (4, 3)") + + if is_saved_by_cte: + ip.run_cell(f"%sql --save saved_cte --no-execute {query_clause}") + out = ip.run_line_magic("sql", "--with saved_cte SELECT * from saved_cte") + else: + out = runsql(ip, query_clause) + + if expected_truncated_length: + assert f"Truncated to {DISPLAYLIMIT_LINK} of 10" in out._repr_html_() + + +@pytest.mark.parametrize( + "config_value", + [ + (1), + (0), + (None), + ], +) +def test_displaylimit_with_count_statement(ip, load_penguin, config_value): + ip.run_cell(f"%config SqlMagic.displaylimit = {config_value}") + result = ip.run_line_magic("sql", "select count(*) from penguins.csv") + + assert isinstance(result, ResultSet) + assert str(result) == ( + "+--------------+\n" + "| count_star() |\n" + "+--------------+\n" + "| 344 |\n" + "+--------------+" ) - assert "apple" in result._repr_html_() - assert "cherry" not in result._repr_html_() def test_column_local_vars(ip): @@ -248,7 +759,7 @@ def function(): def test_bind_vars(ip): ip.user_global_ns["x"] = 22 - result = runsql(ip, "SELECT :x") + result = runsql(ip, "SELECT {{x}}") assert result[0][0] == 22 @@ -260,6 +771,73 @@ def test_autopandas(ip): assert dframe.name[0] == "foo" +def test_autopolars(ip): + ip.run_line_magic("config", "SqlMagic.autopolars = True") + dframe = runsql(ip, "SELECT * FROM test;") + + assert isinstance(dframe, pl.DataFrame) + assert not dframe.is_empty() + assert len(dframe.shape) == 2 + assert dframe["name"][0] == "foo" + + +def test_autopolars_infer_schema_length(ip): + """Test for `SqlMagic.polars_dataframe_kwargs = {"infer_schema_length": None}` + Without this config, polars will raise an exception when it cannot infer the + correct schema from the first 100 rows. + """ + # Create a table with 100 rows with a NULL value and one row with a non-NULL value + ip.run_line_magic("config", "SqlMagic.autopolars = True") + sql = ["CREATE TABLE test_autopolars_infer_schema (n INT, name TEXT)"] + for i in range(100): + sql.append(f"INSERT INTO test_autopolars_infer_schema VALUES ({i}, NULL)") + sql.append("INSERT INTO test_autopolars_infer_schema VALUES (100, 'foo')") + runsql(ip, sql) + + # By default, this dataset should raise a ComputeError + with pytest.raises(pl.exceptions.ComputeError): + runsql(ip, "SELECT * FROM test_autopolars_infer_schema;") + + # To avoid this error, pass the `infer_schema_length` argument to polars.DataFrame + line_magic = 'SqlMagic.polars_dataframe_kwargs = {"infer_schema_length": None}' + ip.run_line_magic("config", line_magic) + dframe = runsql(ip, "SELECT * FROM test_autopolars_infer_schema;") + assert dframe.schema == {"n": pl.Int64, "name": pl.Utf8} + + # Assert that if we unset the dataframe kwargs, the error is raised again + ip.run_line_magic("config", "SqlMagic.polars_dataframe_kwargs = {}") + with pytest.raises(pl.exceptions.ComputeError): + runsql(ip, "SELECT * FROM test_autopolars_infer_schema;") + + runsql(ip, "DROP TABLE test_autopolars_infer_schema") + + +def test_mutex_autopolars_autopandas(ip): + ip.run_line_magic("config", "SqlMagic.autopolars = False") + ip.run_line_magic("config", "SqlMagic.autopandas = False") + + dframe = runsql(ip, "SELECT * FROM test;") + assert isinstance(dframe, ResultSet) + + ip.run_line_magic("config", "SqlMagic.autopolars = True") + dframe = runsql(ip, "SELECT * FROM test;") + assert isinstance(dframe, pl.DataFrame) + + ip.run_line_magic("config", "SqlMagic.autopandas = True") + dframe = runsql(ip, "SELECT * FROM test;") + assert isinstance(dframe, pd.DataFrame) + + # Test that re-enabling autopolars works + ip.run_line_magic("config", "SqlMagic.autopolars = True") + dframe = runsql(ip, "SELECT * FROM test;") + assert isinstance(dframe, pl.DataFrame) + + # Disabling autopolars at this point should result in the default behavior + ip.run_line_magic("config", "SqlMagic.autopolars = False") + dframe = runsql(ip, "SELECT * FROM test;") + assert isinstance(dframe, ResultSet) + + def test_csv(ip): ip.run_line_magic("config", "SqlMagic.autopandas = False") # uh-oh result = runsql(ip, "SELECT * FROM test;") @@ -294,11 +872,13 @@ def test_sql_from_file(ip): def test_sql_from_nonexistent_file(ip): - ip.run_line_magic("config", "SqlMagic.autopandas = False") - with tempfile.TemporaryDirectory() as tempdir: - fname = os.path.join(tempdir, "nonexistent.sql") - result = ip.run_cell("%sql --file " + fname) - assert isinstance(result.error_in_exec, FileNotFoundError) + with pytest.raises(UsageError) as excinfo: + ip.run_cell("%sql --file some_file_that_doesnt_exist.sql") + + assert "No such file or directory: 'some_file_that_doesnt_exist.sql" in str( + excinfo.value + ) + assert excinfo.value.error_type == "FileNotFoundError" def test_dict(ip): @@ -321,77 +901,1869 @@ def test_dicts(ip): def test_bracket_var_substitution(ip): - ip.user_global_ns["col"] = "first_name" - assert runsql(ip, "SELECT * FROM author" " WHERE {col} = 'William' ")[0] == ( - u"William", - u"Shakespeare", + assert runsql(ip, "SELECT * FROM author" " WHERE {{col}} = 'William' ")[0] == ( + "William", + "Shakespeare", 1616, ) ip.user_global_ns["col"] = "last_name" - result = runsql(ip, "SELECT * FROM author" " WHERE {col} = 'William' ") + result = runsql(ip, "SELECT * FROM author" " WHERE {{col}} = 'William' ") assert not result +# the next two tests had the same name, so I added a _2 to the second one def test_multiline_bracket_var_substitution(ip): - ip.user_global_ns["col"] = "first_name" - assert runsql(ip, "SELECT * FROM author\n" " WHERE {col} = 'William' ")[0] == ( - u"William", - u"Shakespeare", + assert runsql(ip, "SELECT * FROM author\n" " WHERE {{col}} = 'William' ")[0] == ( + "William", + "Shakespeare", 1616, ) ip.user_global_ns["col"] = "last_name" - result = runsql(ip, "SELECT * FROM author" " WHERE {col} = 'William' ") + result = runsql(ip, "SELECT * FROM author" " WHERE {{col}} = 'William' ") assert not result -def test_multiline_bracket_var_substitution(ip): +def test_multiline_bracket_var_substitution_2(ip): ip.user_global_ns["col"] = "first_name" result = ip.run_cell_magic( "sql", "", """ - sqlite:// SELECT * FROM author - WHERE {col} = 'William' + sqlite:// SELECT * FROM author + WHERE {{col}} = 'William' """, ) - assert (u"William", u"Shakespeare", 1616) in result + assert ("William", "Shakespeare", 1616) in result ip.user_global_ns["col"] = "last_name" result = ip.run_cell_magic( "sql", "", """ - sqlite:// SELECT * FROM author - WHERE {col} = 'William' + sqlite:// SELECT * FROM author + WHERE {{col}} = 'William' """, ) assert not result - + def test_json_in_select(ip): - # Variable expansion does not work within json, but + # Variable expansion does not work within json, but # at least the two usages of curly braces do not collide ip.user_global_ns["person"] = "prince" result = ip.run_cell_magic( "sql", "", """ - sqlite:// + sqlite:// SELECT - '{"greeting": "Farewell sweet {person}"}' + '{"greeting": "Farewell sweet {person}"}' AS json """, ) - assert ('{"greeting": "Farewell sweet {person}"}',) + + assert result == [('{"greeting": "Farewell sweet {person}"}',)] -def test_close_connection(ip): +def test_closed_connections_are_no_longer_listed(ip): connections = runsql(ip, "%sql -l") connection_name = list(connections)[0] runsql(ip, f"%sql -x {connection_name}") connections_afterward = runsql(ip, "%sql -l") assert connection_name not in connections_afterward + + +def test_close_connection(ip, tmp_empty): + process = psutil.Process() + + ip.run_cell("%sql sqlite:///one.db") + ip.run_cell("%sql sqlite:///two.db") + + # check files are open + assert {Path(f.path).name for f in process.open_files()} >= {"one.db", "two.db"} + + # close connections + ip.run_cell("%sql -x sqlite:///one.db") + ip.run_cell("%sql --close sqlite:///two.db") + + # connections should not longer appear + assert "sqlite:///one.db" not in ConnectionManager.connections + assert "sqlite:///two.db" not in ConnectionManager.connections + + # files should be closed + assert {Path(f.path).name for f in process.open_files()} & { + "one.db", + "two.db", + } == set() + + +@pytest.mark.parametrize( + "close_cell", + [ + "%sql -x first", + "%sql --close first", + ], +) +def test_close_connection_with_alias(ip, tmp_empty, close_cell): + process = psutil.Process() + + ip.run_cell("%sql sqlite:///one.db --alias first") + + assert {Path(f.path).name for f in process.open_files()} >= {"one.db"} + + ip.run_cell(close_cell) + + assert "sqlite:///one.db" not in ConnectionManager.connections + assert "first" not in ConnectionManager.connections + assert "one.db" not in {Path(f.path).name for f in process.open_files()} + + +def test_alias(clean_conns, ip_empty, tmp_empty): + ip_empty.run_cell("%sql sqlite:///one.db --alias one") + assert {"one"} == set(ConnectionManager.connections) + + +def test_alias_existing_engine(clean_conns, ip_empty, tmp_empty): + ip_empty.user_global_ns["first"] = create_engine("sqlite:///first.db") + ip_empty.run_cell("%sql first --alias one") + assert {"one"} == set(ConnectionManager.connections) + + +def test_alias_dbapi_connection(clean_conns, ip_empty, tmp_empty): + ip_empty.user_global_ns["first"] = create_engine("sqlite://") + ip_empty.run_cell("%sql first --alias one") + assert {"one"} == set(ConnectionManager.connections) + + +def test_close_connection_with_existing_engine_and_alias(ip, tmp_empty): + ip.user_global_ns["first"] = create_engine("sqlite:///first.db") + ip.user_global_ns["second"] = create_engine("sqlite:///second.db") + + # open two connections + ip.run_cell("%sql first --alias one") + ip.run_cell("%sql second --alias two") + + # close them + ip.run_cell("%sql -x one") + ip.run_cell("%sql --close two") + + assert "sqlite:///first.db" not in ConnectionManager.connections + assert "sqlite:///second.db" not in ConnectionManager.connections + assert "first" not in ConnectionManager.connections + assert "second" not in ConnectionManager.connections + + +def test_close_connection_with_dbapi_connection_and_alias(ip, tmp_empty): + ip.user_global_ns["first"] = create_engine("sqlite:///first.db") + ip.user_global_ns["second"] = create_engine("sqlite:///second.db") + + # open two connections + ip.run_cell("%sql first --alias one") + ip.run_cell("%sql second --alias two") + + # close them + ip.run_cell("%sql -x one") + ip.run_cell("%sql --close two") + + assert "sqlite:///first.db" not in ConnectionManager.connections + assert "sqlite:///second.db" not in ConnectionManager.connections + assert "first" not in ConnectionManager.connections + assert "second" not in ConnectionManager.connections + + +def test_creator_no_argument_raises(ip_empty): + with pytest.raises( + UsageError, match="argument -c/--creator: expected one argument" + ): + ip_empty.run_line_magic("sql", "--creator") + + +def test_creator(monkeypatch, ip_empty): + monkeypatch.setenv("DATABASE_URL", "sqlite:///") + + def creator(): + return sqlite3.connect("") + + ip_empty.user_global_ns["func"] = creator + ip_empty.run_line_magic("sql", "--creator func") + + result = ip_empty.run_line_magic( + "sql", "SELECT name FROM sqlite_schema WHERE type='table' ORDER BY name;" + ) + + assert isinstance(result, ResultSet) + + +def test_column_names_visible(ip, tmp_empty): + res = ip.run_line_magic("sql", "SELECT * FROM empty_table") + + assert "" in res._repr_html_() + assert "" in res._repr_html_() + + +@pytest.mark.xfail(reason="known parse @ parser.py error") +def test_sqlite_path_with_spaces(ip, tmp_empty): + ip.run_cell("%sql sqlite:///some database.db") + + assert Path("some database.db").is_file() + + +def test_pass_existing_engine(ip, tmp_empty): + ip.user_global_ns["my_engine"] = create_engine("sqlite:///my.db") + ip.run_line_magic("sql", " my_engine ") + + runsql( + ip, + [ + "CREATE TABLE some_data (n INT, name TEXT)", + "INSERT INTO some_data VALUES (10, 'foo')", + "INSERT INTO some_data VALUES (20, 'bar')", + ], + ) + + result = ip.run_line_magic("sql", "SELECT * FROM some_data") + + assert result == [(10, "foo"), (20, "bar")] + + +# there's some weird shared state with this one, moving it to the end +def test_autolimit(ip): + # test table has two rows + ip.run_line_magic("config", "SqlMagic.autolimit = 0") + result = runsql(ip, "SELECT * FROM test;") + assert len(result) == 2 + + # test table has two rows + ip.run_line_magic("config", "SqlMagic.autolimit = None") + result = runsql(ip, "SELECT * FROM test;") + assert len(result) == 2 + + # test setting autolimit to 1 + ip.run_line_magic("config", "SqlMagic.autolimit = 1") + result = runsql(ip, "SELECT * FROM test;") + assert len(result) == 1 + + +invalid_connection_string = f""" +No active connection. + +To fix it: + +Pass a valid connection string: + Example: %sql postgresql://username:password@hostname/dbname + +OR + +Set the environment variable $DATABASE_URL + +For more details, see: {PLOOMBER_DOCS_LINK_STR} +{COMMUNITY} +""" + + +def test_error_on_invalid_connection_string(ip_empty, clean_conns): + with pytest.raises(UsageError) as excinfo: + ip_empty.run_cell("%sql some invalid connection string") + + assert invalid_connection_string.strip() == str(excinfo.value) + + +invalid_connection_string_format = f"""\ +Can't load plugin: sqlalchemy.dialects:something + +To fix it, make sure you are using correct driver name: +Ref: https://docs.sqlalchemy.org/en/20/core/engines.html#database-urls + +For more details, see: {PLOOMBER_DOCS_LINK_STR} +{COMMUNITY} +""" # noqa + + +def test_error_on_invalid_connection_string_format(ip_empty, clean_conns): + with pytest.raises(UsageError) as excinfo: + ip_empty.run_cell("%sql something://") + + assert invalid_connection_string_format.strip() == str(excinfo.value) + + +def test_error_on_invalid_connection_string_with_existing_conns(ip_empty, clean_conns): + ip_empty.run_cell("%sql sqlite://") + + with pytest.raises(UsageError) as excinfo: + ip_empty.run_cell("%sql something://") + + assert invalid_connection_string_format.strip() == str(excinfo.value) + + +invalid_connection_string_with_possible_typo = f""" +Can't load plugin: sqlalchemy.dialects:sqlit + +Perhaps you meant to use driver the dialect: "sqlite" + +For more details, see: {PLOOMBER_DOCS_LINK_STR} +{COMMUNITY} +""" # noqa + + +def test_error_on_invalid_connection_string_with_possible_typo(ip_empty, clean_conns): + ip_empty.run_cell("%sql sqlite://") + + with pytest.raises(UsageError) as excinfo: + ip_empty.run_cell("%sql sqlit://") + + assert invalid_connection_string_with_possible_typo.strip() == str(excinfo.value) + + +invalid_connection_string_duckdb_top = """ +An error happened while creating the connection: connect(): incompatible function arguments. The following argument types are supported: + 1. (database: str = ':memory:', read_only: bool = False, config: dict = None) -> duckdb.DuckDBPyConnection +""" # noqa + +invalid_connection_string_duckdb_bottom = f""" +Perhaps you meant to use the 'duckdb' db +To find more information regarding connection: https://jupysql.ploomber.io/en/latest/integrations/duckdb.html + +To fix it: + +Pass a valid connection string: + Example: %sql postgresql://username:password@hostname/dbname + +For more details, see: {PLOOMBER_DOCS_LINK_STR} +{COMMUNITY} +""" # noqa + + +def test_error_on_invalid_connection_string_duckdb(ip_empty, clean_conns): + with pytest.raises(UsageError) as excinfo: + ip_empty.run_cell("%sql duckdb://invalid_db") + + assert invalid_connection_string_duckdb_top.strip() in str(excinfo.value) + assert invalid_connection_string_duckdb_bottom.strip() in str(excinfo.value) + + +@pytest.mark.parametrize( + "establish_non_identifier, non_identifier", + [ + ( + "conn_in_lst = [conn]", + "conn_in_lst[0]", + ), + ( + "conn_in_dict = {'conn1': conn}", + "conn_in_dict['conn1']", + ), + ( + """ +class ConnInObj(object): + def __init__(self, conn): + self.conn1 = conn + +conn_in_obj = ConnInObj(conn) +""", + "conn_in_obj.conn1", + ), + ], +) +def test_error_on_passing_non_identifier_to_connect( + ip_empty, establish_non_identifier, non_identifier +): + ip_empty.run_cell("import duckdb; conn = duckdb.connect();") + ip_empty.run_cell(establish_non_identifier) + + with pytest.raises(UsageError) as excinfo: + ip_empty.run_cell(f"%sql {non_identifier}") + + assert excinfo.value.error_type == "UsageError" + assert ( + f"'{non_identifier}' is not a valid connection identifier. " + "Please pass the variable's name directly, as passing " + "object attributes, dictionaries or lists won't work." + ) in str(excinfo.value) + + +@pytest.mark.skipif( + SQLALCHEMY_VERSION == 1, reason="no transaction is active error with sqlalchemy 1.x" +) +@pytest.mark.parametrize( + "command", + [ + ("commit;"), + ("rollback;"), + ], +) +def test_passing_command_ending_with_semicolon(ip_empty, command): + expected_result = "+---------+\n" "| Success |\n" "+---------+\n" "+---------+" + ip_empty.run_cell("%sql duckdb://") + + out = ip_empty.run_cell(f"%sql {command}").result + assert str(out) == expected_result + + ip_empty.run_cell( + f"""%%sql +{command} +""" + ) + assert str(out) == expected_result + + +def test_jupysql_alias(): + assert SqlMagic.magics == { + "line": {"jupysql": "execute", "sql": "execute"}, + "cell": {"jupysql": "execute", "sql": "execute"}, + } + + +@pytest.mark.xfail(reason="will be fixed once we deprecate the $name parametrization") +def test_columns_with_dollar_sign(ip_empty): + ip_empty.run_cell("%sql sqlite://") + result = ip_empty.run_cell( + """ + %sql SELECT $2 FROM (VALUES (1, 'one'), (2, 'two'), (3, 'three'))""" + ) + + html = result.result._repr_html_() + + assert "$2" in html + + +def test_save_with(ip): + # First Query + ip.run_cell( + "%sql --save shakespeare SELECT * FROM author WHERE last_name = 'Shakespeare'" + ) + # Second Query + ip.run_cell( + "%sql --with shakespeare --save shake_born_in_1616 SELECT * FROM " + "shakespeare WHERE year_of_death = 1616" + ) + + # Third Query + ip.run_cell( + "%sql --save shake_born_in_1616_limit_10 --with shake_born_in_1616" + " SELECT * FROM shake_born_in_1616 LIMIT 10" + ) + + second_out = ip.run_cell( + "%sql --with shake_born_in_1616 SELECT * FROM shake_born_in_1616" + ) + third_out = ip.run_cell( + "%sql --with shake_born_in_1616_limit_10" + " SELECT * FROM shake_born_in_1616_limit_10" + ) + assert second_out.result == [("William", "Shakespeare", 1616)] + assert third_out.result == [("William", "Shakespeare", 1616)] + + +@pytest.mark.parametrize( + "prep_cell_1, prep_cell_2, prep_cell_3, with_cell_1," + " with_cell_2, with_cell_1_excepted, with_cell_2_excepted", + [ + [ + "%sql --save everything SELECT * FROM number_table", + "%sql --with everything --no-execute --save positive_x" + " SELECT * FROM everything WHERE x > 0", + "%sql --with positive_x --no-execute --save " + "positive_x_and_y SELECT * FROM positive_x WHERE y > 0", + "%sql --with positive_x SELECT * FROM positive_x", + "%sql --with positive_x_and_y SELECT * FROM positive_x_and_y", + [(4, -2), (2, 4), (2, -5), (4, 3)], + [(2, 4), (4, 3)], + ], + [ + "%sql --save everything SELECT * FROM number_table", + "%sql --with everything --no-execute --save odd_x " + "SELECT * FROM everything WHERE x % 2 != 0", + "%sql --with odd_x --no-execute --save odd_x_and_y " + "SELECT * FROM odd_x WHERE y % 2 != 0", + "%sql --with odd_x SELECT * FROM odd_x", + "%sql --with odd_x_and_y SELECT * FROM odd_x_and_y", + [(-5, 0), (-5, -1)], + [(-5, -1)], + ], + ], +) +def test_save_with_number_table( + ip, + prep_cell_1, + prep_cell_2, + prep_cell_3, + with_cell_1, + with_cell_2, + with_cell_1_excepted, + with_cell_2_excepted, +): + ip.run_cell(prep_cell_1) + ip.run_cell(prep_cell_2) + ip.run_cell(prep_cell_3) + ip.run_cell(prep_cell_1) + + with_cell_1_out = ip.run_cell(with_cell_1).result + with_cell_2_out = ip.run_cell(with_cell_2).result + assert with_cell_1_excepted == with_cell_1_out + assert with_cell_2_excepted == with_cell_2_out + + +def test_save_with_non_existing_with(ip): + with pytest.raises(UsageError) as excinfo: + ip.run_cell( + "%sql --with non_existing_sub_query SELECT * FROM non_existing_sub_query" + ) + + assert '"non_existing_sub_query" is not a valid snippet identifier.' in str( + excinfo.value + ) + assert excinfo.value.error_type == "UsageError" + + +def test_save_with_non_existing_table(ip): + with pytest.raises(UsageError) as excinfo: + ip.run_cell("%sql --save my_query SELECT * FROM non_existing_table") + + assert excinfo.value.error_type == "RuntimeError" + assert "(sqlite3.OperationalError) no such table: non_existing_table" in str( + excinfo.value + ) + + +def test_interact_basic_data_types(ip, capsys): + ip.user_global_ns["my_variable"] = 5 + ip.run_cell( + "%sql --interact my_variable SELECT * FROM author LIMIT {{my_variable}}" + ) + out, _ = capsys.readouterr() + + assert ( + "Interactive mode, please interact with below widget(s)" + " to control the variable" in out + ) + + +@pytest.fixture +def mockValueWidget(monkeypatch): + with patch("ipywidgets.widgets.IntSlider") as MockClass: + instance = MockClass.return_value + yield instance + + +def test_interact_basic_widgets(ip, mockValueWidget, capsys): + ip.user_global_ns["my_widget"] = mockValueWidget + + ip.run_cell( + "%sql --interact my_widget SELECT * FROM number_table LIMIT {{my_widget}}" + ) + out, _ = capsys.readouterr() + assert ( + "Interactive mode, please interact with below widget(s)" + " to control the variable" in out + ) + + +def test_interact_and_missing_ipywidgets_installed(ip): + with patch.dict(sys.modules): + sys.modules["ipywidgets"] = None + ip.user_global_ns["my_variable"] = 5 + + with pytest.raises(ModuleNotFoundError) as excinfo: + ip.run_cell( + "%sql --interact my_variable SELECT * FROM author LIMIT {{my_variable}}" + ) + + assert "'ipywidgets' is required to use '--interactive argument'" in str( + excinfo.value + ) + + +@pytest.mark.parametrize( + "fixture_name", + [ + "ip", + "ip_dbapi", + ], +) +def test_interpolation_ignore_literals(fixture_name, request): + ip = request.getfixturevalue(fixture_name) + + ip.run_cell("%config SqlMagic.named_parameters = True") + + # this isn't a parameter because it's quoted (':last_name') + result = ip.run_cell( + "%sql select * from author where last_name = ':last_name'" + ).result + assert result.dict() == {} + + +def test_sqlalchemy_interpolation(ip): + ip.run_cell("%config SqlMagic.named_parameters = True") + + ip.run_cell("last_name = 'Shakespeare'") + + # define another variable to ensure the test doesn't break if there are more + # variables in the namespace + ip.run_cell("first_name = 'William'") + + result = ip.run_cell( + "%sql select * from author where last_name = :last_name" + ).result + + assert result.dict() == { + "first_name": ("William",), + "last_name": ("Shakespeare",), + "year_of_death": (1616,), + } + + +def test_sqlalchemy_interpolation_missing_parameter(ip): + ip.run_cell("%config SqlMagic.named_parameters = True") + + with pytest.raises(UsageError) as excinfo: + ip.run_cell("%sql select * from author where last_name = :last_name") + + assert ( + "Cannot execute query because the following variables are undefined: last_name" + in str(excinfo.value) + ) + + +@pytest.mark.parametrize( + "fixture_name", + [ + "ip", + "ip_dbapi", + ], +) +def test_sqlalchemy_insert_literals_with_colon_character(fixture_name, request): + ip = request.getfixturevalue(fixture_name) + + ip.run_cell( + """%%sql +CREATE TABLE names ( + name VARCHAR(50) NOT NULL +); + +INSERT INTO names (name) +VALUES + ('John'), + (':Mary'), + ('Alex'), + (':Lily'), + ('Michael'), + ('Robert'), + (':Sarah'), + ('Jennifer'), + (':Tom'), + ('Jessica'); +""" + ) + + result = ip.run_cell("%sql SELECT * FROM names WHERE name = ':Mary'").result + + assert result.dict() == {"name": (":Mary",)} + + +def test_error_suggests_turning_feature_on_if_it_detects_named_params(ip): + ip.run_cell("%config SqlMagic.named_parameters = False") + + with pytest.raises(UsageError) as excinfo: + ip.run_cell("%sql SELECT * FROM penguins.csv where species = :species") + + suggestion = ( + "Your query contains named parameters (species) " + 'but the named parameters feature is "warn". \nEnable it ' + 'with: %config SqlMagic.named_parameters="enabled" \nor ' + "disable it with: " + '%config SqlMagic.named_parameters="disabled"\n' + "For more info, see the docs: " + "https://jupysql.ploomber.io/en/latest/api/configuration.html" + ) + assert suggestion in str(excinfo.value) + + +@pytest.mark.parametrize( + "cell, expected_warning", + [ + ( + "%sql SELECT * FROM author where last_name = ':last_name'", + "The following variables are defined: last_name.", + ), + ( + "%sql SELECT * FROM author where last_name = ':last_name' " + "and first_name = :first_name", + "The following variables are defined: last_name.", + ), + ( + "%sql SELECT * FROM author where last_name = ':last_name' " + "and first_name = ':first_name'", + "The following variables are defined: first_name, last_name.", + ), + ], + ids=[ + "one-quoted", + "one-quoted-one-unquoted", + "two-quoted", + ], +) +def test_warning_if_variable_defined_but_named_param_is_quoted( + ip, cell, expected_warning +): + ip.run_cell("%config SqlMagic.named_parameters = True") + ip.run_cell("last_name = 'Shakespeare'") + ip.run_cell("first_name = 'William'") + + with pytest.warns( + JupySQLQuotedNamedParametersWarning, + match=expected_warning, + ): + ip.run_cell(cell) + + +def test_can_run_cte_that_references_a_table_whose_name_is_the_same_as_a_snippet(ip): + # randomize the name to avoid collisions + identifier = "shakespeare_" + str(uuid.uuid4())[:8] + + # create table + ip.run_cell( + f"""%%sql +create table {identifier} as select * from author where last_name = 'Shakespeare' +""" + ) + + # store a snippet with the same name + ip.run_cell( + f"""%%sql --save {identifier} +select * from author where last_name = 'some other last name' +""" + ) + + # this should query the table, not the snippet + results = ip.run_cell( + f"""%%sql +with author_subset as ( + select * from {identifier} +) +select * from author_subset +""" + ).result + + assert results.dict() == { + "first_name": ("William",), + "last_name": ("Shakespeare",), + "year_of_death": (1616,), + } + + +def test_error_when_running_a_cte_and_passing_with_argument(ip): + # randomize the name to avoid collisions + identifier = "shakespeare_" + str(uuid.uuid4())[:8] + + # create table + ip.run_cell( + f"""%%sql +create table {identifier} as select * from author where last_name = 'Shakespeare' +""" + ) + + # store a snippet with the same name + ip.run_cell( + f"""%%sql --save {identifier} +select * from author where last_name = 'some other last name' +""" + ) + + with pytest.raises(UsageError) as excinfo: + ip.run_cell( + f"""%%sql --with {identifier} +with author_subset as ( + select * from {identifier} +) +select * from author_subset +""" + ) + + assert "Cannot use --with with CTEs, remove --with and re-run the cell" in str( + excinfo.value + ) + + +def test_error_if_using_persist_with_dbapi_connection(ip_dbapi): + df = pd.DataFrame({"a": [1, 2, 3]}) + ip_dbapi.push({"df": df}) + + with pytest.raises(UsageError) as excinfo: + ip_dbapi.run_cell("%sql --persist df") + + message = ( + "--persist/--persist-replace is not available for " + "DBAPI connections (only available for SQLAlchemy connections)" + ) + assert message in str(excinfo.value) + + +@pytest.mark.parametrize("cell", ["%sql --persist df", "%sql --persist-replace df"]) +def test_persist_uses_error_handling_method(ip, monkeypatch, cell): + df = pd.DataFrame({"a": [1, 2, 3]}) + ip.push({"df": df}) + + conn = ConnectionManager.current + execute_with_error_handling_mock = Mock(wraps=conn._execute_with_error_handling) + monkeypatch.setattr( + conn, "_execute_with_error_handling", execute_with_error_handling_mock + ) + + ip.run_cell(cell) + + # ensure this got called because this function handles several sqlalchemy edge + # cases + execute_with_error_handling_mock.assert_called_once() + + +def test_error_when_using_section_argument_but_dsn_is_missing(ip_empty, tmp_empty): + ip_empty.run_cell("%config SqlMagic.dsn_filename = 'path/to/connections.ini'") + + with pytest.raises(UsageError) as excinfo: + ip_empty.run_cell("%sql --section some_section") + + assert excinfo.value.error_type == "FileNotFoundError" + assert "%config SqlMagic.dsn_filename" in str(excinfo.value) + assert "not found" in str(excinfo.value) + + +def test_error_when_using_section_argument_but_dsn_section_is_missing( + ip_empty, tmp_empty +): + Path("connections.ini").write_text( + """ +[section] +key = value +""" + ) + + ip_empty.run_cell("%config SqlMagic.dsn_filename = 'connections.ini'") + + with pytest.raises(UsageError) as excinfo: + ip_empty.run_cell("%sql --section another_section") + + assert excinfo.value.error_type == "KeyError" + + message = ( + "The section 'another_section' does not exist in the " + "connections file 'connections.ini'" + ) + assert message in str(excinfo.value) + + +def test_error_when_using_section_argument_but_keys_are_invalid(ip_empty, tmp_empty): + Path("connections.ini").write_text( + """ +[section] +key = value +""" + ) + + ip_empty.run_cell("%config SqlMagic.dsn_filename = 'connections.ini'") + + with pytest.raises(UsageError) as excinfo: + ip_empty.run_cell("%sql --section section") + + assert excinfo.value.error_type == "TypeError" + + message = "%config SqlMagic.dsn_filename ('connections.ini') is invalid" + assert message in str(excinfo.value) + + +def test_error_when_using_section_argument_but_values_are_invalid(ip_empty, tmp_empty): + Path("connections.ini").write_text( + """ +[section] +drivername = not-a-driver +""" + ) + + ip_empty.run_cell("%config SqlMagic.dsn_filename = 'connections.ini'") + + with pytest.raises(UsageError) as excinfo: + ip_empty.run_cell("%sql --section section") + + message = "Could not parse SQLAlchemy URL from string 'not-a-driver://'" + assert message in str(excinfo.value) + + +def test_error_when_using_section_argument_and_alias(ip_empty, tmp_empty): + Path("connections.ini").write_text( + """ +[duck] +drivername = duckdb +""" + ) + + ip_empty.run_cell("%config SqlMagic.dsn_filename = 'connections.ini'") + + with pytest.raises(UsageError) as excinfo: + ip_empty.run_cell("%sql --section duck --alias stuff") + + assert excinfo.value.error_type == "UsageError" + + message = "Cannot use --section with --alias" + assert message in str(excinfo.value) + + +def test_connect_to_db_in_connections_file_using_section_argument(ip_empty, tmp_empty): + Path("connections.ini").write_text( + """ +[duck] +drivername = duckdb +""" + ) + + ip_empty.run_cell("%config SqlMagic.dsn_filename = 'connections.ini'") + + ip_empty.run_cell("%sql --section duck") + + conns = ConnectionManager.connections + assert conns == {"duck": ANY} + + +def test_connect_to_db_in_connections_file_using_section_name_between_square_brackets( + ip_empty, tmp_empty +): + Path("connections.ini").write_text( + """ +[duck] +drivername = duckdb +""" + ) + + ip_empty.run_cell("%config SqlMagic.dsn_filename = 'connections.ini'") + + with pytest.warns(FutureWarning) as record: + ip_empty.run_cell("%sql [duck]") + + assert "Starting connections with: %sql [section_name] is deprecated" in str( + record[0].message + ) + assert len(record) == 1 + conns = ConnectionManager.connections + assert conns == {"duckdb://": ANY} + + +@pytest.mark.parametrize( + "content, error_type, error_detail", + [ + ( + """ +[duck] +drivername = duckdb + +[duck] +drivername = duckdb +""", + "DuplicateSectionError", + "section 'duck' already exists", + ), + ( + """ +[duck] +drivername = duckdb +drivername = duckdb +""", + "DuplicateOptionError", + "option 'drivername' in section 'duck' already exists", + ), + ], + ids=[ + "duplicate-section", + "duplicate-key", + ], +) +def test_error_when_ini_file_is_corrupted( + ip_empty, tmp_empty, content, error_type, error_detail +): + Path("connections.ini").write_text(content) + + ip_empty.run_cell("%config SqlMagic.dsn_filename = 'connections.ini'") + + with pytest.raises(UsageError) as excinfo: + ip_empty.run_cell("%sql --section duck") + + assert "An error happened when loading your %config SqlMagic.dsn_filename" in str( + excinfo.value + ) + + assert error_type in str(excinfo.value) + assert error_detail in str(excinfo.value) + + +def test_spaces_in_variable_name(ip_empty): + ip_empty.run_cell("%sql duckdb://") + ip_empty.run_cell("%sql create table 'table with spaces' (n INT)") + ip_empty.run_cell('%sql create table "table with spaces2" (n INT)') + tables_result = ip_empty.run_cell("%sqlcmd tables").result + assert "table with spaces" in str(tables_result) + assert "table with spaces2" in str(tables_result) + + ip_empty.run_cell("%sql INSERT INTO 'table with spaces' VALUES (1)") + ip_empty.run_cell('%sql INSERT INTO "table with spaces" VALUES (2)') + ip_empty.run_cell( + """%%sql +INSERT INTO 'table with spaces' VALUES (3) +""" + ) + ip_empty.run_cell( + """%%sql +INSERT INTO "table with spaces" VALUES (4) +""" + ) + select_result_with_single_quote = ip_empty.run_cell( + "%sql SELECT * FROM 'table with spaces'" + ).result + assert select_result_with_single_quote.dict() == {"n": (1, 2, 3, 4)} + + select_result_with_double_quote = ip_empty.run_cell( + '%sql SELECT * FROM "table with spaces"' + ).result + assert select_result_with_double_quote.dict() == {"n": (1, 2, 3, 4)} + + +@pytest.mark.parametrize( + "query", + [ + (" SELECT * FROM test"), + (" SELECT * FROM test"), + (" SELECT * FROM test"), + ( + """ +SELECT * FROM test""" + ), + ( + """ + +SELECT * FROM test""" + ), + ( + """ +SELECT + * FROM test""" + ), + ( + """ + +SELECT + * FROM test""" + ), + ], +) +def test_whitespaces_linebreaks_near_first_token(ip, query): + expected_result = ( + "+---+------+\n" + "| n | name |\n" + "+---+------+\n" + "| 1 | foo |\n" + "| 2 | bar |\n" + "+---+------+" + ) + + ip.user_global_ns["query"] = query + out = ip.run_cell("%sql {{query}}").result + assert str(out) == expected_result + + out = ip.run_cell( + """%%sql +{{query}}""" + ).result + assert str(out) == expected_result + + +def test_summarize_in_duckdb(ip_empty): + expected_result = { + "column_name": ("id", "x"), + "column_type": ("INTEGER", "INTEGER"), + "min": ("1", "-1"), + "max": ("3", "2"), + "approx_unique": (3, 3), + "avg": ("2.0", "0.6666666666666666"), + "std": ("1.0", "1.5275252316519468"), + "q25": ("1", "0"), + "q50": ("2", "1"), + "q75": ("3", "2"), + "count": (3, 3), + "null_percentage": (Decimal("0.00"), Decimal("0.00")), + } + + ip_empty.run_cell("%sql duckdb://") + ip_empty.run_cell("%sql CREATE TABLE table1 (id INTEGER, x INTEGER)") + ip_empty.run_cell( + """%%sql +INSERT INTO table1 VALUES (1, -1), (2, 1), (3, 2)""" + ) + out = ip_empty.run_cell("%sql SUMMARIZE table1").result + assert out.dict() == expected_result + + out = ip_empty.run_cell( + """%%sql +SUMMARIZE table1""" + ).result + assert out.dict() == expected_result + + +def test_accessing_previously_nonexisting_file(ip_empty, tmp_empty, capsys): + ip_empty.run_cell("%sql duckdb://") + with pytest.raises(UsageError): + ip_empty.run_cell("%sql SELECT * FROM 'data.csv' LIMIT 3") + + Path("data.csv").write_text( + "name,age\nDan,33\nBob,19\nSheri,\nVin,33\nMick,\nJay,33\nSky,33" + ) + expected = ( + "+-------+------+\n" + "| name | age |\n" + "+-------+------+\n" + "| Dan | 33 |\n" + "| Bob | 19 |\n" + "| Sheri | None |\n" + "+-------+------+" + ) + + ip_empty.run_cell("%sql SELECT * FROM 'data.csv' LIMIT 3") + out, _ = capsys.readouterr() + assert expected in out + + +expected_summarize = { + "column_name": ("memid",), + "column_type": ("BIGINT",), + "min": ("1",), + "max": ("8",), + "approx_unique": (5,), + "avg": ("3.8",), + "std": ("2.7748873851023217",), + "q25": ("2",), + "q50": ("3",), + "q75": ("6",), + "count": (5,), + "null_percentage": (Decimal("0.00"),), +} +expected_select = {"memid": (1, 2, 3, 5, 8)} + + +@pytest.mark.parametrize( + "cell, expected_output", + [ + ("%sql /* x */ SUMMARIZE df", expected_summarize), + ("%sql /*x*//*x*/ SUMMARIZE /*x*/ df", expected_summarize), + ( + """%%sql + /*x*/ + SUMMARIZE df + """, + expected_summarize, + ), + ( + """%%sql + /*x*/ + /*x*/ + -- comment + SUMMARIZE df + /*x*/ + """, + expected_summarize, + ), + ( + """%%sql + /*x*/ + SELECT * FROM df + """, + expected_select, + ), + ( + """%%sql + /*x*/ + FROM df SELECT * + """, + expected_select, + ), + ], +) +def test_comments_in_duckdb_select_summarize(ip_empty, cell, expected_output): + ip_empty.run_cell("%sql duckdb://") + df = pd.DataFrame( # noqa: F841 + data=dict( + memid=[1, 2, 3, 5, 8], + ), + ) + out = ip_empty.run_cell(cell).result + assert out.dict() == expected_output + + +@pytest.mark.parametrize( + "setup, save_snippet, query_with_error, error_msgs, error_type", + [ + ( + """ + %sql duckdb:// + %sql CREATE TABLE penguins (id INTEGER) + %sql INSERT INTO penguins VALUES (1) + """, + """ + %%sql --save mysnippet + SELECT * FROM penguins + """, + "%sql select not_a_function(id) from mysnippet", + [ + "Scalar Function with name not_a_function does not exist!", + ], + "RuntimeError", + ), + ( + """ + %sql duckdb:// + %sql CREATE TABLE penguins (id INTEGER) + %sql INSERT INTO penguins VALUES (1) + """, + """ + %%sql --save mysnippet + SELECT * FROM penguins + """, + "%sql select not_a_function(id) from mysnip", + [ + "If using snippets, you may pass the --with argument explicitly.", + "There is no table with name 'mysnip'", + "Table with name mysnip does not exist!", + ], + "TableNotFoundError", + ), + ( + "%sql sqlite://", + """ + %%sql --save mysnippet + select * from test + """, + "%sql select not_a_function(name) from mysnippet", + [ + "no such function: not_a_function", + ], + "RuntimeError", + ), + ( + "%sql sqlite://", + """ + %%sql --save mysnippet + select * from test + """, + "%sql select not_a_function(name) from mysnip", + [ + "If using snippets, you may pass the --with argument explicitly.", + "There is no table with name 'mysnip'", + "no such table: mysnip", + ], + "TableNotFoundError", + ), + ], + ids=[ + "no-typo-duckdb", + "with-typo-duckdb", + "no-typo-sqlite", + "with-typo-sqlite", + ], +) +def test_query_snippet_invalid_function_error_message( + ip, setup, save_snippet, query_with_error, error_msgs, error_type +): + # Set up snippet. + ip.run_cell(setup) + ip.run_cell(save_snippet) + + # Run query + with pytest.raises(UsageError) as excinfo: + ip.run_cell(query_with_error) + + # Save result and test error message + result_error = excinfo.value.error_type + result_msg = str(excinfo.value) + + assert error_type == result_error + assert all(msg in result_msg for msg in error_msgs) + + +@pytest.mark.parametrize( + "sql_snippet, sql_query, expected_result, raises", + [ + ( + """%%sql --save language_lt1 +select * from languages where rating < 1""", + """%%sql +create table langs as ( + select * from language_lt1 +)""", + """Your query is using the following snippets: language_lt1. \ +The query is not a SELECT type query and as snippets only work \ +with SELECT queries, CTE generation is disabled""", + True, + ), + ( + """%%sql --save language_lt2 +select * from languages where rating < 2""", + """%%sql +with langs as ( + select * from language_lt2 +) select * from langs """, + """Your query is using one or more of the following snippets: \ +language_lt2. JupySQL does not support snippet expansion within CTEs yet, \ +CTE generation is disabled""", + True, + ), + ( + """%%sql --save language_lt3 +select * from languages where rating < 3""", + """%%sql +create table langs1 as ( + WITH language_lt3 as ( + select * from languages where rating < 3 + ) + select * from language_lt3 +) """, + """Your query is using the following snippets: language_lt3. \ +The query is not a SELECT type query and as snippets only work \ +with SELECT queries, CTE generation is disabled""", + False, + ), + ], +) +def test_warn_when_using_snippets_in_non_select_command( + ip_empty, capsys, sql_snippet, sql_query, expected_result, raises +): + ip_empty.run_cell("%sql duckdb://") + ip_empty.run_cell("%sql create table languages (name VARCHAR, rating INTEGER)") + ip_empty.run_cell( + """%%sql +INSERT INTO languages VALUES ('Python', 1), ('Java', 0), ('OCaml', 2)""" + ) + + ip_empty.run_cell(sql_snippet) + + if raises: + with pytest.raises(UsageError) as _: + ip_empty.run_cell(sql_query) + else: + ip_empty.run_cell(sql_query) + + out, _ = capsys.readouterr() + assert expected_result in out + + +@pytest.mark.parametrize( + "query, query_type", + [ + ( + """ + CREATE TABLE penguins AS ( + WITH my_penguins AS ( + SELECT * FROM penguins.csv + ) + SELECT * FROM my_penguins + ) + """, + "CREATE", + ), + ( + """ + WITH my_penguins AS ( + SELECT * FROM penguins.csv + ) + SELECT * FROM my_penguins + """, + "SELECT", + ), + ( + """ + WITH my_penguins AS ( + SELECT * FROM penguins.csv + ) + * FROM my_penguins + """, + None, + ), + ], +) +def test_get_query_type(query, query_type): + assert get_query_type(query) == query_type + + +@pytest.mark.parametrize( + "query, expected", + [ + ( + "%sql select '{\"a\": 1}'::json -> 'a';", + 1, + ), + ( + '%sql select \'[{"b": "c"}]\'::json -> 0;', + {"b": "c"}, + ), + ( + "%sql select '{\"a\": 1}'::json ->> 'a';", + "1", + ), + ( + '%sql select \'[{"b": "c"}]\'::json ->> 0;', + '{"b":"c"}', + ), + ( + """%%sql select '{\"a\": 1}'::json + -> + 'a';""", + 1, + ), + ( + """%%sql select '[{\"b\": \"c\"}]'::json + -> + 0;""", + {"b": "c"}, + ), + ( + """%%sql select '{\"a\": 1}'::json + ->> + 'a';""", + "1", + ), + ( + """%%sql + select + \'[{"b": "c"}]\'::json + ->> + 0;""", + '{"b":"c"}', + ), + ( + "%sql SELECT '{\"a\": 1}'::json -> 'a';", + 1, + ), + ( + "%sql SELect '{\"a\": 1}'::json -> 'a';", + 1, + ), + ( + "%sql SELECT json('{\"a\": 1}') -> 'a';", + 1, + ), + ], + ids=[ + "single-key", + "single-index", + "double-key", + "double-index", + "single-key-multi-line", + "single-index-multi-line-tab", + "double-key-multi-line-space", + "double-index-multi-line", + "single-key-all-caps", + "single-key-mixed-caps", + "single-key-cast-parentheses", + ], +) +def test_json_arrow_operators(ip, query, expected): + ip.run_cell("%sql duckdb://") + result = ip.run_cell(query).result + result = list(result.dict().values())[0][0] + assert result == expected + + +@pytest.mark.parametrize( + "query_save, query_snippet, expected", + [ + ( + """%%sql --save snippet + select '{\"a\": 1}'::json -> 'a';""", + "%sql select * from snippet", + 1, + ), + ( + """%sql --save snippet select '[{\"b\": \"c\"}]'::json ->> 0;""", + "%sql select * from snippet", + '{"b":"c"}', + ), + ( + """%%sql --save snippet + select '[1, 2, 3]'::json + -> 2 + as number""", + "%sql select number from snippet", + 3, + ), + ], + ids=["cell-magic-key", "line-magic-index", "cell-magic-multi-line-as-column"], +) +def test_json_arrow_operators_with_snippets(ip, query_save, query_snippet, expected): + ip.run_cell("%sql duckdb://") + ip.run_cell(query_save) + result = ip.run_cell(query_snippet).result + result = list(result.dict().values())[0][0] + assert result == expected + + +@pytest.mark.parametrize( + "query, expected", + [ + ( + """%%sql +SELECT 1""", + 1, + ), + ( + """%%sql +SELECT 1 -- comment""", + 1, + ), + ( + """%%sql +SELECT 1 +-- comment""", + 1, + ), + ( + """%%sql +SELECT 1; -- comment""", + 1, + ), + ( + """%%sql +SELECT 1; +-- comment""", + 1, + ), + ( + """%%sql +-- comment before +SELECT 1;""", + 1, + ), + ( + """%%sql +-- comment before +SELECT 1; +-- comment after""", + 1, + ), + ( + """%%sql +SELECT 1; -- comment +SELECT 2""", + 2, + ), + ( + """%%sql +SELECT 1; -- comment +SELECT 2;""", + 2, + ), + ( + """%%sql +SELECT 1; +-- comment +SELECT 2;""", + 2, + ), + ( + """%%sql +SELECT 1; +-- comment before +SELECT 2; +-- comment after""", + 2, + ), + ( + """%%sql +SELECT 1; -- comment before +SELECT 2; +-- comment after""", + 2, + ), + ], +) +def test_query_comment_after_semicolon(ip, query, expected): + result = ip.run_cell(query).result + assert list(result.dict().values())[-1][0] == expected + + +@pytest.mark.parametrize( + "query, error_type, error_message", + [ + ( + """%%sql +SELECT * FROM snip; +SELECT * from temp;""", + "TableNotFoundError", + """If using snippets, you may pass the --with argument explicitly. +For more details please refer: \ +https://jupysql.ploomber.io/en/latest/compose.html#with-argument + +There is no table with name 'snip'. +Did you mean: 'snippet' + + +Original error message from DB driver: +(duckdb.duckdb.CatalogException) Catalog Error: Table with name snip does not exist! +Did you mean "temp"? +LINE 1: SELECT * FROM snip; + ^ +[SQL: SELECT * FROM snip;]""", + ), + ( + """%%sql +SELECT * FROM snippet; +SELECT * from tem;""", + "RuntimeError", + """If using snippets, you may pass the --with argument explicitly. +For more details please refer: \ +https://jupysql.ploomber.io/en/latest/compose.html#with-argument + + +Original error message from DB driver: +(duckdb.duckdb.CatalogException) Catalog Error: Table with name tem does not exist! +Did you mean "temp"? +LINE 1: SELECT * from tem; + ^ +[SQL: SELECT * from tem;]""", + ), + ( + """%%sql +SELECT * FROM snip; +SELECT * from tem;""", + "TableNotFoundError", + """If using snippets, you may pass the --with argument explicitly. +For more details please refer: \ +https://jupysql.ploomber.io/en/latest/compose.html#with-argument + +There is no table with name 'snip'. +Did you mean: 'snippet' + + +Original error message from DB driver: +(duckdb.duckdb.CatalogException) Catalog Error: Table with name snip does not exist! +Did you mean "temp"? +LINE 1: SELECT * FROM snip; + ^ +[SQL: SELECT * FROM snip;]""", + ), + ( + """%%sql +SELECT * FROM s; +SELECT * from temp;""", + "RuntimeError", + """If using snippets, you may pass the --with argument explicitly. +For more details please refer: \ +https://jupysql.ploomber.io/en/latest/compose.html#with-argument + + +Original error message from DB driver: +(duckdb.duckdb.CatalogException) Catalog Error: Table with name s does not exist! +Did you mean "temp"? +LINE 1: SELECT * FROM s; + ^ +[SQL: SELECT * FROM s;]""", + ), + ( + """%%sql +DROP TABLE temp; +SELECT * FROM snippet; +SELECT * from temp;""", + "RuntimeError", + """If using snippets, you may pass the --with argument explicitly. +For more details please refer: \ +https://jupysql.ploomber.io/en/latest/compose.html#with-argument + + +Original error message from DB driver: +(duckdb.duckdb.CatalogException) Catalog Error: Table with name snippet does not exist! +Did you mean "pg_type"? +LINE 1: SELECT * FROM snippet; + ^ +[SQL: SELECT * FROM snippet;]""", + ), + ], + ids=[ + "snippet-typo", + "table-typo", + "both-typo", + "snippet-typo-no-suggestion", + "no-typo-drop-table", + ], +) +def test_table_does_not_exist_with_snippet_error( + ip_empty, query, error_type, error_message +): + ip_empty.run_cell( + """%load_ext sql +%sql duckdb://""" + ) + # Create temp table + ip_empty.run_cell( + """%%sql +CREATE TABLE temp AS +SELECT * FROM penguins.csv""" + ) + + # Create snippet + ip_empty.run_cell( + """%%sql --save snippet +SELECT * FROM penguins.csv;""" + ) + + # Run query + with pytest.raises(Exception) as excinfo: + ip_empty.run_cell(query) + + # Test error and message + assert error_type == excinfo.value.error_type + assert error_message in str(excinfo.value) + + +@pytest.mark.parametrize( + "query, expected", + [ + ("%sql select 5 * -2", (-10,)), + ("%sql select 5 * - 2", (-10,)), + ("%sql select 5 * -2;", (-10,)), + ("%sql select -5 * 2;", (-10,)), + ("%sql select 5 * -2 ;", (-10,)), + ("%sql select 5 * - 2;", (-10,)), + ("%sql select x * -2 from number_table", (-8, 10, -4, 0, 10, 4, 4, 8, -4, -8)), + ("%sql select x *-2 from number_table", (-8, 10, -4, 0, 10, 4, 4, 8, -4, -8)), + ("%sql select x * - 2 from number_table", (-8, 10, -4, 0, 10, 4, 4, 8, -4, -8)), + ("%sql select x *- 2 from number_table", (-8, 10, -4, 0, 10, 4, 4, 8, -4, -8)), + ("%sql select -x * 2 from number_table", (-8, 10, -4, 0, 10, 4, 4, 8, -4, -8)), + ("%sql select - x * 2 from number_table", (-8, 10, -4, 0, 10, 4, 4, 8, -4, -8)), + ("%sql select - x* 2 from number_table", (-8, 10, -4, 0, 10, 4, 4, 8, -4, -8)), + ], +) +def test_negative_operations_query(ip, query, expected): + result = ip.run_cell(query).result + assert list(result.dict().values())[-1] == expected + + +def test_bracket_var_substitution_save(ip): + ip.user_global_ns["col"] = "first_name" + ip.user_global_ns["snippet"] = "mysnippet" + ip.run_cell( + "%sql --save {{snippet}} SELECT * FROM author WHERE {{col}} = 'William' " + ) + out = ip.run_cell("%sql SELECT * FROM {{snippet}}").result + assert out[0] == ( + "William", + "Shakespeare", + 1616, + ) + + +def test_var_substitution_save_with(ip): + ip.user_global_ns["col"] = "first_name" + ip.user_global_ns["snippet_one"] = "william" + ip.user_global_ns["snippet_two"] = "bertold" + ip.run_cell( + "%sql --save {{snippet_one}} SELECT * FROM author WHERE {{col}} = 'William' " + ) + ip.run_cell( + "%sql --save {{snippet_two}} SELECT * FROM author WHERE {{col}} = 'Bertold' " + ) + out = ip.run_cell( + """%%sql --with {{snippet_one}} --with {{snippet_two}} +SELECT * FROM {{snippet_one}} +UNION +SELECT * FROM {{snippet_two}} +""" + ).result + + assert out[1] == ( + "William", + "Shakespeare", + 1616, + ) + assert out[0] == ( + "Bertold", + "Brecht", + 1956, + ) + + +def test_var_substitution_alias(clean_conns, ip_empty, tmp_empty): + ip_empty.user_global_ns["alias"] = "one" + ip_empty.run_cell("%sql sqlite:///one.db --alias {{alias}}") + assert {"one"} == set(ConnectionManager.connections) + + +@pytest.mark.parametrize( + "close_cell", + [ + "%sql -x {{alias}}", + "%sql --close {{alias}}", + ], +) +def test_var_substitution_close_connection_with_alias(ip, tmp_empty, close_cell): + ip.user_global_ns["alias"] = "one" + process = psutil.Process() + + ip.run_cell("%sql sqlite:///one.db --alias {{alias}}") + + assert {Path(f.path).name for f in process.open_files()} >= {"one.db"} + + ip.run_cell(close_cell) + + assert "sqlite:///one.db" not in ConnectionManager.connections + assert "first" not in ConnectionManager.connections + assert "one.db" not in {Path(f.path).name for f in process.open_files()} + + +def test_var_substitution_section(ip_empty, tmp_empty): + Path("connections.ini").write_text( + """ +[duck] +drivername = duckdb +""" + ) + ip_empty.user_global_ns["section"] = "duck" + + ip_empty.run_cell("%config SqlMagic.dsn_filename = 'connections.ini'") + + ip_empty.run_cell("%sql --section {{section}}") + + conns = ConnectionManager.connections + assert conns == {"duck": ANY} + + +@pytest.mark.parametrize( + "query, expected", + [ + ( + '%sql select json(\'[{"a":1}, {"b":2}]\')', + "[{'a': 1}, {'b': 2}]", + ), + ( + '%sql select \'[{"a":1}, {"b":2}]\'::json', + "[{'a': 1}, {'b': 2}]", + ), + ], +) +def test_disable_named_parameters_with_json(ip, query, expected): + ip.run_cell("%sql duckdb://") + ip.run_cell("%config SqlMagic.named_parameters='disabled'") + result = ip.run_cell(query).result + assert str(list(result.dict().values())[0][0]) == expected + + +def test_disabled_named_parameters_shows_disabled_warning(ip): + ip.run_cell("%config SqlMagic.named_parameters='disabled'") + query_should_warn = "%sql select json('[{\"a\"::1}')" + + with pytest.raises(UsageError) as excinfo: + ip.run_cell(query_should_warn) + + expected_warning = ( + 'The named parameters feature is "disabled". ' + 'Enable it with: %config SqlMagic.named_parameters="enabled".\n' + "For more info, see the docs: " + "https://jupysql.ploomber.io/en/latest/api/configuration.html" + ) + + assert expected_warning in str(excinfo.value) diff --git a/src/tests/test_magic_cmd.py b/src/tests/test_magic_cmd.py new file mode 100644 index 000000000..c2550b33b --- /dev/null +++ b/src/tests/test_magic_cmd.py @@ -0,0 +1,1130 @@ +import math +import pytest +from IPython.core.error import UsageError +from pathlib import Path + +from sqlalchemy import create_engine +from sql.connection import SQLAlchemyConnection +from sql.inspect import _is_numeric +from sql.display import Table, Message +from sql.widgets import TableWidget +from jupysql_plugin.widgets import ConnectorWidget +import duckdb +import sqlite3 + + +VALID_COMMANDS_MESSAGE = ( + "Valid commands are: tables, columns, test, profile, explore, snippets, connect" +) + + +def _get_row_string(row, column_name): + """ + Helper function to retrieve the string value of a specific column in a table row. + + Parameters + ---------- + row: PrettyTable row object. + column_name: Name of the column. + + Returns: + String value of the specified column in the row. + """ + return row.get_string(fields=[column_name], border=False, header=False).strip() + + +@pytest.fixture +def ip_snippets(ip): + ip.run_cell("%sql sqlite://") + ip.run_cell( + """ + %%sql --save high_price --no-execute +SELECT * +FROM "test_store" +WHERE price >= 1.50 +""" + ) + ip.run_cell( + """ + %%sql --save high_price_a --no-execute +SELECT * +FROM "high_price" +WHERE symbol == 'a' +""" + ) + ip.run_cell( + """ + %%sql --save high_price_b --no-execute +SELECT * +FROM "high_price" +WHERE symbol == 'b' +""" + ) + yield ip + + +@pytest.fixture +def ip_with_connections(ip_empty): + ip_empty.run_cell("%sql duckdb:// --alias duckdb_sqlalchemy") + ip_empty.run_cell("%sql sqlite:// --alias sqlite_sqlalchemy") + duckdb_dbapi = duckdb.connect("") + sqlite_dbapi = sqlite3.connect("") + + ip_empty.push({"duckdb_dbapi": duckdb_dbapi}) + ip_empty.push({"sqlite_dbapi": sqlite_dbapi}) + + yield ip_empty + + +@pytest.fixture +def test_snippet_ip(ip): + ip.run_cell("%sql sqlite://") + yield ip + + +@pytest.fixture +def sample_schema_with_table(ip_empty): + ip_empty.run_cell("%sql duckdb://") + ip_empty.run_cell( + """%%sql +CREATE SCHEMA schema1; +CREATE TABLE schema1.table1 (x INT, y TEXT); +INSERT INTO schema1.table1 VALUES (1, 'one'); +INSERT INTO schema1.table1 VALUES (2, 'two'); +""" + ) + + +@pytest.mark.parametrize( + "cmd, cols, table_name", + [ + [ + "%sqlcmd columns -t {{table}}", + ["first_name", "last_name", "year_of_death"], + "author", + ], + ["%sqlcmd columns -t {{table}}", ["first", "second"], "table with spaces"], + ["%sqlcmd columns -t {{table}}", ["first", "second"], "table with spaces"], + ], +) +def test_columns_with_variable_substitution(ip, cmd, cols, table_name): + ip.user_global_ns["table"] = table_name + out = ip.run_cell(cmd).result._repr_html_() + assert all(col in out for col in cols) + + +@pytest.mark.parametrize( + "cell, error_message", + [ + [ + "%sqlcmd", + "Missing argument for %sqlcmd. " f"{VALID_COMMANDS_MESSAGE}", + ], + [ + "%sqlcmd ", + "Missing argument for %sqlcmd. " f"{VALID_COMMANDS_MESSAGE}", + ], + [ + "%sqlcmd ", + "Missing argument for %sqlcmd. " f"{VALID_COMMANDS_MESSAGE}", + ], + [ + "%sqlcmd ", + "Missing argument for %sqlcmd. " f"{VALID_COMMANDS_MESSAGE}", + ], + [ + "%sqlcmd stuff", + "%sqlcmd has no command: 'stuff'. " f"{VALID_COMMANDS_MESSAGE}", + ], + [ + "%sqlcmd columns", + "the following arguments are required: -t/--table", + ], + ], +) +def test_error(tmp_empty, ip, cell, error_message): + with pytest.raises(UsageError) as excinfo: + ip.run_cell(cell) + + assert excinfo.value.error_type == "UsageError" + assert str(excinfo.value) == error_message + + +@pytest.mark.parametrize( + "command", + [ + "tables", + "columns", + "test", + "profile", + "explore", + ], +) +def test_sqlcmd_error_when_no_connection(ip_empty, command): + with pytest.raises(UsageError) as excinfo: + ip_empty.run_cell(f"%sqlcmd {command}") + + assert excinfo.value.error_type == "RuntimeError" + assert str(excinfo.value) == ( + f"Cannot execute %sqlcmd {command} because there is no " + "active connection. Connect to a database and try again." + ) + + +def test_sqlcmd_snippets_when_no_connection(ip_empty, capsys): + ip_empty.run_cell("%sqlcmd snippets") + captured = capsys.readouterr() + assert "No snippets stored" in captured.out + + +@pytest.mark.parametrize( + "query, command", + [ + ("%sqlcmd tables", "tables"), + ("%sqlcmd columns --table penguins.csv", "columns"), + ( + "%sqlcmd test --table penguins.csv --column body_mass_g --greater 2900", + "test", + ), + ("%sqlcmd explore --table penguins.csv", "explore"), + ], +) +def test_sqlcmd_not_supported_error(ip_with_connections, query, command, capsys): + ip_with_connections.run_cell("%sql duckdb_dbapi") + expected_error_message = ( + f"%sqlcmd {command} is only supported with SQLAlchemy connections, " + "not with DBAPI connections" + ) + with pytest.raises(UsageError) as excinfo: + ip_with_connections.run_cell(query) + + assert expected_error_message in str(excinfo.value) + + +def test_tables(ip): + out = ip.run_cell("%sqlcmd tables").result._repr_html_() + assert "author" in out + assert "empty_table" in out + assert "test" in out + + +def test_tables_with_schema(ip, tmp_empty): + conn = SQLAlchemyConnection(engine=create_engine("sqlite:///my.db")) + conn.execute("CREATE TABLE numbers (some_number FLOAT)") + + ip.run_cell( + """%%sql +ATTACH DATABASE 'my.db' AS some_schema +""" + ) + + out = ip.run_cell("%sqlcmd tables --schema some_schema").result._repr_html_() + + assert "numbers" in out + + +def test_tables_with_schema_variable_substitution(ip, tmp_empty): + conn = SQLAlchemyConnection(engine=create_engine("sqlite:///my.db")) + conn.execute("CREATE TABLE numbers (some_number FLOAT)") + + ip.user_global_ns["schema"] = "some_schema" + + ip.run_cell( + """%%sql +ATTACH DATABASE 'my.db' AS {{schema}} +""" + ) + + out = ip.run_cell("%sqlcmd tables --schema {{schema}}").result._repr_html_() + + assert "numbers" in out + + +@pytest.mark.parametrize( + "cmd, cols", + [ + ["%sqlcmd columns -t author", ["first_name", "last_name", "year_of_death"]], + [ + "%sqlcmd columns -t 'table with spaces'", + ["first", "second"], + ], + [ + '%sqlcmd columns -t "table with spaces"', + ["first", "second"], + ], + ], +) +def test_columns(ip, cmd, cols): + out = ip.run_cell(cmd).result._repr_html_() + assert all(col in out for col in cols) + + +@pytest.mark.parametrize( + "arguments", ["--table numbers --schema some_schema", "--table some_schema.numbers"] +) +def test_columns_with_schema(ip, tmp_empty, arguments): + conn = SQLAlchemyConnection(engine=create_engine("sqlite:///my.db")) + conn.execute("CREATE TABLE numbers (some_number FLOAT)") + + ip.run_cell( + """%%sql +ATTACH DATABASE 'my.db' AS some_schema +""" + ) + + out = ip.run_cell(f"%sqlcmd columns {arguments}").result._repr_html_() + + assert "some_number" in out + + +@pytest.mark.parametrize( + "arguments", + ["--table {{table}} --schema {{schema}}", "--table {{schema}}.{{table}}"], +) +def test_columns_with_schema_variable_substitution(ip, tmp_empty, arguments): + conn = SQLAlchemyConnection(engine=create_engine("sqlite:///my.db")) + conn.execute("CREATE TABLE numbers (some_number FLOAT)") + + ip.user_global_ns["table"] = "numbers" + ip.user_global_ns["schema"] = "some_schema" + + ip.run_cell( + """%%sql +ATTACH DATABASE 'my.db' AS {{schema}} +""" + ) + + out = ip.run_cell(f"%sqlcmd columns {arguments}").result._repr_html_() + + assert "some_number" in out + + +@pytest.mark.parametrize( + "conn", + [ + ("sqlite_sqlalchemy"), + ("sqlite_dbapi"), + ], +) +def test_table_profile(ip_with_connections, tmp_empty, conn): + ip_with_connections.run_cell(f"%sql {conn}") + ip_with_connections.run_cell( + """ + %%sql + CREATE TABLE numbers (rating float, price float, number int, word varchar(50)); + INSERT INTO numbers VALUES (14.44, 2.48, 82, 'a'); + INSERT INTO numbers VALUES (13.13, 1.50, 93, 'b'); + INSERT INTO numbers VALUES (12.59, 0.20, 98, 'a'); + INSERT INTO numbers VALUES (11.54, 0.41, 89, 'a'); + INSERT INTO numbers VALUES (10.532, 0.1, 88, 'c'); + INSERT INTO numbers VALUES (11.5, 0.2, 84, ' '); + INSERT INTO numbers VALUES (11.1, 0.3, 90, 'a'); + INSERT INTO numbers VALUES (12.9, 0.31, 86, ''); + """ + ) + + expected = { + "count": [8, 8, 8, 8], + "mean": ["12.2165", "0.6875", "88.7500", math.nan], + "min": [10.532, 0.1, 82, math.nan], + "max": [14.44, 2.48, 98, math.nan], + "unique": [8, 7, 8, 5], + "freq": [math.nan, math.nan, math.nan, 4], + "top": [math.nan, math.nan, math.nan, "a"], + } + + out = ip_with_connections.run_cell("%sqlcmd profile -t numbers").result + + stats_table = out._table + + assert len(stats_table.rows) == len(expected) + + for row in stats_table: + profile_metric = _get_row_string(row, " ") + rating = _get_row_string(row, "rating") + price = _get_row_string(row, "price") + number = _get_row_string(row, "number") + word = _get_row_string(row, "word") + + assert profile_metric in expected + assert rating == str(expected[profile_metric][0]) + assert price == str(expected[profile_metric][1]) + assert number == str(expected[profile_metric][2]) + assert word == str(expected[profile_metric][3]) + + # Test sticky column style was injected + assert "position: sticky;" in out._table_html + + +@pytest.mark.parametrize( + "conn", + [ + ("sqlite_sqlalchemy"), + ("sqlite_dbapi"), + ], +) +def test_table_profile_with_substitution(ip_with_connections, tmp_empty, conn): + ip_with_connections.run_cell(f"%sql {conn}") + ip_with_connections.run_cell( + """ + %%sql + CREATE TABLE numbers (rating float, price float, number int, word varchar(50)); + INSERT INTO numbers VALUES (14.44, 2.48, 82, 'a'); + INSERT INTO numbers VALUES (13.13, 1.50, 93, 'b'); + INSERT INTO numbers VALUES (12.59, 0.20, 98, 'a'); + INSERT INTO numbers VALUES (11.54, 0.41, 89, 'a'); + INSERT INTO numbers VALUES (10.532, 0.1, 88, 'c'); + INSERT INTO numbers VALUES (11.5, 0.2, 84, ' '); + INSERT INTO numbers VALUES (11.1, 0.3, 90, 'a'); + INSERT INTO numbers VALUES (12.9, 0.31, 86, ''); + """ + ) + + expected = { + "count": [8, 8, 8, 8], + "mean": ["12.2165", "0.6875", "88.7500", math.nan], + "min": [10.532, 0.1, 82, math.nan], + "max": [14.44, 2.48, 98, math.nan], + "unique": [8, 7, 8, 5], + "freq": [math.nan, math.nan, math.nan, 4], + "top": [math.nan, math.nan, math.nan, "a"], + } + + ip_with_connections.user_global_ns["table"] = "numbers" + + out = ip_with_connections.run_cell("%sqlcmd profile -t {{table}}").result + + stats_table = out._table + + assert len(stats_table.rows) == len(expected) + + for row in stats_table: + profile_metric = _get_row_string(row, " ") + rating = _get_row_string(row, "rating") + price = _get_row_string(row, "price") + number = _get_row_string(row, "number") + word = _get_row_string(row, "word") + + assert profile_metric in expected + assert rating == str(expected[profile_metric][0]) + assert price == str(expected[profile_metric][1]) + assert number == str(expected[profile_metric][2]) + assert word == str(expected[profile_metric][3]) + + # Test sticky column style was injected + assert "position: sticky;" in out._table_html + + +@pytest.mark.parametrize( + "conn", + [ + ("duckdb_sqlalchemy"), + ("duckdb_dbapi"), + ], +) +def test_table_profile_with_stdev(ip_with_connections, tmp_empty, conn): + ip_with_connections.run_cell(f"%sql {conn}") + ip_with_connections.run_cell( + """ + %%sql + CREATE TABLE numbers (rating float, price float, number int, word varchar(50)); + INSERT INTO numbers VALUES (14.44, 2.48, 82, 'a'); + INSERT INTO numbers VALUES (13.13, 1.50, 93, 'b'); + INSERT INTO numbers VALUES (12.59, 0.20, 98, 'a'); + INSERT INTO numbers VALUES (11.54, 0.41, 89, 'a'); + INSERT INTO numbers VALUES (10.532, 0.1, 88, 'c'); + INSERT INTO numbers VALUES (11.5, 0.2, 84, ' '); + INSERT INTO numbers VALUES (11.1, 0.3, 90, 'a'); + INSERT INTO numbers VALUES (12.9, 0.31, 86, ''); + """ + ) + + expected = { + "count": [8, 8, 8, 8], + "mean": ["12.2165", "0.6875", "88.7500", math.nan], + "min": [10.532, 0.1, 82, math.nan], + "max": [14.44, 2.48, 98, math.nan], + "unique": [8, 7, 8, 5], + "freq": [math.nan, math.nan, math.nan, 4], + "top": [math.nan, math.nan, math.nan, "a"], + "std": ["1.1958", "0.7956", "4.7631", math.nan], + "25%": ["11.1000", "0.2000", "84.0000", math.nan], + "50%": ["11.5400", "0.3000", "88.0000", math.nan], + "75%": ["12.9000", "0.4100", "90.0000", math.nan], + } + + out = ip_with_connections.run_cell("%sqlcmd profile -t numbers").result + + stats_table = out._table + + assert len(stats_table.rows) == len(expected) + + for row in stats_table: + profile_metric = _get_row_string(row, " ") + rating = _get_row_string(row, "rating") + price = _get_row_string(row, "price") + number = _get_row_string(row, "number") + word = _get_row_string(row, "word") + + assert profile_metric in expected + assert rating == str(expected[profile_metric][0]) + assert price == str(expected[profile_metric][1]) + assert number == str(expected[profile_metric][2]) + assert word == str(expected[profile_metric][3]) + + # Test sticky column style was injected + assert "position: sticky;" in out._table_html + + +@pytest.mark.parametrize( + "arguments", ["--table t --schema b_schema", "--table b_schema.t"] +) +def test_table_schema_profile(ip, tmp_empty, arguments): + ip.run_cell("%sql sqlite:///a.db") + ip.run_cell("%sql CREATE TABLE t (n FLOAT)") + ip.run_cell("%sql INSERT INTO t VALUES (1)") + ip.run_cell("%sql INSERT INTO t VALUES (2)") + ip.run_cell("%sql INSERT INTO t VALUES (3)") + ip.run_cell("%sql --close sqlite:///a.db") + + ip.run_cell("%sql sqlite:///b.db") + ip.run_cell("%sql CREATE TABLE t (n FLOAT)") + ip.run_cell("%sql INSERT INTO t VALUES (11)") + ip.run_cell("%sql INSERT INTO t VALUES (22)") + ip.run_cell("%sql INSERT INTO t VALUES (33)") + ip.run_cell("%sql --close sqlite:///b.db") + + ip.run_cell( + """ + %%sql sqlite:// + ATTACH DATABASE 'a.db' AS a_schema; + ATTACH DATABASE 'b.db' AS b_schema; + """ + ) + + expected = { + "count": ["3"], + "mean": ["22.0000"], + "min": ["11.0"], + "max": ["33.0"], + "std": ["11.0000"], + "unique": ["3"], + "freq": [math.nan], + "top": [math.nan], + } + + out = ip.run_cell(f"%sqlcmd profile {arguments}").result + + stats_table = out._table + + for row in stats_table: + profile_metric = _get_row_string(row, " ") + + cell = row.get_string(fields=["n"], border=False, header=False).strip() + + if profile_metric in expected: + assert cell == str(expected[profile_metric][0]) + + +@pytest.mark.parametrize( + "arguments", + ["--table {{table}} --schema {{schema}}", "--table {{schema}}.{{table}}"], +) +def test_table_schema_profile_with_substitution(ip, tmp_empty, arguments): + ip.run_cell("%sql sqlite:///a.db") + ip.run_cell("%sql CREATE TABLE t (n FLOAT)") + ip.run_cell("%sql INSERT INTO t VALUES (1)") + ip.run_cell("%sql INSERT INTO t VALUES (2)") + ip.run_cell("%sql INSERT INTO t VALUES (3)") + ip.run_cell("%sql --close sqlite:///a.db") + + ip.run_cell("%sql sqlite:///b.db") + ip.run_cell("%sql CREATE TABLE t (n FLOAT)") + ip.run_cell("%sql INSERT INTO t VALUES (11)") + ip.run_cell("%sql INSERT INTO t VALUES (22)") + ip.run_cell("%sql INSERT INTO t VALUES (33)") + ip.run_cell("%sql --close sqlite:///b.db") + + ip.run_cell( + """ + %%sql sqlite:// + ATTACH DATABASE 'a.db' AS a_schema; + ATTACH DATABASE 'b.db' AS b_schema; + """ + ) + + expected = { + "count": ["3"], + "mean": ["22.0000"], + "min": ["11.0"], + "max": ["33.0"], + "std": ["11.0000"], + "unique": ["3"], + "freq": [math.nan], + "top": [math.nan], + } + + ip.user_global_ns["table"] = "t" + ip.user_global_ns["schema"] = "b_schema" + out = ip.run_cell(f"%sqlcmd profile {arguments}").result + + stats_table = out._table + + for row in stats_table: + profile_metric = _get_row_string(row, " ") + + cell = row.get_string(fields=["n"], border=False, header=False).strip() + + if profile_metric in expected: + assert cell == str(expected[profile_metric][0]) + + +@pytest.mark.parametrize( + "arguments", + ["--table sample_table --schema test_schema", "--table test_schema.sample_table"], +) +def test_sqlcmd_profile_with_schema_argument_and_dbapi(ip_empty, tmp_empty, arguments): + sqlite_dbapi_testdb_conn = sqlite3.connect("test.db") + ip_empty.push({"sqlite_dbapi_testdb_conn": sqlite_dbapi_testdb_conn}) + + ip_empty.run_cell( + """%%sql sqlite_dbapi_testdb_conn +CREATE TABLE sample_table (n FLOAT); +INSERT INTO sample_table VALUES (11); +INSERT INTO sample_table VALUES (22); +INSERT INTO sample_table VALUES (33); +""" + ) + + ip_empty.run_cell( + """ + %%sql sqlite_dbapi_testdb_conn + ATTACH DATABASE 'test.db' AS test_schema; + """ + ) + + expected = { + "count": ["3"], + "mean": ["22.0000"], + "min": ["11.0"], + "max": ["33.0"], + "std": ["11.0000"], + "unique": ["3"], + "freq": [math.nan], + "top": [math.nan], + } + + out = ip_empty.run_cell(f"%sqlcmd profile {arguments}").result + + stats_table = out._table + + for row in stats_table: + profile_metric = _get_row_string(row, " ") + + cell = row.get_string(fields=["n"], border=False, header=False).strip() + + if profile_metric in expected: + assert cell == str(expected[profile_metric][0]) + + +@pytest.mark.parametrize( + "conn", + [ + ("sqlite_sqlalchemy"), + ("sqlite_dbapi"), + ], +) +def test_table_profile_warnings_styles(ip_with_connections, tmp_empty, conn): + ip_with_connections.run_cell( + f""" + %%sql {conn} + CREATE TABLE numbers (rating float,price varchar(50),number int,word varchar(50)); + INSERT INTO numbers VALUES (14.44, '2.48', 82, 'a'); + INSERT INTO numbers VALUES (13.13, '1.50', 93, 'b'); + """ + ) + out = ip_with_connections.run_cell("%sqlcmd profile -t numbers").result + stats_table_html = out._table_html + assert "Columns price have a datatype mismatch" in stats_table_html + assert "td:nth-child(3)" in stats_table_html + assert "Following statistics are not available in" in stats_table_html + + +def test_profile_is_numeric(): + assert _is_numeric("123") is True + assert _is_numeric(None) is False + assert _is_numeric("abc") is False + assert _is_numeric("45.6") is True + assert _is_numeric(100) is True + assert _is_numeric(True) is False + assert _is_numeric("NaN") is True + assert _is_numeric(math.nan) is True + + +@pytest.mark.parametrize( + "conn", + [ + ("sqlite_sqlalchemy"), + ("sqlite_dbapi"), + ], +) +def test_table_profile_is_numeric(ip_with_connections, tmp_empty, conn): + ip_with_connections.run_cell( + f""" + %%sql {conn} + CREATE TABLE people (name varchar(50),age varchar(50),number int, + country varchar(50),gender_1 varchar(50), gender_2 varchar(50)); + INSERT INTO people VALUES ('joe', '48', 82, 'usa', '0', 'male'); + INSERT INTO people VALUES ('paula', '50', 93, 'uk', '1', 'female'); + """ + ) + out = ip_with_connections.run_cell("%sqlcmd profile -t people").result + stats_table_html = out._table_html + assert "td:nth-child(3)" in stats_table_html + assert "td:nth-child(6)" in stats_table_html + assert "td:nth-child(7)" not in stats_table_html + assert "td:nth-child(4)" not in stats_table_html + assert ( + "Columns agegender_1 have a datatype mismatch" + in stats_table_html + ) + + +@pytest.mark.parametrize( + "conn, report_fname", + [ + ("sqlite_sqlalchemy", "test_report.html"), + ("sqlite_dbapi", "test_report_dbapi.html"), + ], +) +def test_table_profile_store(ip_with_connections, tmp_empty, conn, report_fname): + ip_with_connections.run_cell( + f""" + %%sql {conn} + CREATE TABLE test_store (rating, price, number, symbol); + INSERT INTO test_store VALUES (14.44, 2.48, 82, 'a'); + INSERT INTO test_store VALUES (13.13, 1.50, 93, 'b'); + INSERT INTO test_store VALUES (12.59, 0.20, 98, 'a'); + INSERT INTO test_store VALUES (11.54, 0.41, 89, 'a'); + """ + ) + + ip_with_connections.run_cell( + f"%sqlcmd profile -t test_store --output {report_fname}" + ) + + report = Path(report_fname) + assert report.is_file() + + +@pytest.mark.parametrize( + "conn, report_fname", + [ + ("sqlite_sqlalchemy", "test_report.html"), + ("sqlite_dbapi", "test_report_dbapi.html"), + ], +) +def test_table_profile_store_with_substitution( + ip_with_connections, tmp_empty, conn, report_fname +): + ip_with_connections.run_cell( + f""" + %%sql {conn} + CREATE TABLE test_store (rating, price, number, symbol); + INSERT INTO test_store VALUES (14.44, 2.48, 82, 'a'); + INSERT INTO test_store VALUES (13.13, 1.50, 93, 'b'); + INSERT INTO test_store VALUES (12.59, 0.20, 98, 'a'); + INSERT INTO test_store VALUES (11.54, 0.41, 89, 'a'); + """ + ) + ip_with_connections.user_global_ns["table"] = "test_store" + ip_with_connections.user_global_ns["output"] = report_fname + + ip_with_connections.run_cell("%sqlcmd profile -t {{table}} --output {{output}}") + + report = Path(report_fname) + assert report.is_file() + + +@pytest.mark.parametrize( + "cell, error_message", + [ + [ + "%sqlcmd test -t test_numbers", + "Please use a valid comparator.", + ], + [ + "%sqlcmd test --t test_numbers --greater 12", + "Please pass a column to test.", + ], + [ + "%sqlcmd test --table test_numbers --column something --greater 100", + "Referenced column 'something' not found!", + ], + ], + ids=[ + "no_comparator", + "no_column", + "no_column_name", + ], +) +def test_test_error(ip, cell, error_message): + ip.run_cell( + """ + %%sql sqlite:// + CREATE TABLE test_numbers (value); + INSERT INTO test_numbers VALUES (14); + INSERT INTO test_numbers VALUES (13); + INSERT INTO test_numbers VALUES (12); + INSERT INTO test_numbers VALUES (11); + """ + ) + + with pytest.raises(UsageError) as excinfo: + ip.run_cell(cell) + + assert excinfo.value.error_type == "UsageError" + assert str(excinfo.value) == error_message + + +@pytest.mark.parametrize( + "arguments", ["--table schema1.table1", "--table table1 --schema schema1"] +) +def test_failing_test_with_schema(ip_empty, sample_schema_with_table, arguments): + expected_error_message = "The above values do not match your test requirements." + + with pytest.raises(UsageError) as excinfo: + ip_empty.run_cell(f"%sqlcmd test {arguments} --column x --less-than 2") + + assert expected_error_message in str(excinfo.value) + + +@pytest.mark.parametrize( + "arguments", ["--table schema1.table1", "--table table1 --schema schema1"] +) +def test_passing_test_with_schema(ip_empty, sample_schema_with_table, arguments): + out = ip_empty.run_cell(f"%sqlcmd test {arguments} --column x --less-than 3").result + assert out is True + + +@pytest.mark.parametrize( + "arguments", + ["--table {{schema}}.{{table}}", "--table {{table}} --schema {{schema}}"], +) +def test_test_with_schema_variable_substitution( + ip_empty, sample_schema_with_table, arguments +): + ip_empty.user_global_ns["table"] = "table1" + ip_empty.user_global_ns["schema"] = "schema1" + out = ip_empty.run_cell(f"%sqlcmd test {arguments} --column x --less-than 3").result + assert out is True + + +def test_test_column_variable_substitution(ip_empty, sample_schema_with_table): + ip_empty.user_global_ns["column"] = "x" + out = ip_empty.run_cell( + "%sqlcmd test --table schema1.table1 --column {{column}} --less-than 3" + ).result + assert out is True + + +@pytest.mark.parametrize( + "cmds, result", + [ + (["%sqlcmd snippets"], Message("No snippets stored")), + ( + [ + """%%sql --save test_snippet --no-execute +SELECT * FROM "test_store" WHERE price >= 1.50 +""", + "%sqlcmd snippets", + ], + Table( + ["Stored snippets"], + [["test_snippet"]], + ), + ), + ( + [ + """%%sql --save test_snippet --no-execute +SELECT * FROM "test_store" WHERE price >= 1.50 +""", + """%%sql --save test_snippet_a --no-execute +SELECT * FROM "test_snippet" WHERE symbol == 'a' +""", + "%sqlcmd snippets", + ], + Table( + ["Stored snippets"], + [["test_snippet"], ["test_snippet_a"]], + ), + ), + ( + [ + """%%sql --save test_snippet --no-execute +SELECT * FROM "test_store" WHERE price >= 1.50 +""", + """%%sql --save test_snippet_a --no-execute +SELECT * FROM "test_snippet" WHERE symbol == 'a' +""", + """%%sql --save test_snippet_b --no-execute +SELECT * FROM "test_snippet" WHERE symbol == 'b' +""", + "%sqlcmd snippets", + ], + Table( + ["Stored snippets"], + [["test_snippet"], ["test_snippet_a"], ["test_snippet_b"]], + ), + ), + ], +) +def test_snippet(test_snippet_ip, cmds, result): + out = [test_snippet_ip.run_cell(cmd) for cmd in cmds][-1].result + assert str(out) == str(result) + assert isinstance(out, type(result)) + + +@pytest.mark.parametrize( + "precmd, cmd, err_msg", + [ + ( + None, + "%sqlcmd snippets invalid", + ( + "'invalid' is not a snippet. Available snippets are 'high_price', " + "'high_price_a', and 'high_price_b'." + ), + ), + ( + "%sqlcmd snippets -d high_price_b", + "%sqlcmd snippets invalid", + ( + "'invalid' is not a snippet. Available snippets are 'high_price', " + "and 'high_price_a'." + ), + ), + ( + "%sqlcmd snippets -A high_price", + "%sqlcmd snippets invalid", + "'invalid' is not a snippet. There is no available snippet.", + ), + ], +) +def test_invalid_snippet(ip_snippets, precmd, cmd, err_msg): + if precmd: + ip_snippets.run_cell(precmd) + + with pytest.raises(UsageError) as excinfo: + ip_snippets.run_cell(cmd) + + assert excinfo.value.error_type == "UsageError" + assert str(excinfo.value) == err_msg + + +@pytest.mark.parametrize("arg", ["--delete", "-d"]) +def test_delete_saved_key(ip_snippets, arg): + out = ip_snippets.run_cell(f"%sqlcmd snippets {arg} high_price_a").result + assert "high_price_a has been deleted.\n" in out + stored_snippets = out[out.find("Stored snippets") + len("Stored snippets: ") :] + assert "high_price, high_price_b" in stored_snippets + assert "high_price_a" not in stored_snippets + + +def test_delete_saved_key_with_variable_substitution(ip_snippets): + ip_snippets.user_global_ns["snippet_name"] = "high_price_a" + out = ip_snippets.run_cell("%sqlcmd snippets --delete {{snippet_name}}").result + assert "high_price_a has been deleted.\n" in out + stored_snippets = out[out.find("Stored snippets") + len("Stored snippets: ") :] + assert "high_price, high_price_b" in stored_snippets + assert "high_price_a" not in stored_snippets + + +@pytest.mark.parametrize("arg", ["--delete-force", "-D"]) +def test_force_delete(ip_snippets, arg): + out = ip_snippets.run_cell(f"%sqlcmd snippets {arg} high_price").result + assert ( + "high_price has been deleted.\nhigh_price_a, " + "high_price_b depend on high_price\n" in out + ) + stored_snippets = out[out.find("Stored snippets") + len("Stored snippets: ") :] + assert "high_price_a, high_price_b" in stored_snippets + assert "high_price," not in stored_snippets + + +def test_force_delete_with_variable_substitution(ip_snippets): + ip_snippets.user_global_ns["snippet_name"] = "high_price" + out = ip_snippets.run_cell( + "%sqlcmd snippets --delete-force {{snippet_name}}" + ).result + assert ( + "high_price has been deleted.\nhigh_price_a, " + "high_price_b depend on high_price\n" in out + ) + stored_snippets = out[out.find("Stored snippets") + len("Stored snippets: ") :] + assert "high_price_a, high_price_b" in stored_snippets + assert "high_price," not in stored_snippets + + +@pytest.mark.parametrize("arg", ["--delete-force-all", "-A"]) +def test_force_delete_all(ip_snippets, arg): + out = ip_snippets.run_cell(f"%sqlcmd snippets {arg} high_price").result + assert "high_price_a, high_price_b, high_price has been deleted" in out + assert "There are no stored snippets" in out + + +def test_force_delete_all_with_variable_substitution(ip_snippets): + ip_snippets.user_global_ns["snippet_name"] = "high_price" + out = ip_snippets.run_cell( + "%sqlcmd snippets --delete-force-all {{snippet_name}}" + ).result + assert "high_price_a, high_price_b, high_price has been deleted" in out + assert "There are no stored snippets" in out + + +@pytest.mark.parametrize("arg", ["--delete-force-all", "-A"]) +def test_force_delete_all_child_query(ip_snippets, arg): + ip_snippets.run_cell( + """ + %%sql --save high_price_b_child --no-execute +SELECT * +FROM "high_price_b" +WHERE symbol == 'b' +LIMIT 3 +""" + ) + out = ip_snippets.run_cell(f"%sqlcmd snippets {arg} high_price_b").result + assert "high_price_b_child, high_price_b has been deleted" in out + stored_snippets = out[out.find("Stored snippets") + len("Stored snippets: ") :] + assert "high_price, high_price_a" in stored_snippets + assert "high_price_b," not in stored_snippets + assert "high_price_b_child" not in stored_snippets + + +@pytest.mark.parametrize( + "arg", + [ + "--delete", + "-d", + ], +) +def test_delete_snippet_error(ip_snippets, arg): + with pytest.raises(UsageError) as excinfo: + ip_snippets.run_cell(f"%sqlcmd snippets {arg} high_price") + + assert excinfo.value.error_type == "UsageError" + assert ( + str(excinfo.value) == "The following tables are dependent on high_price: " + "high_price_a, high_price_b.\nPass --delete-force to only " + "delete high_price.\nPass --delete-force-all to delete " + "high_price_a, high_price_b and high_price" + ) + + +@pytest.mark.parametrize( + "arg", + [ + "--delete", + "-d", + "--delete-force-all", + "-A", + "--delete-force", + "-D", + ], +) +def test_delete_invalid_snippet(arg, ip_snippets): + with pytest.raises(UsageError) as excinfo: + ip_snippets.run_cell(f"%sqlcmd snippets {arg} non_existent_snippet") + + assert excinfo.value.error_type == "UsageError" + assert str(excinfo.value) == "No such saved snippet found : non_existent_snippet" + + +@pytest.mark.parametrize("arg", ["--delete-force", "-D"]) +def test_delete_snippet_when_dependency_force_deleted(ip_snippets, arg): + ip_snippets.run_cell(f"%sqlcmd snippets {arg} high_price") + out = ip_snippets.run_cell("%sqlcmd snippets --delete high_price_a").result + assert "high_price_a has been deleted.\nStored snippets: high_price_b" in out + + +def test_view_snippet_variable_substitution(ip_snippets): + ip_snippets.user_global_ns["snippet_name"] = "test_snippet" + ip_snippets.run_cell( + """%%sql --save {{snippet_name}} --no-execute +SELECT * FROM "test_store" WHERE price >= 1.50 +""" + ) + + out = ip_snippets.run_cell("%sqlcmd snippets {{snippet_name}}").result + assert 'SELECT * FROM "test_store" WHERE price >= 1.50' in out + + +@pytest.mark.parametrize( + "arguments", ["--table schema1.table1", "--table table1 --schema schema1"] +) +def test_explore_with_schema(ip_empty, sample_schema_with_table, arguments): + expected_rows = ['"x": 1', '"y": "one"', '"x": 2', '"y": "two"'] + + out = ip_empty.run_cell(f"%sqlcmd explore {arguments}").result + assert isinstance(out, TableWidget) + assert [row in out._repr_html_() for row in expected_rows] + + +@pytest.mark.parametrize( + "arguments", + ["--table {{schema}}.{{table}}", "--table {{table}} --schema {{schema}}"], +) +def test_explore_with_schema_variable_substitution( + ip_empty, sample_schema_with_table, arguments +): + expected_rows = ['"x": 1', '"y": "one"', '"x": 2', '"y": "two"'] + ip_empty.user_global_ns["table"] = "table1" + ip_empty.user_global_ns["schema"] = "schema1" + out = ip_empty.run_cell(f"%sqlcmd explore {arguments}").result + assert isinstance(out, TableWidget) + assert [row in out._repr_html_() for row in expected_rows] + + +@pytest.mark.parametrize( + "file_content, stored_conns", + [ + ( + """[conn1] +drivername = sqlite +""", + [{"name": "conn1", "driver": "sqlite"}], + ), + ( + """[conn1] +drivername = sqlite + +[conn2] +drivername = sqlite + +[conn3] +drivername = duckdb +""", + [ + {"name": "conn1", "driver": "sqlite"}, + {"name": "conn2", "driver": "sqlite"}, + {"name": "conn3", "driver": "duckdb"}, + ], + ), + ("", []), + ], +) +def test_connect_with_connections_ini(tmp_empty, ip_empty, file_content, stored_conns): + Path("connections.ini").write_text(file_content) + ip_empty.run_cell("%load_ext sql") + ip_empty.run_cell("%config SqlMagic.dsn_filename = './connections.ini'") + connector_widget = ip_empty.run_cell("%sqlcmd connect").result + assert isinstance(connector_widget, ConnectorWidget) + assert connector_widget.stored_connections == stored_conns + + +def test_connect_when_no_connections_ini(tmp_empty, ip_empty): + ip_empty.run_cell("%load_ext sql") + ip_empty.run_cell("%config SqlMagic.dsn_filename = './connections.ini'") + connector_widget = ip_empty.run_cell("%sqlcmd connect").result + assert isinstance(connector_widget, ConnectorWidget) + assert connector_widget.stored_connections == [] diff --git a/src/tests/test_magic_cte.py b/src/tests/test_magic_cte.py new file mode 100644 index 000000000..8a3c8af41 --- /dev/null +++ b/src/tests/test_magic_cte.py @@ -0,0 +1,305 @@ +import pytest +from IPython.core.error import UsageError +from sql.error_handler import CTE_MSG + + +def test_trailing_semicolons_removed_from_cte(ip): + ip.run_cell( + """%%sql --save positive_x +SELECT * FROM number_table WHERE x > 0; + + +""" + ) + + ip.run_cell( + """%%sql --save positive_y +SELECT * FROM number_table WHERE y > 0; +""" + ) + + cell_execution = ip.run_cell( + """%%sql --save final --with positive_x --with positive_y +SELECT * FROM positive_x +UNION +SELECT * FROM positive_y; +""" + ) + + cell_final_query = ip.run_cell("%sqlcmd snippets final") + + assert cell_execution.success + assert cell_final_query.result == ( + "WITH `positive_x` AS (\nSELECT * " + "FROM number_table WHERE x > 0), `positive_y` AS (\nSELECT * " + "FROM number_table WHERE y > 0)\nSELECT * FROM positive_x\n" + "UNION\nSELECT * FROM positive_y;" + ) + + +def test_infer_dependencies(ip, capsys): + ip.run_cell_magic( + "sql", + "--save author_sub", + "SELECT last_name FROM author WHERE year_of_death > 1900", + ) + + ip.run_cell_magic( + "sql", + "--save final", + "SELECT last_name FROM author_sub;", + ) + out, _ = capsys.readouterr() + result = ip.run_cell("%sqlcmd snippets final").result + expected = ( + "WITH `author_sub` AS (\nSELECT last_name FROM author " + "WHERE year_of_death > 1900)\nSELECT last_name FROM author_sub;" + ) + + assert result == expected + assert "Generating CTE with stored snippets: 'author_sub'" in out + + +TABLE_NAME_TYPO_ERR_MSG = """ +There is no table with name 'author_subb'. +Did you mean: 'author_sub' + + +Original error message from DB driver: +(sqlite3.OperationalError) no such table: author_subb +[SQL: SELECT last_name FROM author_subb;] +""" + + +def test_table_name_typo(ip): + ip.run_cell_magic( + "sql", + "--save author_sub", + "SELECT last_name FROM author WHERE year_of_death > 1900", + ) + + with pytest.raises(UsageError) as excinfo: + ip.run_cell_magic( + "sql", + "--save final", + "SELECT last_name FROM author_subb;", + ) + + assert excinfo.value.error_type == "TableNotFoundError" + assert TABLE_NAME_TYPO_ERR_MSG.strip() in str(excinfo.value) + + +def test_snippets_delete(ip, capsys): + ip.run_cell( + """ + %%sql sqlite:// + CREATE TABLE orders (order_id int, customer_id int, order_value float); + INSERT INTO orders VALUES (123, 15, 150.67); + INSERT INTO orders VALUES (124, 25, 200.66); + INSERT INTO orders VALUES (211, 15, 251.43); + INSERT INTO orders VALUES (312, 5, 333.41); + CREATE TABLE another_orders (order_id int, customer_id int, order_value float); + INSERT INTO another_orders VALUES (511,15, 150.67); + INSERT INTO another_orders VALUES (512, 30, 200.66); + CREATE TABLE customers (customer_id int, name varchar(25)); + INSERT INTO customers VALUES (15, 'John'); + INSERT INTO customers VALUES (25, 'Sheryl'); + INSERT INTO customers VALUES (5, 'Mike'); + INSERT INTO customers VALUES (30, 'Daisy'); + """ + ) + ip.run_cell_magic( + "sql", + "--save orders_less", + "SELECT * FROM orders WHERE order_value < 250.0", + ) + + ip.run_cell_magic( + "sql", + "--save another_orders", + "SELECT * FROM orders WHERE order_value > 250.0", + ) + + ip.run_cell_magic( + "sql", + "--save final", + """ + SELECT o.order_id, customers.name, o.order_value + FROM another_orders o + INNER JOIN customers ON o.customer_id=customers.customer_id; + """, + ) + + out, _ = capsys.readouterr() + assert "Generating CTE with stored snippets: 'another_orders'" in out + result_del = ip.run_cell( + "%sqlcmd snippets --delete-force-all another_orders" + ).result + assert "final, another_orders has been deleted.\n" in result_del + stored_snippets = result_del[ + result_del.find("Stored snippets") + len("Stored snippets: ") : + ] + assert "orders_less" in stored_snippets + ip.run_cell_magic( + "sql", + "--save final", + """ + SELECT o.order_id, customers.name, o.order_value + FROM another_orders o + INNER JOIN customers ON o.customer_id=customers.customer_id; + """, + ) + result = ip.run_cell("%sqlcmd snippets final").result + expected = ( + "SELECT o.order_id, customers.name, " + "o.order_value\n " + "FROM another_orders o\n INNER JOIN customers " + "ON o.customer_id=customers.customer_id" + ) + assert expected in result + + +SYNTAX_ERROR_MESSAGE = """ +Syntax Error in WITH `author_sub` AS ( +SELECT last_name FRM author WHERE year_of_death > 1900) +SELECT last_name FROM author_sub: Expecting ( at Line 1, Column 16 +""" + + +def test_query_syntax_error(ip): + ip.run_cell_magic( + "sql", + "--save author_sub --no-execute", + "SELECT last_name FRM author WHERE year_of_death > 1900", + ) + + with pytest.raises(UsageError) as excinfo: + ip.run_cell_magic( + "sql", + "--save final", + "SELECT last_name FROM author_sub;", + ) + + assert excinfo.value.error_type == "RuntimeError" + assert CTE_MSG.strip() in str(excinfo.value) + + +def test_comment_in_query_stripped(ip): + ip.run_cell( + """%%sql --save positive_x +SELECT * FROM number_table WHERE x > 0; +--some comment + +""" + ) + ip.run_cell( + """%%sql --with positive_x --save final +SELECT * FROM positive_x +""" + ) + cell_final_query = ip.run_cell("%sqlcmd snippets final").result + assert ( + cell_final_query == "WITH `positive_x` AS (\nSELECT * FROM number_table WHERE " + "x > 0)\nSELECT * FROM positive_x" + ) + + +def test_inline_comment_in_query_stripped(ip): + ip.run_cell( + """%%sql --save positive_x +SELECT * FROM number_table +WHERE x > 0; --some comment +""" + ) + ip.run_cell( + """%%sql --with positive_x --save final +SELECT * FROM positive_x +""" + ) + cell_final_query = ip.run_cell("%sqlcmd snippets final").result + assert ( + cell_final_query == "WITH `positive_x` AS (\nSELECT * FROM " + "number_table\nWHERE x > 0)\nSELECT * FROM positive_x" + ) + + +def test_comments_in_multiple_with_query_stripped(ip): + ip.run_cell( + """%%sql --save positive_x +/* select all +numbers */ +SELECT * FROM number_table WHERE x > 0; +--some comment + +""" + ) + ip.run_cell( + """%%sql --save positive_x_another +/* select all +numbers again */ +SELECT * FROM number_table WHERE x > 0; +--some comment + +""" + ) + ip.run_cell( + """%%sql --with positive_x --with positive_x_another --save final +SELECT * FROM positive_x, positive_x_another +WHERE positive_x.x = positive_x_another.x +""" + ) + cell_final_query = ip.run_cell("%sqlcmd snippets final").result + assert ( + cell_final_query + == "WITH `positive_x` AS (\n\nSELECT * FROM number_table WHERE x > 0), " + "`positive_x_another` AS (\n\nSELECT * FROM number_table WHERE x > 0)\n" + "SELECT * FROM positive_x, positive_x_another\nWHERE " + "positive_x.x = positive_x_another.x" + ) + + +def test_multiple_comments_in_query_stripped(ip): + ip.run_cell( + """%%sql --save positive_x +--select all rows +SELECT * FROM number_table +--if x > 0 +WHERE x > 0; +--final comment + +""" + ) + ip.run_cell( + """%%sql --with positive_x --save final +SELECT * FROM positive_x +""" + ) + cell_final_query = ip.run_cell("%sqlcmd snippets final").result + assert ( + cell_final_query == "WITH `positive_x` AS (\n\nSELECT * FROM number_table\n\n" + "WHERE x > 0)\nSELECT * FROM positive_x" + ) + + +def test_single_and_multiline_comments_in_query_stripped(ip): + ip.run_cell( + """%%sql --save positive_x +/* select all +rows*/ +SELECT * FROM number_table +--if x > 0 +WHERE x > 0; +--final comment + +""" + ) + ip.run_cell( + """%%sql --with positive_x --save final +SELECT * FROM positive_x +""" + ) + cell_final_query = ip.run_cell("%sqlcmd snippets final").result + assert ( + cell_final_query == "WITH `positive_x` AS (\n\nSELECT * FROM number_table\n\n" + "WHERE x > 0)\nSELECT * FROM positive_x" + ) diff --git a/src/tests/test_magic_display.py b/src/tests/test_magic_display.py new file mode 100644 index 000000000..93fc1688a --- /dev/null +++ b/src/tests/test_magic_display.py @@ -0,0 +1,79 @@ +import pytest + + +@pytest.mark.parametrize("feedback", [1, 2]) +def test_connection_string_displayed(ip_empty, capsys, feedback): + ip_empty.run_cell(f"%config SqlMagic.feedback = {feedback}") + + ip_empty.run_cell("%sql duckdb://") + ip_empty.run_cell("%sql show tables") + + captured = capsys.readouterr() + assert "Running query in 'duckdb://'" in captured.out + + +@pytest.mark.parametrize("feedback", [1, 2]) +def test_dbapi_connection_display(ip_empty, capsys, tmp_empty, feedback): + ip_empty.run_cell(f"%config SqlMagic.feedback = {feedback}") + + ip_empty.run_cell("import duckdb") + ip_empty.run_cell("custom = duckdb.connect('anotherdb')") + ip_empty.run_cell("%sql custom") + ip_empty.run_cell("%sql show tables") + + captured = capsys.readouterr() + assert "Running query in 'DuckDBPyConnection'" in captured.out + + +@pytest.mark.parametrize("feedback", [1, 2]) +def test_connection_string_hidden_when_passing_alias(ip_empty, capsys, feedback): + ip_empty.run_cell(f"%config SqlMagic.feedback = {feedback}") + + ip_empty.run_cell("%sql duckdb:// --alias myduckdbconn") + ip_empty.run_cell("%sql show tables") + + captured = capsys.readouterr() + assert "duckdb://" not in captured.out + assert "Running query in 'myduckdbconn'" in captured.out + + +def test_no_display_connection_if_feedback_disabled(ip_empty, capsys): + ip_empty.run_cell("%config SqlMagic.feedback = 0") + + ip_empty.run_cell("%sql duckdb://") + ip_empty.run_cell("%sql show tables") + + captured = capsys.readouterr() + assert "Running query in" not in captured.out + + +def test_display_message_when_persisting_data_frames(ip_empty, capsys): + ip_empty.run_cell("import pandas as pd; df = pd.DataFrame({'x': range(5)})") + ip_empty.run_cell("%sql duckdb://") + ip_empty.run_cell("%sql --persist df") + + captured = capsys.readouterr() + assert "\nSuccess! Persisted df to the database.\n" in captured.out + + +def test_listing_connections(ip_empty, tmp_empty): + ip_empty.run_cell("%sql duckdb://") + ip_empty.run_cell("%sql sqlite://") + ip_empty.run_cell("%sql sqlite:///my.db --alias somedb") + ip_empty.run_cell("from sqlalchemy import create_engine") + ip_empty.run_cell("engine = create_engine('duckdb:///somedb')") + ip_empty.run_cell("%sql engine --alias someduckdb") + ip_empty.run_cell("import duckdb") + ip_empty.run_cell("custom = duckdb.connect('anotherdb')") + ip_empty.run_cell("%sql custom") + + connections_table = ip_empty.run_cell("%sql --connections").result + txt = str(connections_table) + + assert connections_table._repr_html_() + assert "DuckDBPyConnection" in txt + assert "sqlite:///my.db" in txt + assert "duckdb:///somedb" in txt + assert "sqlite://" in txt + assert "somedb" in txt + assert "someduckdb" in txt diff --git a/src/tests/test_magic_plot.py b/src/tests/test_magic_plot.py new file mode 100644 index 000000000..ab2178172 --- /dev/null +++ b/src/tests/test_magic_plot.py @@ -0,0 +1,1042 @@ +from pathlib import Path +import pytest +from IPython.core.error import UsageError +import matplotlib.pyplot as plt +from sql import util +import duckdb + +from matplotlib.testing.decorators import image_comparison, _cleanup_cm + +SUPPORTED_PLOTS = ["bar", "boxplot", "histogram", "pie"] +plot_str = util.pretty_print(SUPPORTED_PLOTS, last_delimiter="or") + + +@pytest.fixture +def ip_snippets(ip, tmp_empty): + Path("data.csv").write_text( + """\ +x, y +0, 0 +1, 1 +2, 2 +""" + ) + ip.run_cell("%sql duckdb://") + + ip.run_cell( + """%%sql --save subset --no-execute +SELECT * +FROM data.csv +WHERE x > -1 +""" + ) + ip.run_cell( + """%%sql --save subset_another --no-execute +SELECT * +FROM subset +WHERE x > 2 +""" + ) + yield ip + + +@pytest.fixture +def ip_with_schema_and_table(ip_empty, load_penguin): + ip_empty.run_cell("%sql duckdb://") + ip_empty.run_cell( + """%%sql +CREATE SCHEMA sqlalchemy_schema; +CREATE TABLE sqlalchemy_schema.penguins1 ( + species VARCHAR(255), + island VARCHAR(255), + bill_length_mm DECIMAL(5, 2), + bill_depth_mm DECIMAL(5, 2), + flipper_length_mm DECIMAL(5, 2), + body_mass_g INTEGER, + sex VARCHAR(255) +); + +COPY sqlalchemy_schema.penguins1 FROM 'penguins.csv' WITH (FORMAT CSV, HEADER TRUE); +""" + ) + + conn = duckdb.connect(database=":memory:", read_only=False) + ip_empty.push({"conn": conn}) + ip_empty.run_cell("%sql conn") + ip_empty.run_cell( + """%%sql +CREATE SCHEMA dbapi_schema; +CREATE TABLE dbapi_schema.penguins2 ( + species VARCHAR(255), + island VARCHAR(255), + bill_length_mm DECIMAL(5, 2), + bill_depth_mm DECIMAL(5, 2), + flipper_length_mm DECIMAL(5, 2), + body_mass_g INTEGER, + sex VARCHAR(255) +); + +COPY dbapi_schema.penguins2 FROM 'penguins.csv' WITH (FORMAT CSV, HEADER TRUE); +""" + ) + + yield ip_empty + + +@pytest.mark.parametrize( + "cell, error_message", + [ + [ + "%sqlplot someplot -t a -c b", + "argument plot_name: invalid choice: 'someplot' " + "(choose from 'histogram', 'hist', 'boxplot', 'box', 'bar', 'pie')", + ], + [ + "%sqlplot -t a -c b", + "the following arguments are required: plot_name", + ], + ], + ids=["invalid_plot_name", "missing_plot_name"], +) +def test_validate_plot_name(tmp_empty, ip, cell, error_message): + with pytest.raises(UsageError) as excinfo: + ip.run_cell(cell) + + assert excinfo.typename == "UsageError" + assert str(error_message).lower() in str(excinfo.value).lower() + + +@pytest.mark.parametrize( + "cell, error_message", + [ + [ + "%sqlplot histogram --column a", + "the following arguments are required: -t/--table", + ], + [ + "%sqlplot histogram --table a", + "the following arguments are required: -c/--column", + ], + ], +) +def test_validate_arguments(tmp_empty, ip, cell, error_message): + with pytest.raises(UsageError) as excinfo: + ip.run_cell(cell) + + assert str(error_message).lower() in str(excinfo.value).lower() + + +@pytest.mark.parametrize( + "cell, error_message", + [ + [ + ( + "%sqlplot histogram --table penguins.csv --column body_mass_g " + "--breaks 1000 2000 2699" + ), + "All break points are lower than the min data point of 2700.", + ], + [ + ( + "%sqlplot histogram --table penguins.csv --column body_mass_g " + "--breaks 7000 7100 7200" + ), + "All break points are higher than the max data point of 6300.", + ], + [ + ( + "%sqlplot histogram --table penguins.csv --column body_mass_g " + "--breaks 3000 4000 5000 --bins 50" + ), + "'bins', and 'breaks' are specified. You can only specify one of them.", + ], + [ + ( + "%sqlplot histogram --table penguins.csv --bins 50 --column body_mass_g" + " --breaks 3000 4000 5000" + ), + "'bins', and 'breaks' are specified. You can only specify one of them.", + ], + [ + ( + "%sqlplot histogram --table penguins.csv --column bill_length_mm " + "bill_depth_mm --breaks 30 40 50" + ), + "Multiple columns don't support breaks. Please use bins instead.", + ], + ], +) +def test_validate_breaks_arguments(load_penguin, ip, cell, error_message): + with pytest.raises(UsageError) as excinfo: + ip.run_cell(cell) + + assert error_message in str(excinfo.value) + + +@pytest.mark.parametrize( + "cell, error_message", + [ + [ + ( + "%sqlplot histogram --table penguins.csv --column body_mass_g " + "--bins 50 --binwidth 1000" + ), + "'bins', and 'binwidth' are specified. You can only specify one of them.", + ], + [ + ( + "%sqlplot histogram --table penguins.csv --column body_mass_g " + "-W 50 --breaks 3000 4000 5000" + ), + "'binwidth', and 'breaks' are specified. You can only specify one of them.", + ], + [ + ( + "%sqlplot histogram --table penguins.csv --column body_mass_g " + "--binwidth 0" + ), + ( + "Binwidth given : 0.0. When using binwidth, " + "please ensure to pass a positive value." + ), + ], + [ + ( + "%sqlplot histogram --table penguins.csv --column body_mass_g " + "--binwidth -10" + ), + ( + "Binwidth given : -10.0. When using binwidth, " + "please ensure to pass a positive value." + ), + ], + ], +) +def test_validate_binwidth_arguments(load_penguin, ip, cell, error_message): + with pytest.raises(UsageError) as excinfo: + ip.run_cell(cell) + + assert error_message in str(excinfo.value) + assert excinfo.value.error_type == "ValueError" + + +def test_validate_binwidth_text_argument(tmp_empty, ip): + with pytest.raises(UsageError) as excinfo: + ip.run_cell( + "%sqlplot histogram --table penguins.csv " + "--column body_mass_g --binwidth test" + ) + + assert "argument -W/--binwidth: invalid float value: 'test'" == str(excinfo.value) + + +def test_binwidth_larger_than_range(load_penguin, ip, capsys): + ip.run_cell( + "%sqlplot histogram --table penguins.csv --column body_mass_g --binwidth 3601" + ) + out, _ = capsys.readouterr() + assert ( + "Specified binwidth 3601.0 is larger than the range 3600. " + "Please choose a smaller binwidth." + ) in out + + +@_cleanup_cm() +@pytest.mark.parametrize( + "cell", + [ + "%sqlplot histogram --table data.csv --column x", + "%sqlplot hist --table data.csv --column x", + "%sqlplot histogram --table data.csv --column x --bins 10", + "%sqlplot histogram --table data.csv --column x --binwidth 1", + pytest.param( + "%sqlplot histogram --table nas.csv --column x", + marks=pytest.mark.xfail(reason="Not implemented yet"), + ), + "%sqlplot boxplot --table data.csv --column x", + "%sqlplot box --table data.csv --column x", + "%sqlplot boxplot --table data.csv --column x --orient h", + "%sqlplot boxplot --table subset --column x", + "%sqlplot boxplot --table subset --column x --with subset", + "%sqlplot boxplot -t subset -c x -w subset -o h", + "%sqlplot boxplot --table nas.csv --column x", + "%sqlplot bar -t data.csv -c x", + "%sqlplot bar --table subset --column x", + "%sqlplot bar --table subset --column x --with subset", + "%sqlplot bar -t data.csv -c x -S", + "%sqlplot bar -t data.csv -c x -o h", + "%sqlplot bar -t data.csv -c x y", + "%sqlplot pie -t data.csv -c x", + "%sqlplot pie --table subset --column x", + "%sqlplot pie --table subset --column x --with subset", + "%sqlplot pie -t data.csv -c x -S", + "%sqlplot pie -t data.csv -c x y", + '%sqlplot boxplot --table spaces.csv --column "some column"', + '%sqlplot histogram --table spaces.csv --column "some column"', + '%sqlplot bar --table spaces.csv --column "some column"', + '%sqlplot pie --table spaces.csv --column "some column"', + "%sqlplot boxplot --table 'file with spaces.csv' --column x", + "%sqlplot histogram --table 'file with spaces.csv' --column x", + "%sqlplot bar --table 'file with spaces.csv' --column x", + "%sqlplot pie --table 'file with spaces.csv' --column x", + ], + ids=[ + "histogram", + "hist", + "histogram-bins", + "histogram-binwidth", + "histogram-nas", + "boxplot", + "boxplot-with", + "box", + "boxplot-horizontal", + "boxplot-with", + "boxplot-shortcuts", + "boxplot-nas", + "bar-1-col", + "bar-subset", + "bar-subset-with", + "bar-1-col-show_num", + "bar-1-col-horizontal", + "bar-2-col", + "pie-1-col", + "pie-subset", + "pie-subset-with", + "pie-1-col-show_num", + "pie-2-col", + "boxplot-column-name-with-spaces", + "histogram-column-name-with-spaces", + "bar-column-name-with-spaces", + "pie-column-name-with-spaces", + "boxplot-table-name-with-spaces", + "histogram-table-name-with-spaces", + "bar-table-name-with-spaces", + "pie-table-name-with-spaces", + ], +) +def test_sqlplot(tmp_empty, ip, cell): + # clean current Axes + plt.cla() + + Path("spaces.csv").write_text( + """\ +"some column", y +0, 0 +1, 1 +2, 2 +""" + ) + + Path("data.csv").write_text( + """\ +x, y +0, 0 +1, 1 +2, 2 +""" + ) + + Path("nas.csv").write_text( + """\ +x, y +, 0 +1, 1 +2, 2 +""" + ) + + Path("file with spaces.csv").write_text( + """\ +x, y +0, 0 +1, 1 +2, 2 +""" + ) + ip.run_cell("%sql duckdb://") + + ip.run_cell( + """%%sql --save subset --no-execute +SELECT * +FROM data.csv +WHERE x > -1 +""" + ) + + out = ip.run_cell(cell) + + # maptlotlib >= 3.7 has Axes but earlier Python + # versions are not compatible + assert type(out.result).__name__ in {"Axes", "AxesSubplot"} + + +@pytest.fixture +def load_data_two_col(ip): + if not Path("data_two.csv").is_file(): + Path("data_two.csv").write_text( + """\ +x, y +0, 0 +1, 1 +2, 2 +5, 7""" + ) + + ip.run_cell("%sql duckdb://") + + +@pytest.fixture +def load_data_one_col(ip): + if not Path("data_one.csv").is_file(): + Path("data_one.csv").write_text( + """\ +x +0 +0 +1 +1 +1 +2 +""" + ) + ip.run_cell("%sql duckdb://") + + +@pytest.fixture +def load_data_one_col_null(ip): + if not Path("data_one_null.csv").is_file(): + Path("data_one_null.csv").write_text( + """\ +x + +0 + +0 +1 + +1 +1 +2 +""" + ) + ip.run_cell("%sql duckdb://") + + +@_cleanup_cm() +@image_comparison(baseline_images=["bar_one_col"], extensions=["png"], remove_text=True) +def test_bar_one_col(load_data_one_col, ip): + ip.run_cell("%sqlplot bar -t data_one.csv -c x") + + +@_cleanup_cm() +@image_comparison( + baseline_images=["bar_one_col_null"], extensions=["png"], remove_text=True +) +def test_bar_one_col_null(load_data_one_col_null, ip): + ip.run_cell("%sqlplot bar -t data_one_null.csv -c x") + + +@_cleanup_cm() +@image_comparison( + baseline_images=["bar_one_col_h"], extensions=["png"], remove_text=True +) +def test_bar_one_col_h(load_data_one_col, ip): + ip.run_cell("%sqlplot bar -t data_one.csv -c x -o h") + + +@pytest.mark.xfail(reason="DuckDB v0.9.0 bug") +@_cleanup_cm() +@image_comparison( + baseline_images=["bar_one_col_num_h"], extensions=["png"], remove_text=True +) +def test_bar_one_col_num_h(load_data_one_col, ip): + ip.run_cell("%sqlplot bar -t data_one.csv -c x -o h -S") + + +@pytest.mark.xfail(reason="DuckDB v0.9.0 bug") +@_cleanup_cm() +@image_comparison( + baseline_images=["bar_one_col_num_v"], extensions=["png"], remove_text=True +) +def test_bar_one_col_num_v(load_data_one_col, ip): + ip.run_cell("%sqlplot bar -t data_one.csv -c x -S") + + +@_cleanup_cm() +@image_comparison(baseline_images=["bar_two_col"], extensions=["png"], remove_text=True) +def test_bar_two_col(load_data_two_col, ip): + ip.run_cell("%sqlplot bar -t data_two.csv -c x y") + + +@_cleanup_cm() +@pytest.mark.xfail(reason="Failing intermittently with DuckDB v0.10.0") +@image_comparison(baseline_images=["pie_one_col"], extensions=["png"], remove_text=True) +def test_pie_one_col(load_data_one_col, ip): + ip.run_cell("%sqlplot pie -t data_one.csv -c x") + + +@pytest.mark.xfail(reason="Failing intermittently with DuckDB v0.10.0") +@_cleanup_cm() +@image_comparison( + baseline_images=["pie_one_col_null"], extensions=["png"], remove_text=True +) +def test_pie_one_col_null(load_data_one_col_null, ip): + ip.run_cell("%sqlplot pie -t data_one_null.csv -c x") + + +@pytest.mark.xfail(reason="Failing intermittently with DuckDB v0.10.0") +@_cleanup_cm() +@image_comparison( + baseline_images=["pie_one_col_num"], extensions=["png"], remove_text=True +) +def test_pie_one_col_num(load_data_one_col, ip): + ip.run_cell("%sqlplot pie -t data_one.csv -c x -S") + + +@pytest.mark.xfail(reason="Failing intermittently with DuckDB v0.10.0") +@_cleanup_cm() +@image_comparison(baseline_images=["pie_two_col"], extensions=["png"], remove_text=True) +def test_pie_two_col(load_data_two_col, ip): + ip.run_cell("%sqlplot pie -t data_two.csv -c x y") + + +@_cleanup_cm() +@image_comparison(baseline_images=["boxplot"], extensions=["png"], remove_text=True) +def test_boxplot(load_penguin, ip): + ip.run_cell("%sqlplot boxplot --table penguins.csv --column body_mass_g") + + +@_cleanup_cm() +@image_comparison( + baseline_images=["boxplot_duckdb"], extensions=["png"], remove_text=True +) +def test_boxplot_duckdb(load_penguin, ip): + conn = duckdb.connect(database=":memory:", read_only=False) + ip.push({"conn": conn}) + ip.run_cell("%sql conn") + ip.run_cell("%sqlplot boxplot --table penguins.csv --column body_mass_g") + + +@_cleanup_cm() +@image_comparison(baseline_images=["boxplot_h"], extensions=["png"], remove_text=True) +def test_boxplot_h(load_penguin, ip): + ip.run_cell("%sqlplot boxplot --table penguins.csv --column body_mass_g --orient h") + + +@_cleanup_cm() +@image_comparison(baseline_images=["boxplot_two"], extensions=["png"], remove_text=True) +def test_boxplot_two_col(load_penguin, ip): + ip.run_cell( + "%sqlplot boxplot --table penguins.csv --column bill_length_mm " + "bill_depth_mm flipper_length_mm" + ) + + +@_cleanup_cm() +@image_comparison( + baseline_images=["boxplot_null"], extensions=["png"], remove_text=True +) +def test_boxplot_null(load_penguin, ip): + ip.run_cell("%sqlplot boxplot --table penguins.csv --column bill_length_mm ") + + +@_cleanup_cm() +@image_comparison(baseline_images=["hist"], extensions=["png"], remove_text=True) +def test_hist(load_penguin, ip): + ip.run_cell("%sqlplot histogram --table penguins.csv --column body_mass_g") + + +@_cleanup_cm() +@image_comparison(baseline_images=["hist_bin"], extensions=["png"], remove_text=True) +def test_hist_bin(load_penguin, ip): + ip.run_cell( + "%sqlplot histogram --table penguins.csv --column body_mass_g --bins 300" + ) + + +@_cleanup_cm() +@image_comparison(baseline_images=["hist_two"], extensions=["png"], remove_text=True) +def test_hist_two(load_penguin, ip): + ip.run_cell( + "%sqlplot histogram --table penguins.csv --column bill_length_mm bill_depth_mm" + ) + + +@_cleanup_cm() +@image_comparison(baseline_images=["hist_null"], extensions=["png"], remove_text=True) +def test_hist_null(load_penguin, ip): + ip.run_cell("%sqlplot histogram --table penguins.csv --column bill_length_mm ") + + +@_cleanup_cm() +@image_comparison(baseline_images=["hist_custom"], extensions=["png"], remove_text=True) +def test_hist_cust(load_penguin, ip): + ax = ip.run_cell( + "%sqlplot histogram --table penguins.csv --column bill_length_mm " + ).result + ax.set_title("Custom Title") + _ = ax.grid(True) + + +@_cleanup_cm() +@image_comparison(baseline_images=["hist_breaks"], extensions=["png"], remove_text=True) +def test_hist_breaks(load_penguin, ip): + ip.run_cell( + "%sqlplot histogram --table penguins.csv --column body_mass_g " + "--breaks 3000 3100 3300 3700 4000 4600" + ) + + +@pytest.mark.parametrize( + "binwidth", + [ + "--binwidth", + "-W", + ], +) +@_cleanup_cm() +@image_comparison( + baseline_images=["hist_binwidth"], extensions=["png"], remove_text=True +) +def test_hist_binwidth(load_penguin, ip, binwidth): + ip.run_cell( + f"%sqlplot histogram --table penguins.csv --column body_mass_g {binwidth} 150" + ) + + +@pytest.mark.parametrize( + "cmd, conn", + [ + ( + "%sqlplot boxplot --table sqlalchemy_schema.penguins1 --column body_mass_g", + "%sql duckdb://", + ), + ( + ( + "%sqlplot boxplot --table penguins1 --schema sqlalchemy_schema " + "--column body_mass_g" + ), + "%sql duckdb://", + ), + ( + "%sqlplot boxplot --table dbapi_schema.penguins2 --column body_mass_g", + "%sql conn", + ), + ( + ( + "%sqlplot boxplot --table penguins2 --schema dbapi_schema " + "--column body_mass_g" + ), + "%sql conn", + ), + ( + "%sqlplot boxplot --table penguins.csv --column body_mass_g", + "%sql duckdb://", + ), + ], +) +@_cleanup_cm() +@image_comparison( + baseline_images=["boxplot_with_table_in_schema"], + extensions=["png"], + remove_text=True, +) +def test_boxplot_with_table_in_schema(ip_with_schema_and_table, cmd, conn): + ip_with_schema_and_table.run_cell(conn) + ip_with_schema_and_table.run_cell(cmd) + + +@pytest.mark.parametrize( + "cmd, conn", + [ + ( + ( + "%sqlplot histogram --table sqlalchemy_schema.penguins1 " + "--column body_mass_g" + ), + "%sql duckdb://", + ), + ( + ( + "%sqlplot histogram --table penguins1 --schema sqlalchemy_schema " + "--column body_mass_g" + ), + "%sql duckdb://", + ), + ( + "%sqlplot histogram --table dbapi_schema.penguins2 --column body_mass_g", + "%sql conn", + ), + ( + ( + "%sqlplot histogram --table penguins2 --schema dbapi_schema " + "--column body_mass_g" + ), + "%sql conn", + ), + ( + "%sqlplot histogram --table penguins.csv --column body_mass_g", + "%sql duckdb://", + ), + ], +) +@_cleanup_cm() +@image_comparison( + baseline_images=["histogram_with_table_in_schema"], + extensions=["png"], + remove_text=True, +) +def test_histogram_with_table_in_schema(ip_with_schema_and_table, cmd, conn): + ip_with_schema_and_table.run_cell(conn) + ip_with_schema_and_table.run_cell(cmd) + + +@pytest.mark.xfail(reason="DuckDB v0.9.0 bug") +@pytest.mark.parametrize( + "cmd, conn", + [ + ( + "%sqlplot bar --table sqlalchemy_schema.penguins1 --column species", + "%sql duckdb://", + ), + ( + ( + "%sqlplot bar --table penguins1 --schema sqlalchemy_schema " + "--column species" + ), + "%sql duckdb://", + ), + ("%sqlplot bar --table dbapi_schema.penguins2 --column species", "%sql conn"), + ( + "%sqlplot bar --table penguins2 --schema dbapi_schema --column species", + "%sql conn", + ), + ("%sqlplot bar --table penguins.csv --column species", "%sql duckdb://"), + ], +) +@_cleanup_cm() +@image_comparison( + baseline_images=["bar_with_table_in_schema"], extensions=["png"], remove_text=True +) +def test_bar_with_table_in_schema(ip_with_schema_and_table, cmd, conn): + ip_with_schema_and_table.run_cell(conn) + ip_with_schema_and_table.run_cell(cmd) + + +@pytest.mark.xfail(reason="DuckDB v0.9.0 bug") +@pytest.mark.parametrize( + "cmd, conn", + [ + ( + "%sqlplot pie --table sqlalchemy_schema.penguins1 --column species", + "%sql duckdb://", + ), + ( + ( + "%sqlplot pie --table penguins1 --schema sqlalchemy_schema " + "--column species" + ), + "%sql duckdb://", + ), + ("%sqlplot pie --table dbapi_schema.penguins2 --column species", "%sql conn"), + ( + "%sqlplot pie --table penguins2 --schema dbapi_schema --column species", + "%sql conn", + ), + ("%sqlplot pie --table penguins.csv --column species", "%sql duckdb://"), + ], +) +@_cleanup_cm() +@image_comparison( + baseline_images=["pie_with_table_in_schema"], extensions=["png"], remove_text=True +) +def test_pie_with_table_in_schema(ip_with_schema_and_table, cmd, conn): + ip_with_schema_and_table.run_cell(conn) + ip_with_schema_and_table.run_cell(cmd) + + +@pytest.mark.parametrize( + "arg", + [ + "--delete", + "-d", + "--delete-force-all", + "-A", + "--delete-force", + "-D", + ], +) +def test_sqlplot_snippet_deletion(ip_snippets, arg): + ip_snippets.run_cell(f"%sqlcmd snippets {arg} subset_another") + + with pytest.raises(UsageError) as excinfo: + ip_snippets.run_cell("%sqlplot boxplot --table subset_another --column x") + + assert "There is no table with name 'subset_another' in the default schema" in str( + excinfo.value + ) + + +TABLE_NAME_TYPO_MSG = """ +There is no table with name 'subst' in the default schema +Did you mean: 'subset' +If you need help solving this issue, send us a message: https://ploomber.io/community +""" + + +def test_sqlplot_snippet_typo(ip_snippets): + with pytest.raises(UsageError) as excinfo: + ip_snippets.run_cell("%sqlplot boxplot --table subst --column x") + + assert TABLE_NAME_TYPO_MSG.strip() in str(excinfo.value).strip() + + +MISSING_TABLE_ERROR_MSG = """ +There is no table with name 'missing' in the default schema +If you need help solving this issue, send us a message: https://ploomber.io/community +""" + + +def test_sqlplot_missing_table(ip_snippets, capsys): + with pytest.raises(UsageError) as excinfo: + ip_snippets.run_cell("%sqlplot boxplot --table missing --column x") + + assert MISSING_TABLE_ERROR_MSG.strip() in str(excinfo.value).strip() + + +@_cleanup_cm() +@pytest.mark.parametrize( + "cell", + [ + "%sqlplot boxplot --table {{table}} --column body_mass_g", + "%sqlplot boxplot --table penguins.csv --column {{column}}", + "%sqlplot boxplot --table {{table}} --column {{column}}", + ], + ids=["table", "column", "table-column"], +) +@image_comparison(baseline_images=["boxplot"], extensions=["png"], remove_text=True) +def test_boxplot_with_variable_substitution(load_penguin, ip, cell): + ip.user_global_ns["table"] = "penguins.csv" + ip.user_global_ns["column"] = "body_mass_g" + ip.run_cell(cell) + + +@_cleanup_cm() +@pytest.mark.parametrize( + "cell", + [ + "%sqlplot histogram --table {{table}} --column body_mass_g", + "%sqlplot histogram --table penguins.csv --column {{column}}", + "%sqlplot histogram --table {{table}} --column {{column}}", + ], + ids=["table", "column", "table-column"], +) +@image_comparison(baseline_images=["hist"], extensions=["png"], remove_text=True) +def test_hist_with_variable_substitution(load_penguin, ip, cell): + ip.user_global_ns["table"] = "penguins.csv" + ip.user_global_ns["column"] = "body_mass_g" + ip.run_cell(cell) + + +@_cleanup_cm() +@pytest.mark.parametrize( + "cell", + [ + "%sqlplot bar --table {{table}} --column x", + "%sqlplot bar --table data_one.csv --column {{column}}", + "%sqlplot bar --table {{table}} --column {{column}}", + ], + ids=["table", "column", "table-column"], +) +@image_comparison(baseline_images=["bar_one_col"], extensions=["png"], remove_text=True) +def test_bar_with_variable_substitution(load_data_one_col, ip, cell): + ip.user_global_ns["table"] = "data_one.csv" + ip.user_global_ns["column"] = "x" + ip.run_cell(cell) + + +@pytest.mark.xfail(reason="Failing intermittently with DuckDB v0.10.0") +@_cleanup_cm() +@pytest.mark.parametrize( + "cell", + [ + "%sqlplot pie --table {{table}} --column x", + "%sqlplot pie --table data_one.csv --column {{column}}", + "%sqlplot pie --table {{table}} --column {{column}}", + ], + ids=["table", "column", "table-column"], +) +@image_comparison(baseline_images=["pie_one_col"], extensions=["png"], remove_text=True) +def test_pie_with_variable_substitution(load_data_one_col, ip, cell): + ip.user_global_ns["table"] = "data_one.csv" + ip.user_global_ns["column"] = "x" + ip.run_cell(cell) + + +@_cleanup_cm() +@pytest.mark.parametrize( + "cell", + [ + "%sqlplot histogram --table {{table}} --column {{column}}", + "%sqlplot hist --table {{table}} --column {{column}}", + "%sqlplot boxplot --table {{table}} --column {{column}}", + "%sqlplot box --table {{table}} --column {{column}}", + "%sqlplot boxplot --table {{table}} --column {{column}} --orient {{orient}}", + "%sqlplot boxplot --table {{subset_table}} --column {{column}}", + "%sqlplot boxplot --table {{subset_table}} --column " + "{{column}} --with {{subset_table}}", + "%sqlplot boxplot -t {{subset_table}} -c {{column}} -w {{subset_table}} -o h", + "%sqlplot boxplot --table {{nas_table}} --column {{column}}", + "%sqlplot bar -t {{table}} -c {{column}}", + "%sqlplot bar --table {{subset_table}} --column {{column}}", + "%sqlplot bar --table {{subset_table}} --column {{column}} " + "--with {{subset_table}}", + "%sqlplot bar -t {{table}} -c {{column}} -S", + "%sqlplot bar -t {{table}} -c {{column}} -o h", + "%sqlplot bar -t {{table}} -c {{column}} y", + "%sqlplot pie -t {{table}} -c {{column}}", + "%sqlplot pie --table {{subset_table}} --column {{column}}", + "%sqlplot pie --table {{subset_table}} --column {{column}} " + "--with {{subset_table}}", + "%sqlplot pie -t {{table}} -c {{column}} -S", + "%sqlplot pie -t {{table}} -c {{column}} y", + '%sqlplot boxplot --table {{spaces_table}} --column "some column"', + '%sqlplot histogram --table {{spaces_table}} --column "some column"', + '%sqlplot bar --table {{spaces_table}} --column "some column"', + '%sqlplot pie --table {{spaces_table}} --column "some column"', + ], + ids=[ + "histogram", + "hist", + "boxplot", + "boxplot-with", + "box", + "boxplot-horizontal", + "boxplot-with", + "boxplot-shortcuts", + "boxplot-nas", + "bar-1-col", + "bar-subset", + "bar-subset-with", + "bar-1-col-show_num", + "bar-1-col-horizontal", + "bar-2-col", + "pie-1-col", + "pie-subset", + "pie-subset-with", + "pie-1-col-show_num", + "pie-2-col", + "boxplot-column-name-with-spaces", + "histogram-column-name-with-spaces", + "bar-column-name-with-spaces", + "pie-column-name-with-spaces", + ], +) +def test_sqlplot_with_variable_substitution(tmp_empty, ip, cell): + # clean current Axes + ip.user_global_ns["table"] = "data.csv" + ip.user_global_ns["column"] = "x" + ip.user_global_ns["subset_table"] = "subset" + ip.user_global_ns["nas_table"] = "nas.csv" + ip.user_global_ns["spaces_table"] = "spaces.csv" + ip.user_global_ns["file_spaces"] = "file with spaces.csv" + ip.user_global_ns["orient"] = "h" + plt.cla() + + Path("spaces.csv").write_text( + """\ +"some column", y +0, 0 +1, 1 +2, 2 +""" + ) + + Path("data.csv").write_text( + """\ +x, y +0, 0 +1, 1 +2, 2 +""" + ) + + Path("nas.csv").write_text( + """\ +x, y +, 0 +1, 1 +2, 2 +""" + ) + + Path("file with spaces.csv").write_text( + """\ +x, y +0, 0 +1, 1 +2, 2 +""" + ) + ip.run_cell("%sql duckdb://") + + ip.run_cell( + """%%sql --save subset --no-execute +SELECT * +FROM data.csv +WHERE x > -1 +""" + ) + + out = ip.run_cell(cell) + + # maptlotlib >= 3.7 has Axes but earlier Python + # versions are not compatible + assert type(out.result).__name__ in {"Axes", "AxesSubplot"} + + +@pytest.mark.parametrize( + "cell", + [ + "%sqlplot histogram --table {{schema}}.{{table}} " "--column {{column}}", + "%sqlplot histogram --table {{table}} --schema {{schema}} " + "--column {{column}}", + ], +) +@_cleanup_cm() +@image_comparison( + baseline_images=["histogram_with_table_in_schema"], + extensions=["png"], + remove_text=True, +) +def test_histogram_with_table_in_schema_variable_substitution( + ip_with_schema_and_table, cell +): + ip_with_schema_and_table.user_global_ns["table"] = "penguins1" + ip_with_schema_and_table.user_global_ns["column"] = "body_mass_g" + ip_with_schema_and_table.user_global_ns["schema"] = "sqlalchemy_schema" + ip_with_schema_and_table.run_cell("%sql duckdb://") + ip_with_schema_and_table.run_cell(cell) + + +@pytest.mark.parametrize( + "cell", + [ + "%sqlplot boxplot --table {{schema}}.{{table}} --column {{column}}", + "%sqlplot boxplot --table {{table}} --schema {{schema}} " "--column {{column}}", + ], +) +@_cleanup_cm() +@image_comparison( + baseline_images=["boxplot_with_table_in_schema"], + extensions=["png"], + remove_text=True, +) +def test_boxplot_with_table_in_schema_variable_substitution( + ip_with_schema_and_table, cell +): + ip_with_schema_and_table.user_global_ns["table"] = "penguins1" + ip_with_schema_and_table.user_global_ns["column"] = "body_mass_g" + ip_with_schema_and_table.user_global_ns["schema"] = "sqlalchemy_schema" + ip_with_schema_and_table.run_cell("%sql duckdb://") + ip_with_schema_and_table.run_cell(cell) diff --git a/src/tests/test_parse.py b/src/tests/test_parse.py index a0aa89b3f..6018b2497 100644 --- a/src/tests/test_parse.py +++ b/src/tests/test_parse.py @@ -1,122 +1,218 @@ -import json import os from pathlib import Path -from six.moves import configparser -from sql.parse import connection_from_dsn_section, parse, without_sql_comment +import pytest +from IPython.core.error import UsageError -try: - from traitlets.config.configurable import Configurable -except ImportError: - from IPython.config.configurable import Configurable +from sql.parse import ( + connection_str_from_dsn_section, + parse, + without_sql_comment, + split_args_and_sql, + magic_args, + escape_string_literals_with_colon_prefix, + escape_string_slicing_notation, + find_named_parameters, + _connection_string, + ConnectionsFile, +) -empty_config = Configurable() default_connect_args = {"options": "-csearch_path=test"} +PATH_TO_DSN_FILE = "src/tests/test_dsn_config.ini" + + +class DummyConfig: + dsn_filename = Path("src/tests/test_dsn_config.ini") + def test_parse_no_sql(): - assert parse("will:longliveliz@localhost/shakes", empty_config) == { + assert parse("will:longliveliz@localhost/shakes", PATH_TO_DSN_FILE) == { "connection": "will:longliveliz@localhost/shakes", "sql": "", "result_var": None, + "return_result_var": False, } def test_parse_with_sql(): assert parse( "postgresql://will:longliveliz@localhost/shakes SELECT * FROM work", - empty_config, + PATH_TO_DSN_FILE, ) == { "connection": "postgresql://will:longliveliz@localhost/shakes", "sql": "SELECT * FROM work", "result_var": None, + "return_result_var": False, } def test_parse_sql_only(): - assert parse("SELECT * FROM work", empty_config) == { + assert parse("SELECT * FROM work", PATH_TO_DSN_FILE) == { "connection": "", "sql": "SELECT * FROM work", "result_var": None, + "return_result_var": False, } def test_parse_postgresql_socket_connection(): - assert parse("postgresql:///shakes SELECT * FROM work", empty_config) == { + assert parse("postgresql:///shakes SELECT * FROM work", PATH_TO_DSN_FILE) == { "connection": "postgresql:///shakes", "sql": "SELECT * FROM work", "result_var": None, + "return_result_var": False, } def test_expand_environment_variables_in_connection(): os.environ["DATABASE_URL"] = "postgresql:///shakes" - assert parse("$DATABASE_URL SELECT * FROM work", empty_config) == { + assert parse("$DATABASE_URL SELECT * FROM work", PATH_TO_DSN_FILE) == { "connection": "postgresql:///shakes", "sql": "SELECT * FROM work", "result_var": None, + "return_result_var": False, } def test_parse_shovel_operator(): - assert parse("dest << SELECT * FROM work", empty_config) == { + assert parse("dest << SELECT * FROM work", PATH_TO_DSN_FILE) == { "connection": "", "sql": "SELECT * FROM work", "result_var": "dest", + "return_result_var": False, } -def test_parse_connect_plus_shovel(): - assert parse("sqlite:// dest << SELECT * FROM work", empty_config) == { - "connection": "sqlite://", +@pytest.mark.parametrize( + "input_string", + [ + "dest= << SELECT * FROM work", + "dest = << SELECT * FROM work", + "dest =<< SELECT * FROM work", + "dest = << SELECT * FROM work", + "dest =<< SELECT * FROM work", + "dest = << SELECT * FROM work", + "dest=<< SELECT * FROM work", + "dest=<
columnanother
\n \n \n \n " + "\n \n \n \n " + "\n \n \n " + "\n \n \n " + "\n \n \n
x
1
2
3
", + ], + ], + ids=[ + "repr", + "repr_html", + ], +) +def test_resultset_fetches_required_rows_repr(results, method, repr_expected): + mock = Mock() + mock.displaylimit = 3 + mock.autolimit = 1000_000 + + rs = ResultSet(results, mock, statement=None, conn=Mock()) + repr_returned = getattr(rs, method)() + + assert repr_expected in repr_returned + assert rs._done_fetching() is False + results.fetchall.assert_not_called() + results.fetchmany.assert_has_calls([call(size=2), call(size=1)]) + results.fetchone.assert_not_called() + + +def test_resultset_autolimit_one(results): + mock = Mock() + mock.displaylimit = 10 + mock.autolimit = 1 + + rs = ResultSet(results, mock, statement=None, conn=Mock()) + repr(rs) + str(rs) + rs._repr_html_() + list(rs) + + results.fetchmany.assert_has_calls([call(size=1)]) + results.fetchone.assert_not_called() + results.fetchall.assert_not_called() + + +def test_display_limit_respected_even_when_feched_all(results): + mock = Mock() + mock.displaylimit = 2 + mock.autolimit = 0 + + rs = ResultSet(results, mock, statement=None, conn=Mock()) + elements = list(rs) + + assert len(elements) == 5 + assert str(rs) == "+---+\n| x |\n+---+\n| 1 |\n| 2 |\n+---+" + assert ( + "\n \n \n \n " + "\n \n \n \n " + "\n \n \n \n" + " \n \n
x
1
2
" in rs._repr_html_() + ) + + +@pytest.mark.parametrize( + "displaylimit, message", + [ + (1, "Truncated to $DISPLAYLIMIT of 1."), + (2, "Truncated to $DISPLAYLIMIT of 2."), + ], +) +def test_displaylimit_truncated_footer(displaylimit, message, results): + HTML_LINK = ( + 'displaylimit' + ) + + mock = Mock() + mock.displaylimit = displaylimit + mock.autolimit = 0 + + rs = ResultSet(results, mock, statement=None, conn=Mock()) + + message_html = string.Template(message).substitute(DISPLAYLIMIT=HTML_LINK) + assert message_html in rs._repr_html_() + + message_plain = string.Template(message).substitute(DISPLAYLIMIT="displaylimit") + assert message_plain in repr(rs) + assert message_plain in str(rs) + + +@pytest.mark.parametrize("displaylimit", [0, 1000]) +def test_no_displaylimit_message(results, displaylimit): + mock = Mock() + mock.displaylimit = displaylimit + mock.autolimit = 0 + + rs = ResultSet(results, mock, statement=None, conn=Mock()) + + assert "Truncated to displaylimit" not in rs._repr_html_() + assert "Truncated to displaylimit" not in repr(rs) + assert "Truncated to displaylimit" not in str(rs) + + +def test_refreshes_sqlaproxy_for_sqlalchemy_duckdb(): + first = SQLAlchemyConnection(create_engine("duckdb://")) + first.execute("CREATE TABLE numbers (x INTEGER)") + first.execute("INSERT INTO numbers VALUES (1), (2), (3), (4), (5)") + first.execute("CREATE TABLE characters (c VARCHAR)") + first.execute("INSERT INTO characters VALUES ('a'), ('b'), ('c'), ('d'), ('e')") + + mock = Mock() + mock.displaylimit = 10 + mock.autolimit = 0 + + statement = "SELECT * FROM numbers" + first_set = ResultSet( + first.raw_execute(statement), mock, statement=statement, conn=first + ) + + original_id = id(first_set._sqlaproxy) + + # create a new resultset so the other one is no longer the latest one + statement = "SELECT * FROM characters" + ResultSet(first.raw_execute(statement), mock, statement=statement, conn=first) + + # force fetching data, this should trigger a refresh + list(first_set) + + assert id(first_set._sqlaproxy) != original_id + + +def test_doesnt_refresh_sqlaproxy_for_if_not_sqlalchemy_and_duckdb(): + first = DBAPIConnection(duckdb.connect(":memory:")) + first.execute("CREATE TABLE numbers (x INTEGER)") + first.execute("INSERT INTO numbers VALUES (1), (2), (3), (4), (5)") + first.execute("CREATE TABLE characters (c VARCHAR)") + first.execute("INSERT INTO characters VALUES ('a'), ('b'), ('c'), ('d'), ('e')") + + mock = Mock() + mock.displaylimit = 10 + mock.autolimit = 0 + + statement = "SELECT * FROM numbers" + first_set = ResultSet( + first.raw_execute(statement), mock, statement=statement, conn=first + ) + + original_id = id(first_set._sqlaproxy) + + # create a new resultset so the other one is no longer the latest one + statement = "SELECT * FROM characters" + ResultSet(first.raw_execute(statement), mock, statement=statement, conn=first) + + # force fetching data, this should not trigger a refresh + list(first_set) + + assert id(first_set._sqlaproxy) == original_id + + +def test_doesnt_refresh_sqlaproxy_if_different_connection(): + first = SQLAlchemyConnection(create_engine("duckdb://")) + first.execute("CREATE TABLE numbers (x INTEGER)") + first.execute("INSERT INTO numbers VALUES (1), (2), (3), (4), (5)") + + second = SQLAlchemyConnection(create_engine("duckdb://")) + second.execute("CREATE TABLE characters (c VARCHAR)") + second.execute("INSERT INTO characters VALUES ('a'), ('b'), ('c'), ('d'), ('e')") + + mock = Mock() + mock.displaylimit = 10 + mock.autolimit = 0 + + statement = "SELECT * FROM numbers" + first_set = ResultSet( + first.raw_execute(statement), mock, statement=statement, conn=first + ) + + original_id = id(first_set._sqlaproxy) + + statement = "SELECT * FROM characters" + ResultSet(second.raw_execute(statement), mock, statement=statement, conn=second) + + # force fetching data + list(first_set) + + assert id(first_set._sqlaproxy) == original_id + + +@pytest.mark.parametrize( + "function, expected_warning, dataset", + [ + ( + "pie", + ( + ".pie() is deprecated and will be removed in a future version. " + "Use %sqlplot pie instead. " + "For more help, find us at https://ploomber.io/community " + ), + { + "x": [1, 2, 3], + "y": [4, 5, 6], + }, + ), + ( + "bar", + ( + ".bar() is deprecated and will be removed in a future version. " + "Use %sqlplot bar instead. " + "For more help, find us at https://ploomber.io/community " + ), + { + "x": [1, 2, 3], + }, + ), + ( + "plot", + ( + ".plot() is deprecated and will be removed in a future version. " + "For more help, find us at https://ploomber.io/community " + ), + { + "x": [1, 2, 3], + }, + ), + ], +) +def test_calling_legacy_plotting_functions_displays_warning( + config, function, expected_warning, dataset +): + df = pd.DataFrame(dataset) # noqa + engine = sqlalchemy.create_engine("duckdb://") + conn = SQLAlchemyConnection(engine) + result = conn.raw_execute("select * from df") + + rs = ResultSet(result, config, statement="select * from df", conn=conn) + + with warnings.catch_warnings(record=True) as record: + getattr(rs, function)() + + assert len(record) == 1 + assert str(record[0].message) == expected_warning + + +@pytest.mark.xfail(reason="Failing intermittently with DuckDB v0.10.0") +@pytest.mark.parametrize( + "df_type, library, equal_func", + [ + ( + "autopandas", + pd, + "equals", + ), + ( + "autopolars", + pl, + "frame_equal", + ), + ], +) +def test_pivot_dataframe_conversion_results(ip, df_type, library, equal_func): + # Setup connection, data + ip.run_cell( + """import duckdb +conn = duckdb.connect()""" + ) + ip.run_cell("%sql conn --alias duckdb-mem") + ip.run_cell( + """ + %%sql +CREATE OR REPLACE TABLE Cities(Country VARCHAR, Name VARCHAR, Year INT, Population INT); +INSERT INTO Cities VALUES ('NL', 'Amsterdam', 2000, 1005); +INSERT INTO Cities VALUES ('NL', 'Amsterdam', 2010, 1065); +INSERT INTO Cities VALUES ('NL', 'Amsterdam', 2020, 1158); +INSERT INTO Cities VALUES ('US', 'Seattle', 2000, 564); +INSERT INTO Cities VALUES ('US', 'Seattle', 2010, 608); +INSERT INTO Cities VALUES ('US', 'Seattle', 2020, 738); +INSERT INTO Cities VALUES ('US', 'New York City', 2000, 8015); +INSERT INTO Cities VALUES ('US', 'New York City', 2010, 8175); +INSERT INTO Cities VALUES ('US', 'New York City', 2020, 8772); + """ + ) + + # Run Pivot statement as baseline + expected = ip.run_cell( + """%%sql + PIVOT Cities ON Year USING SUM(Population)""" + ).result + + # Turn on auto-convert (also do with autopolars) + ip.run_cell(f"%config SqlMagic.{df_type} = True") + + # Run Pivot statement again and ensure equal + result = ip.run_cell( + """%%sql + PIVOT Cities ON Year USING SUM(Population)""" + ).result + + # Assert result matches expected + expected_result = { + "Country": ["US", "US", "NL"], + "Name": ["New York City", "Seattle", "Amsterdam"], + "2000": [8015.0, 564.0, 1005.0], + "2010": [8175.0, 608.0, 1065.0], + "2020": [8772.0, 738.0, 1158.0], + } + expected = getattr(library, "DataFrame")(expected_result) + assert getattr(result, equal_func)(expected) diff --git a/src/tests/test_run.py b/src/tests/test_run.py new file mode 100644 index 000000000..6557c1540 --- /dev/null +++ b/src/tests/test_run.py @@ -0,0 +1,155 @@ +import sqlite3 +from unittest.mock import Mock + +from IPython.core.error import UsageError +import pandas +import polars +import pytest +from sqlalchemy import create_engine +import duckdb + +from sql.connection import SQLAlchemyConnection, DBAPIConnection +from sql.run.run import ( + run_statements, + is_postgres_or_redshift, + select_df_type, +) +from sql.run.pgspecial import handle_postgres_special +from sql.run.resultset import ResultSet + + +@pytest.fixture +def mock_conns(): + conn = SQLAlchemyConnection(Mock()) + conn.connection_sqlalchemy.execution_options.side_effect = ValueError + return conn + + +class Config: + autopandas = None + autopolars = None + autocommit = True + feedback = True + polars_dataframe_kwargs = {} + style = "DEFAULT" + autolimit = 0 + displaylimit = 10 + + +class ConfigPandas(Config): + autopandas = True + autopolars = False + + +class ConfigPolars(Config): + autopandas = False + autopolars = True + + +@pytest.fixture +def pytds_conns(mock_conns): + mock_conns._dialect = "mssql+pytds" + return mock_conns + + +@pytest.fixture +def mock_resultset(): + class ResultSet: + def __init__(self, *args, **kwargs): + pass + + @classmethod + def DataFrame(cls): + return pandas.DataFrame() + + @classmethod + def PolarsDataFrame(cls): + return polars.DataFrame() + + return ResultSet + + +@pytest.mark.parametrize( + "dialect", + [ + "postgres", + "redshift", + ], +) +def test_is_postgres_or_redshift(dialect): + assert is_postgres_or_redshift(dialect) is True + + +def test_handle_postgres_special(mock_conns): + with pytest.raises(UsageError) as excinfo: + handle_postgres_special(mock_conns, "\\") + + assert "pgspecial not installed" in str(excinfo.value) + + +def test_select_df_type_is_pandas(mock_resultset): + output = select_df_type(mock_resultset, ConfigPandas) + assert isinstance(output, pandas.DataFrame) + + +def test_select_df_type_is_polars(mock_resultset): + output = select_df_type(mock_resultset, ConfigPolars) + assert isinstance(output, polars.DataFrame) + + +def test_sql_starts_with_begin(mock_conns): + with pytest.raises(UsageError, match="does not support transactions") as excinfo: + run_statements(mock_conns, "BEGIN", Config) + + assert excinfo.value.error_type == "RuntimeError" + + +def test_sql_is_empty(mock_conns): + assert run_statements(mock_conns, " ", Config) == "Connected: %s" % mock_conns.name + + +@pytest.mark.parametrize( + "connection", + [ + SQLAlchemyConnection(create_engine("duckdb://")), + SQLAlchemyConnection(create_engine("sqlite://")), + DBAPIConnection(duckdb.connect()), + DBAPIConnection(sqlite3.connect("")), + ], + ids=[ + "duckdb-sqlalchemy", + "sqlite-sqlalchemy", + "duckdb", + "sqlite", + ], +) +@pytest.mark.parametrize( + "config, expected_type", + [ + [Config, ResultSet], + [ConfigPandas, pandas.DataFrame], + [ConfigPolars, polars.DataFrame], + ], +) +@pytest.mark.parametrize( + "sql", + [ + "SELECT 1", + "SELECT 1; SELECT 2;", + ], + ids=["single", "multiple"], +) +def test_run(connection, config, expected_type, sql): + out = run_statements(connection, sql, config) + assert isinstance(out, expected_type) + + +def test_do_not_fail_if_sqlalchemy_autocommit_not_supported(): + conn = SQLAlchemyConnection(create_engine("sqlite://")) + conn.connection_sqlalchemy.execution_options = Mock( + side_effect=Exception("AUTOCOMMIT not supported!") + ) + + run_statements(conn, "SELECT 1", Config) + + # TODO: test .commit called or not depending on config! diff --git a/src/tests/test_store.py b/src/tests/test_store.py new file mode 100644 index 000000000..faa8120f5 --- /dev/null +++ b/src/tests/test_store.py @@ -0,0 +1,346 @@ +import pytest +from sql.connection import SQLAlchemyConnection, ConnectionManager +from IPython.core.error import UsageError +from sql import store +from sqlalchemy import create_engine + + +@pytest.fixture(autouse=True) +def setup_no_current_connect(monkeypatch): + monkeypatch.setattr(ConnectionManager, "current", None) + + +@pytest.fixture +def ip_snippets(ip): + ip.run_cell( + """ +%%sql --save a --no-execute +SELECT * +FROM number_table +""" + ) + ip.run_cell( + """ + %%sql --save b --no-execute + SELECT * + FROM a + WHERE x > 5 + """ + ) + ip.run_cell( + """ + %%sql --save c --no-execute + SELECT * + FROM a + WHERE x < 5 + """ + ) + yield ip + + +def test_sqlstore_setitem(): + sql_store = store.SQLStore() + sql_store["a"] = "SELECT * FROM a" + assert sql_store["a"] == "SELECT * FROM a" + + +def test_sqlstore_getitem_success(): + sql_store = store.SQLStore() + sql_store["first"] = "SELECT * FROM a" + assert sql_store["first"] == "SELECT * FROM a" + + +@pytest.mark.parametrize( + "key, expected_error", + [ + ( + "second", + ( + '"second" is not a valid snippet identifier.' + ' Valid identifiers are "first".' + ), + ), + ( + "firs", + '"firs" is not a valid snippet identifier. Did you mean "first"?', + ), + ], + ids=[ + "invalid-key", + "close-match-key", + ], +) +def test_sqlstore_getitem(key, expected_error): + sql_store = store.SQLStore() + sql_store["first"] = "SELECT * FROM a" + + with pytest.raises(UsageError) as excinfo: + sql_store[key] + + assert excinfo.value.error_type == "UsageError" + assert str(excinfo.value) == expected_error + + +def test_sqlstore_getitem_with_multiple_existing_snippets(): + sql_store = store.SQLStore() + sql_store["first"] = "SELECT * FROM a" + sql_store["first2"] = "SELECT * FROM a" + + with pytest.raises(UsageError) as excinfo: + sql_store["second"] + + assert excinfo.value.error_type == "UsageError" + assert ( + str(excinfo.value) + == '"second" is not a valid snippet identifier. ' + + 'Valid identifiers are "first", "first2".' + ) + + +def test_hyphen(): + sql_store = store.SQLStore() + + with pytest.raises(UsageError) as excinfo: + store.SQLQuery(sql_store, "SELECT * FROM a", with_=["first-"]) + + assert "Using hyphens is not allowed." in str(excinfo.value) + + +def test_key(): + sql_store = store.SQLStore() + + with pytest.raises(UsageError) as excinfo: + sql_store.store("first", "SELECT * FROM first WHERE x > 20", with_=["first"]) + + assert "cannot appear in with_ argument" in str(excinfo.value) + + +@pytest.mark.parametrize( + "is_dialect_support_backtick", + [(True), (False)], +) +@pytest.mark.parametrize( + "with_", + [ + ["third"], + ["first", "third"], + ["first", "third", "first"], + ["third", "first"], + ], + ids=[ + "simple", + "redundant", + "duplicated", + "redundant-end", + ], +) +def test_serial(with_, is_dialect_support_backtick, monkeypatch): + """To test if SQLStore can store multiple with sql clause + and parse into final combined sql clause + + Parameters + ---------- + with_ : string + The key to use in with sql clause + is_dialect_support_backtick : bool + If the current connected dialect support `(backtick) syntax + monkeypatch : Monkeypatch + A convenient fixture for monkey-patching + """ + conn = SQLAlchemyConnection(engine=create_engine("sqlite://")) + + monkeypatch.setattr( + conn, + "is_use_backtick_template", + lambda: is_dialect_support_backtick, + ) + identifier = "`" if is_dialect_support_backtick else "" + + sql_store = store.SQLStore() + sql_store.store("first", "SELECT * FROM a WHERE x > 10") + sql_store.store("second", "SELECT * FROM first WHERE x > 20", with_=["first"]) + + sql_store.store( + "third", "SELECT * FROM second WHERE x > 30", with_=["second", "first"] + ) + + result = sql_store.render("SELECT * FROM third", with_=with_) + + assert ( + str(result) + == "WITH {0}first{0} AS (SELECT * FROM a WHERE x > 10), \ +{0}second{0} AS (SELECT * FROM first WHERE x > 20), \ +{0}third{0} AS (SELECT * FROM second WHERE x > 30)SELECT * FROM third".format( + identifier + ) + ) + + +@pytest.mark.parametrize( + "is_dialect_support_backtick", + [(True), (False)], +) +def test_branch_root(is_dialect_support_backtick, monkeypatch): + """To test if SQLStore can store multiple with sql clause, + but with each with clause has it's own sub-query. + To see if SQLStore can parse into final combined sql clause + + Parameters + ---------- + with_ : string + The key to use in with sql clause + is_dialect_support_backtick : bool + If the current connected dialect support `(backtick) syntax + monkeypatch : Monkeypatch + A convenient fixture for monkey-patching + """ + conn = SQLAlchemyConnection(engine=create_engine("sqlite://")) + monkeypatch.setattr( + conn, + "is_use_backtick_template", + lambda: is_dialect_support_backtick, + ) + identifier = "`" if is_dialect_support_backtick else "" + + sql_store = store.SQLStore() + sql_store.store("first_a", "SELECT * FROM a WHERE x > 10") + sql_store.store("second_a", "SELECT * FROM first_a WHERE x > 20", with_=["first_a"]) + sql_store.store( + "third_a", "SELECT * FROM second_a WHERE x > 30", with_=["second_a"] + ) + + sql_store.store("first_b", "SELECT * FROM b WHERE y > 10") + + result = sql_store.render("SELECT * FROM third", with_=["third_a", "first_b"]) + assert ( + str(result) + == "WITH {0}first_a{0} AS (SELECT * FROM a WHERE x > 10), \ +{0}second_a{0} AS (SELECT * FROM first_a WHERE x > 20), \ +{0}third_a{0} AS (SELECT * FROM second_a WHERE x > 30), \ +{0}first_b{0} AS (SELECT * FROM b WHERE y > 10)SELECT * FROM third".format( + identifier + ) + ) + + +@pytest.mark.parametrize( + "is_dialect_support_backtick", + [(True), (False)], +) +def test_branch_root_reverse_final_with(is_dialect_support_backtick, monkeypatch): + """To test if SQLStore can store multiple with sql clause, + but with different reverse order in with_ parameter. + To see if SQLStore can parse into final combined sql clause + + Parameters + ---------- + with_ : string + The key to use in with sql clause + is_dialect_support_backtick : bool + If the current connected dialect support `(backtick) syntax + monkeypatch : Monkeypatch + A convenient fixture for monkey-patching + """ + conn = SQLAlchemyConnection(engine=create_engine("sqlite://")) + + monkeypatch.setattr( + conn, + "is_use_backtick_template", + lambda: is_dialect_support_backtick, + ) + identifier = "`" if is_dialect_support_backtick else "" + + sql_store = store.SQLStore() + + sql_store.store("first_a", "SELECT * FROM a WHERE x > 10") + sql_store.store("second_a", "SELECT * FROM first_a WHERE x > 20", with_=["first_a"]) + sql_store.store( + "third_a", "SELECT * FROM second_a WHERE x > 30", with_=["second_a"] + ) + + sql_store.store("first_b", "SELECT * FROM b WHERE y > 10") + + result = sql_store.render("SELECT * FROM third", with_=["first_b", "third_a"]) + assert ( + str(result) + == "WITH {0}first_a{0} AS (SELECT * FROM a WHERE x > 10), \ +{0}second_a{0} AS (SELECT * FROM first_a WHERE x > 20), \ +{0}first_b{0} AS (SELECT * FROM b WHERE y > 10), \ +{0}third_a{0} AS (SELECT * FROM second_a WHERE x > 30)SELECT * FROM third".format( + identifier + ) + ) + + +@pytest.mark.parametrize( + "is_dialect_support_backtick", + [(True), (False)], +) +def test_branch(is_dialect_support_backtick, monkeypatch): + """To test if SQLStore can store multiple with sql clause, + but some sub-queries have same with_ dependency. + To see if SQLStore can parse into final combined sql clause + + Parameters + ---------- + with_ : string + The key to use in with sql clause + monkeypatch : Monkeypatch + A convenient fixture for monkey-patching + """ + conn = SQLAlchemyConnection(engine=create_engine("sqlite://")) + + monkeypatch.setattr( + conn, + "is_use_backtick_template", + lambda: is_dialect_support_backtick, + ) + identifier = "`" if is_dialect_support_backtick else "" + + sql_store = store.SQLStore() + + sql_store.store("first_a", "SELECT * FROM a WHERE x > 10") + sql_store.store("second_a", "SELECT * FROM first_a WHERE x > 20", with_=["first_a"]) + sql_store.store( + "third_a", "SELECT * FROM second_a WHERE x > 30", with_=["second_a"] + ) + + sql_store.store( + "first_b", "SELECT * FROM second_a WHERE y > 10", with_=["second_a"] + ) + + result = sql_store.render("SELECT * FROM third", with_=["first_b", "third_a"]) + assert ( + str(result) + == "WITH {0}first_a{0} AS (SELECT * FROM a WHERE x > 10), \ +{0}second_a{0} AS (SELECT * FROM first_a WHERE x > 20), \ +{0}first_b{0} AS (SELECT * FROM second_a WHERE y > 10), \ +{0}third_a{0} AS (SELECT * FROM second_a WHERE x > 30)SELECT * FROM third".format( + identifier + ) + ) + + +def test_get_all_keys(ip_snippets): + keys = store.get_all_keys() + assert "a" in keys + assert "b" in keys + assert "c" in keys + + +def test_get_key_dependents(ip_snippets): + keys = store.get_key_dependents("a") + assert "b" in keys + assert "c" in keys + + +def test_del_saved_key(ip_snippets): + keys = store.del_saved_key("c") + assert "a" in keys + assert "b" in keys + + +def test_del_saved_key_error(ip_snippets): + with pytest.raises(UsageError) as excinfo: + store.del_saved_key("non_existent_key") + assert "No such saved snippet found : non_existent_key" in str(excinfo.value) diff --git a/src/tests/test_syntax_errors.py b/src/tests/test_syntax_errors.py new file mode 100644 index 000000000..d4021543d --- /dev/null +++ b/src/tests/test_syntax_errors.py @@ -0,0 +1,89 @@ +import pytest +import sqlalchemy.exc + +from sqlalchemy.exc import OperationalError +from IPython.core.error import UsageError + +from sql.error_handler import ORIGINAL_ERROR, CTE_MSG +from ploomber_core.exceptions import COMMUNITY + + +COMMUNITY = COMMUNITY.strip() + + +@pytest.fixture +def query_incorrect_column_name(): + return ( + "sql", + "", + """ + sqlite:// + SELECT first_(name FROM author; + """, + ) + + +@pytest.fixture +def query_multiple(): + return ( + "sql", + "", + """ + sqlite:// + INSERT INTO author VALUES ('Charles', 'Dickens', 1812); + ALTER TABLE author RENAME another_name; + """, + ) + + +def _common_strings_check(err): + assert ORIGINAL_ERROR.strip() in str(err.value) + assert CTE_MSG.strip() in str(err.value) + assert COMMUNITY in str(err.value) + assert isinstance(err.value, UsageError) + + +def test_syntax_error_incorrect_column_name(ip, query_incorrect_column_name): + with pytest.raises(UsageError) as err: + ip.run_cell_magic(*query_incorrect_column_name) + _common_strings_check(err) + + +message_incorrect_column_name_long = f"""\ +(sqlite3.OperationalError) near "FROM": syntax error +{COMMUNITY} +[SQL: SELECT first_(name FROM author;] +""" # noqa + + +def test_syntax_error_incorrect_column_name_long( + ip, capsys, query_incorrect_column_name +): + ip.run_line_magic("config", "SqlMagic.short_errors = False") + with pytest.raises(OperationalError) as err: + ip.run_cell_magic(*query_incorrect_column_name) + out, _ = capsys.readouterr() + assert message_incorrect_column_name_long.strip() in str(err.value).strip() + assert isinstance(err.value, sqlalchemy.exc.OperationalError) + + +def test_syntax_error_multiple_statements(ip, query_multiple): + with pytest.raises(UsageError) as err: + ip.run_cell_magic(*query_multiple) + _common_strings_check(err) + + +message_multiple_statements_long = f"""\ +(sqlite3.OperationalError) near ";": syntax error +{COMMUNITY} +[SQL: ALTER TABLE author RENAME another_name;] +""" # noqa + + +def test_syntax_error_multiple_statements_long(ip, capsys, query_multiple): + ip.run_line_magic("config", "SqlMagic.short_errors = False") + with pytest.raises(OperationalError) as err: + ip.run_cell_magic(*query_multiple) + out, _ = capsys.readouterr() + assert message_multiple_statements_long.strip() in str(err.value).strip() + assert isinstance(err.value, sqlalchemy.exc.OperationalError) diff --git a/src/tests/test_testing.py b/src/tests/test_testing.py new file mode 100644 index 000000000..ee8031d75 --- /dev/null +++ b/src/tests/test_testing.py @@ -0,0 +1,18 @@ +import pytest + +from sql._testing import TestingShell + + +@pytest.fixture(scope="module") +def ip(): + return TestingShell() + + +def test_testingshell_raises_code_errors(ip): + with pytest.raises(ZeroDivisionError): + ip.run_cell("1 / 0") + + +def test_testingshell_raises_syntax_errors(ip): + with pytest.raises(SyntaxError): + ip.run_cell("SELECT * FROM penguins.csv where species = :species") diff --git a/src/tests/test_util.py b/src/tests/test_util.py new file mode 100644 index 000000000..9d81401f3 --- /dev/null +++ b/src/tests/test_util.py @@ -0,0 +1,526 @@ +from datetime import datetime +from IPython.core.error import UsageError +import pytest +from sql import util +import json +from sql.magic import SqlMagic +from sql.magic_cmd import SqlCmdMagic +from sql.magic_plot import SqlPlotMagic + +ERROR_MESSAGE = "Table cannot be None" +EXPECTED_STORE_SUGGESTIONS = ( + "but there is a stored query.\nDid you miss passing --with {0}?" +) + + +@pytest.mark.parametrize( + "store_table, query", + [ + pytest.param( + "a", + "%sqlcmd columns --table {}", + marks=pytest.mark.xfail(reason="this is not working yet, see #658"), + ), + pytest.param( + "bbb", + "%sqlcmd profile --table {}", + marks=pytest.mark.xfail(reason="this is not working yet, see #658"), + ), + ("c_c", "%sqlplot histogram --table {} --column x"), + ("d_d_d", "%sqlplot boxplot --table {} --column x"), + ], + ids=[ + "columns", + "profile", + "histogram", + "boxplot", + ], +) +def test_no_errors_with_stored_query(ip_empty, store_table, query): + ip_empty.run_cell("%sql duckdb://") + + ip_empty.run_cell( + """%%sql +CREATE TABLE numbers ( + x FLOAT +); + +INSERT INTO numbers (x) VALUES (1), (2), (3); +""" + ) + + ip_empty.run_cell( + f""" + %%sql --save {store_table} --no-execute + SELECT * + FROM numbers + """ + ) + + out = ip_empty.run_cell(query.format(store_table, store_table)) + assert out.success + + +@pytest.mark.parametrize( + "src, ltypes, expected", + [ + # 1-D flatten + ([1, 2, 3], list, [1, 2, 3]), + # 2-D flatten + ([(1, 2), 3], None, [1, 2, 3]), + ([(1, 2), 3], tuple, [1, 2, 3]), + ([[[1, 2], 3]], list, [1, 2, 3]), + (([[1, 2], 3]), None, [1, 2, 3]), + (((1, 2), 3), tuple, (1, 2, 3)), + (((1, 2), 3), None, (1, 2, 3)), + (([1, 2], 3), None, (1, 2, 3)), + (([1, 2], 3), list, (1, 2, 3)), + # 3-D flatten + (([[1, 2]], 3), list, (1, 2, 3)), + (([[1, 2]], 3), None, (1, 2, 3)), + ], +) +def test_flatten(src, ltypes, expected): + if ltypes: + assert util.flatten(src, ltypes) == expected + else: + assert util.flatten(src) == expected + + +date_format = "%Y-%m-%d %H:%M:%S" + + +@pytest.mark.parametrize( + "rows, columns, expected_json", + [ + ([(1, 2), (3, 4)], ["x", "y"], [{"x": 1, "y": 2}, {"x": 3, "y": 4}]), + ([(1,), (3,)], ["x"], [{"x": 1}, {"x": 3}]), + ( + [ + ("a", datetime.strptime("2021-01-01 00:30:10", date_format)), + ("b", datetime.strptime("2021-02-01 00:30:10", date_format)), + ], + ["id", "datetime"], + [ + { + "datetime": "2021-01-01 00:30:10", + "id": "a", + }, + { + "datetime": "2021-02-01 00:30:10", + "id": "b", + }, + ], + ), + ( + [(None, "a1", "b1"), (None, "a2", "b2")], + ["x", "y", "z"], + [ + { + "x": "None", + "y": "a1", + "z": "b1", + }, + { + "x": "None", + "y": "a2", + "z": "b2", + }, + ], + ), + ], +) +def test_parse_sql_results_to_json(ip, capsys, rows, columns, expected_json): + j = util.parse_sql_results_to_json(rows, columns) + j = json.loads(j) + with capsys.disabled(): + assert str(j) == str(expected_json) + + +@pytest.mark.parametrize( + "string, substrings, expected", + [ + ["some-string", ["some", "another"], True], + ["some-string", ["another", "word"], False], + ], +) +def test_is_sqlalchemy_error(string, substrings, expected): + result = util.if_substring_exists(string, substrings) + assert result == expected + + +@pytest.mark.parametrize( + "args, aliases", + [ + # for creator/c + ( + ["--creator", "--creator"], + [], + ), + ( + ["-c", "-c"], + [], + ), + ( + ["--creator", "-c"], + [("c", "creator")], + ), + # for persist/p + ( + ["--persist", "--persist"], + [], + ), + ( + ["-p", "-p"], + [], + ), + ( + ["--persist", "-p"], + [("p", "persist")], + ), + # for no-index/n + ( + ["--persist", "--no-index", "--no-index"], + [], + ), + ( + ["--persist", "-n", "-n"], + [], + ), + ( + ["--persist", "--no-index", "-n"], + [("n", "no-index")], + ), + # for file/f + ( + ["--file", "--file"], + [], + ), + ( + ["-f", "-f"], + [], + ), + ( + ["--file", "-f"], + [("f", "file")], + ), + # for save/S + ( + ["--save", "--save"], + [], + ), + ( + ["-S", "-S"], + [], + ), + ( + ["--save", "-S"], + [("S", "save")], + ), + # for alias/A + ( + ["--alias", "--alias"], + [], + ), + ( + ["-A", "-A"], + [], + ), + ( + ["--alias", "-A"], + [("A", "alias")], + ), + # for connections/l + ( + ["--connections", "--connections"], + [], + ), + ( + ["-l", "-l"], + [], + ), + ( + ["--connections", "-l"], + [("l", "connections")], + ), + # for close/x + ( + ["--close", "--close"], + [], + ), + ( + ["-x", "-x"], + [], + ), + ( + ["--close", "-x"], + [("x", "close")], + ), + # for mixed + ( + ["--creator", "--creator", "-c", "--persist", "--file", "-f", "-c"], + [("c", "creator"), ("f", "file")], + ), + ], +) +def test_check_duplicate_arguments_raises_usageerror_for_sql_magic( + check_duplicate_message_factory, + args, + aliases, +): + with pytest.raises(UsageError) as excinfo: + util.check_duplicate_arguments( + SqlMagic.execute, + "sql", + args, + ["-w", "--with", "--append", "--interact"], + ) + assert check_duplicate_message_factory("sql", args, aliases) in str(excinfo.value) + + +@pytest.mark.parametrize( + "args, aliases", + [ + # for table/t + ( + ["--table", "--table", "--column"], + [], + ), + ( + ["-t", "-t", "--column"], + [], + ), + ( + ["--table", "-t", "--column"], + [("t", "table")], + ), + # for column/c + ( + ["--table", "--column", "--column"], + [], + ), + ( + ["--table", "-c", "-c"], + [], + ), + ( + ["--table", "--column", "-c"], + [("c", "column")], + ), + # for bins/b + ( + ["--table", "--column", "--bins", "--bins"], + [], + ), + ( + ["--table", "--column", "-b", "-b"], + [], + ), + ( + ["--table", "--column", "--bins", "-b"], + [("b", "bins")], + ), + # for breaks/B + ( + ["--table", "--column", "--breaks", "--breaks"], + [], + ), + ( + ["--table", "--column", "-B", "-B"], + [], + ), + ( + ["--table", "--column", "--breaks", "-B"], + [("B", "breaks")], + ), + # for binwidth/W + ( + ["--table", "--column", "--binwidth", "--binwidth"], + [], + ), + ( + ["--table", "--column", "-W", "-W"], + [], + ), + ( + ["--table", "--column", "--binwidth", "-W"], + [("W", "binwidth")], + ), + # for orient/o + ( + ["--table", "--column", "--orient", "--orient"], + [], + ), + ( + ["--table", "--column", "-o", "-o"], + [], + ), + ( + ["--table", "--column", "--orient", "-o"], + [("o", "orient")], + ), + # for show-numbers/S + ( + ["--table", "--column", "--show-numbers", "--show-numbers"], + [], + ), + ( + ["--table", "--column", "-S", "-S"], + [], + ), + ( + ["--table", "--column", "--show-numbers", "-S"], + [("S", "show-numbers")], + ), + # for mixed + ( + [ + "--table", + "--column", + "--column", + "-w", + "--with", + "--show-numbers", + "--show-numbers", + "--binwidth", + "--orient", + "-o", + "--breaks", + "-B", + ], + [("w", "with"), ("o", "orient"), ("B", "breaks")], + ), + ], +) +def test_check_duplicate_arguments_raises_usageerror_for_sqlplot( + check_duplicate_message_factory, + args, + aliases, +): + with pytest.raises(UsageError) as excinfo: + util.check_duplicate_arguments( + SqlPlotMagic.execute, + "sqlplot", + args, + ["-w", "--with"], + ) + + assert check_duplicate_message_factory("sqlplot", args, aliases) in str( + excinfo.value + ) + + +DISALLOWED_ALIASES = { + "sqlcmd": { + "-t": "--table", + "-s": "--schema", + "-o": "--output", + }, +} + + +@pytest.mark.parametrize( + "args, aliases", + [ + # for schema/s + ( + ["--schema", "--schema"], + [], + ), + ( + ["-s", "-s"], + [], + ), + ( + ["--schema", "-s"], + [("s", "schema")], + ), + # for table/t + ( + ["--table", "--table"], + [], + ), + ( + ["-t", "-t"], + [], + ), + ( + ["--table", "-t"], + [("t", "table")], + ), + # for mixed + ( + ["--table", "-t", "-s", "-s", "--schema"], + [("t", "table"), ("s", "schema")], + ), + ], +) +def test_check_duplicate_arguments_raises_usageerror_for_sqlcmd( + check_duplicate_message_factory, + args, + aliases, +): + with pytest.raises(UsageError) as excinfo: + util.check_duplicate_arguments( + SqlCmdMagic.execute, + "sqlcmd", + args, + [], + DISALLOWED_ALIASES["sqlcmd"], + ) + assert check_duplicate_message_factory("sqlcmd", args, aliases) in str( + excinfo.value + ) + + +ALLOWED_DUPLICATES = { + "sql": ["-w", "--with", "--append", "--interact"], + "sqlplot": ["-w", "--with"], + "sqlcmd": [], +} + + +@pytest.mark.parametrize( + "magic_execute, args, cmd_from", + [ + (SqlMagic.execute, ["--creator"], "sql"), + (SqlMagic.execute, ["-c"], "sql"), + (SqlMagic.execute, ["--persist"], "sql"), + (SqlMagic.execute, ["-p"], "sql"), + (SqlMagic.execute, ["--persist", "--no-index"], "sql"), + (SqlMagic.execute, ["--persist", "-n"], "sql"), + (SqlMagic.execute, ["--file"], "sql"), + (SqlMagic.execute, ["-f"], "sql"), + (SqlMagic.execute, ["--save"], "sql"), + (SqlMagic.execute, ["-S"], "sql"), + (SqlMagic.execute, ["--alias"], "sql"), + (SqlMagic.execute, ["-A"], "sql"), + (SqlMagic.execute, ["--connections"], "sql"), + (SqlMagic.execute, ["-l"], "sql"), + (SqlMagic.execute, ["--close"], "sql"), + (SqlMagic.execute, ["-x"], "sql"), + (SqlPlotMagic.execute, ["--table", "--column"], "sqlplot"), + (SqlPlotMagic.execute, ["--table", "-c"], "sqlplot"), + (SqlPlotMagic.execute, ["-t", "--column"], "sqlplot"), + (SqlPlotMagic.execute, ["--table", "--column", "--breaks"], "sqlplot"), + (SqlPlotMagic.execute, ["--table", "--column", "-B"], "sqlplot"), + (SqlPlotMagic.execute, ["--table", "--column", "--bins"], "sqlplot"), + (SqlPlotMagic.execute, ["--table", "--column", "-b"], "sqlplot"), + (SqlPlotMagic.execute, ["--table", "--column", "--binwidth"], "sqlplot"), + (SqlPlotMagic.execute, ["--table", "--column", "-W"], "sqlplot"), + (SqlPlotMagic.execute, ["--table", "--column", "--orient"], "sqlplot"), + (SqlPlotMagic.execute, ["--table", "--column", "-o"], "sqlplot"), + (SqlPlotMagic.execute, ["--table", "--column", "--show-numbers"], "sqlplot"), + (SqlPlotMagic.execute, ["--table", "--column", "-S"], "sqlplot"), + (SqlCmdMagic.execute, ["--table"], "sqlcmd"), + (SqlCmdMagic.execute, ["-t"], "sqlcmd"), + (SqlCmdMagic.execute, ["--table", "--schema"], "sqlcmd"), + (SqlCmdMagic.execute, ["--table", "-s"], "sqlcmd"), + ], +) +def test_check_duplicate_arguments_does_not_raise_usageerror( + magic_execute, args, cmd_from +): + assert util.check_duplicate_arguments( + magic_execute, cmd_from, args, ALLOWED_DUPLICATES[cmd_from] + ) diff --git a/src/tests/test_widget.py b/src/tests/test_widget.py new file mode 100644 index 000000000..5c7241b2d --- /dev/null +++ b/src/tests/test_widget.py @@ -0,0 +1,166 @@ +from sql.widgets import TableWidget +import pytest +from sql.widgets import utils +import js2py + + +@pytest.mark.parametrize( + "source, function_to_extract, expected", + [ + ( + """ + function aaa() { + return "a" + } + """, + "aaa", + """function aaa() { + return "a" + }""", + ), + ( + """ + function aaa() { + return "a" + } + function bbb() { + return "b" + } + function ccc() { + return "c" + } + """, + "bbb", + """function bbb() { + return "b" + }""", + ), + ( + """ + function aaa() { + return "a" + } + function bbb() { + return "b" + } + function c_c() { + return "c" + } + """, + "c_c", + """function c_c() { + return "c" + }""", + ), + ( + """ + function aaa() { + return "a" + } + function bbb() { + return "b" + } + function c_c() { + return "c" + } + """, + "ddd", + None, + ), + ( + """ + """, + "aaa", + None, + ), + ], +) +def test_widget_utils_extract_function_by_name(source, function_to_extract, expected): + result = utils.extract_function_by_name(source, function_to_extract) + assert result == expected + + +def test_widget_utils_set_template_params(): + result = utils.set_template_params(a=1, b=2, c=3) + + assert result["a"] == 1 + assert result["b"] == 2 + assert result["c"] == 3 + + +def test_widget_utils_load_css(tmpdir): + test_file = str(tmpdir.join("test.css")) + + css_ = """ + .rule_one { + background-color : red; + } + + .rule_two { + background-color: blue; + } + """ + with open(test_file, "w") as file: + file.write(css_) + + style = utils.load_css(test_file) + + expected = f""" + + """ + assert style == expected + + +def test_widget_utils_load_js(tmpdir): + test_file = str(tmpdir.join("test.js")) + + js_ = """ + function aaa() { + return "a" + } + + function bbb() { + return "b" + } + """ + with open(test_file, "w") as file: + file.write(js_) + + js = utils.load_js(test_file) + + expected = f""" + + """ + + assert js == expected + + +@pytest.mark.parametrize( + "rows, expected", + [ + ( + [{"x": 4, "y": -2, "z": 3}, {"x": -5, "y": 0, "z": 4}], + "4-23" + + "-504", + ), + ( + [{"x": 4}, {"x": -5}, {"x": "textual value"}], + "4-5textual value", + ), + ([{"x": 4}], "4"), + ([{"x": None}], "undefined"), + ([{"x": ""}], ""), + ([], ""), + ], +) +def test_table_widget_create_table_rows(ip, rows, expected): + """ + Test the functionality of table rows creation from dict + """ + table_widget = TableWidget("empty_table") + + create_table_rows = js2py.eval_js(table_widget.tests["createTableRows"]) + + table_rows = create_table_rows(rows) + + assert table_rows == expected diff --git a/tox.ini b/tox.ini deleted file mode 100644 index bce18f24a..000000000 --- a/tox.ini +++ /dev/null @@ -1,9 +0,0 @@ -[tox] -envlist = py27,py36 - -[testenv] -deps = pytest - -rrequirements.txt - -rrequirements-dev.txt -commands = - ipython -c "import pytest; pytest.main(['.'])"