From ec5391b244feb627cf49ce3942724e4adc93b6ac Mon Sep 17 00:00:00 2001 From: Daniel Baumann Date: Sun, 9 Feb 2025 19:48:22 +0100 Subject: [PATCH] Adding upstream version 3.1.0. Signed-off-by: Daniel Baumann --- .coveragerc | 3 + .editorconfig | 15 + .git-blame-ignore-revs | 0 .github/ISSUE_TEMPLATE.md | 9 + .github/PULL_REQUEST_TEMPLATE.md | 12 + .gitignore | 71 + .pre-commit-config.yaml | 7 + .travis.yml | 51 + AUTHORS | 120 ++ DEVELOP.rst | 178 ++ Dockerfile | 6 + LICENSE.txt | 26 + MANIFEST.in | 2 + README.rst | 372 ++++ TODO | 12 + Vagrantfile | 93 + changelog.rst | 1064 ++++++++++++ pgcli-completion.bash | 61 + pgcli/__init__.py | 1 + pgcli/__main__.py | 9 + pgcli/completion_refresher.py | 150 ++ pgcli/config.py | 64 + pgcli/key_bindings.py | 127 ++ pgcli/magic.py | 67 + pgcli/main.py | 1516 +++++++++++++++++ pgcli/packages/__init__.py | 0 pgcli/packages/parseutils/__init__.py | 22 + pgcli/packages/parseutils/ctes.py | 141 ++ pgcli/packages/parseutils/meta.py | 170 ++ pgcli/packages/parseutils/tables.py | 170 ++ pgcli/packages/parseutils/utils.py | 140 ++ pgcli/packages/pgliterals/__init__.py | 0 pgcli/packages/pgliterals/main.py | 15 + pgcli/packages/pgliterals/pgliterals.json | 629 +++++++ pgcli/packages/prioritization.py | 51 + pgcli/packages/prompt_utils.py | 35 + pgcli/packages/sqlcompletion.py | 608 +++++++ pgcli/pgbuffer.py | 50 + pgcli/pgclirc | 195 +++ pgcli/pgcompleter.py | 1046 ++++++++++++ pgcli/pgexecute.py | 857 ++++++++++ pgcli/pgstyle.py | 116 ++ pgcli/pgtoolbar.py | 62 + post-install | 4 + post-remove | 4 + pylintrc | 2 + pyproject.toml | 22 + release.py | 135 ++ release_procedure.txt | 13 + requirements-dev.txt | 14 + sanity_checks.txt | 37 + screenshots/image01.png | Bin 0 -> 82111 bytes screenshots/image02.png | Bin 0 -> 11767 bytes screenshots/pgcli.gif | Bin 0 -> 238421 bytes setup.py | 64 + tests/conftest.py | 52 + tests/features/__init__.py | 0 tests/features/auto_vertical.feature | 12 + tests/features/basic_commands.feature | 58 + tests/features/crud_database.feature | 17 + tests/features/crud_table.feature | 22 + tests/features/db_utils.py | 78 + tests/features/environment.py | 192 +++ tests/features/expanded.feature | 29 + tests/features/fixture_data/help.txt | 25 + tests/features/fixture_data/help_commands.txt | 64 + .../fixture_data/mock_pg_service.conf | 4 + tests/features/fixture_utils.py | 28 + tests/features/iocommands.feature | 17 + tests/features/named_queries.feature | 10 + tests/features/specials.feature | 6 + tests/features/steps/__init__.py | 0 tests/features/steps/auto_vertical.py | 99 ++ tests/features/steps/basic_commands.py | 147 ++ tests/features/steps/crud_database.py | 93 + tests/features/steps/crud_table.py | 118 ++ tests/features/steps/expanded.py | 70 + tests/features/steps/iocommands.py | 80 + tests/features/steps/named_queries.py | 57 + tests/features/steps/specials.py | 26 + tests/features/steps/wrappers.py | 67 + tests/features/wrappager.py | 16 + tests/metadata.py | 255 +++ tests/parseutils/test_ctes.py | 137 ++ tests/parseutils/test_function_metadata.py | 19 + tests/parseutils/test_parseutils.py | 269 +++ tests/pytest.ini | 2 + tests/test_completion_refresher.py | 97 ++ tests/test_config.py | 30 + tests/test_exceptionals.py | 0 tests/test_fuzzy_completion.py | 87 + tests/test_main.py | 383 +++++ tests/test_naive_completion.py | 133 ++ tests/test_pgexecute.py | 542 ++++++ tests/test_pgspecial.py | 78 + tests/test_plan.wiki | 38 + tests/test_prioritization.py | 20 + tests/test_prompt_utils.py | 10 + tests/test_rowlimit.py | 79 + ...test_smart_completion_multiple_schemata.py | 727 ++++++++ ...est_smart_completion_public_schema_only.py | 1112 ++++++++++++ tests/test_sqlcompletion.py | 993 +++++++++++ tests/utils.py | 95 ++ tox.ini | 13 + 104 files changed, 15144 insertions(+) create mode 100644 .coveragerc create mode 100644 .editorconfig create mode 100644 .git-blame-ignore-revs create mode 100644 .github/ISSUE_TEMPLATE.md create mode 100644 .github/PULL_REQUEST_TEMPLATE.md create mode 100644 .gitignore create mode 100644 .pre-commit-config.yaml create mode 100644 .travis.yml create mode 100644 AUTHORS create mode 100644 DEVELOP.rst create mode 100644 Dockerfile create mode 100644 LICENSE.txt create mode 100644 MANIFEST.in create mode 100644 README.rst create mode 100644 TODO create mode 100644 Vagrantfile create mode 100644 changelog.rst create mode 100644 pgcli-completion.bash create mode 100644 pgcli/__init__.py create mode 100644 pgcli/__main__.py create mode 100644 pgcli/completion_refresher.py create mode 100644 pgcli/config.py create mode 100644 pgcli/key_bindings.py create mode 100644 pgcli/magic.py create mode 100644 pgcli/main.py create mode 100644 pgcli/packages/__init__.py create mode 100644 pgcli/packages/parseutils/__init__.py create mode 100644 pgcli/packages/parseutils/ctes.py create mode 100644 pgcli/packages/parseutils/meta.py create mode 100644 pgcli/packages/parseutils/tables.py create mode 100644 pgcli/packages/parseutils/utils.py create mode 100644 pgcli/packages/pgliterals/__init__.py create mode 100644 pgcli/packages/pgliterals/main.py create mode 100644 pgcli/packages/pgliterals/pgliterals.json create mode 100644 pgcli/packages/prioritization.py create mode 100644 pgcli/packages/prompt_utils.py create mode 100644 pgcli/packages/sqlcompletion.py create mode 100644 pgcli/pgbuffer.py create mode 100644 pgcli/pgclirc create mode 100644 pgcli/pgcompleter.py create mode 100644 pgcli/pgexecute.py create mode 100644 pgcli/pgstyle.py create mode 100644 pgcli/pgtoolbar.py create mode 100644 post-install create mode 100644 post-remove create mode 100644 pylintrc create mode 100644 pyproject.toml create mode 100644 release.py create mode 100644 release_procedure.txt create mode 100644 requirements-dev.txt create mode 100644 sanity_checks.txt create mode 100644 screenshots/image01.png create mode 100644 screenshots/image02.png create mode 100644 screenshots/pgcli.gif create mode 100644 setup.py create mode 100644 tests/conftest.py create mode 100644 tests/features/__init__.py create mode 100644 tests/features/auto_vertical.feature create mode 100644 tests/features/basic_commands.feature create mode 100644 tests/features/crud_database.feature create mode 100644 tests/features/crud_table.feature create mode 100644 tests/features/db_utils.py create mode 100644 tests/features/environment.py create mode 100644 tests/features/expanded.feature create mode 100644 tests/features/fixture_data/help.txt create mode 100644 tests/features/fixture_data/help_commands.txt create mode 100644 tests/features/fixture_data/mock_pg_service.conf create mode 100644 tests/features/fixture_utils.py create mode 100644 tests/features/iocommands.feature create mode 100644 tests/features/named_queries.feature create mode 100644 tests/features/specials.feature create mode 100644 tests/features/steps/__init__.py create mode 100644 tests/features/steps/auto_vertical.py create mode 100644 tests/features/steps/basic_commands.py create mode 100644 tests/features/steps/crud_database.py create mode 100644 tests/features/steps/crud_table.py create mode 100644 tests/features/steps/expanded.py create mode 100644 tests/features/steps/iocommands.py create mode 100644 tests/features/steps/named_queries.py create mode 100644 tests/features/steps/specials.py create mode 100644 tests/features/steps/wrappers.py create mode 100755 tests/features/wrappager.py create mode 100644 tests/metadata.py create mode 100644 tests/parseutils/test_ctes.py create mode 100644 tests/parseutils/test_function_metadata.py create mode 100644 tests/parseutils/test_parseutils.py create mode 100644 tests/pytest.ini create mode 100644 tests/test_completion_refresher.py create mode 100644 tests/test_config.py create mode 100644 tests/test_exceptionals.py create mode 100644 tests/test_fuzzy_completion.py create mode 100644 tests/test_main.py create mode 100644 tests/test_naive_completion.py create mode 100644 tests/test_pgexecute.py create mode 100644 tests/test_pgspecial.py create mode 100644 tests/test_plan.wiki create mode 100644 tests/test_prioritization.py create mode 100644 tests/test_prompt_utils.py create mode 100644 tests/test_rowlimit.py create mode 100644 tests/test_smart_completion_multiple_schemata.py create mode 100644 tests/test_smart_completion_public_schema_only.py create mode 100644 tests/test_sqlcompletion.py create mode 100644 tests/utils.py create mode 100644 tox.ini diff --git a/.coveragerc b/.coveragerc new file mode 100644 index 0000000..b2713c7 --- /dev/null +++ b/.coveragerc @@ -0,0 +1,3 @@ +[run] +parallel=True +source=pgcli diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..bacb65c --- /dev/null +++ b/.editorconfig @@ -0,0 +1,15 @@ +# editorconfig.org +# Get your text editor plugin at: +# http://editorconfig.org/#download +root = true + +[*] +charset = utf-8 +end_of_line = lf +indent_size = 4 +indent_style = space +insert_final_newline = true +trim_trailing_whitespace = true + +[travis.yml] +indent_size = 2 diff --git a/.git-blame-ignore-revs b/.git-blame-ignore-revs new file mode 100644 index 0000000..e69de29 diff --git a/.github/ISSUE_TEMPLATE.md b/.github/ISSUE_TEMPLATE.md new file mode 100644 index 0000000..b5cdbec --- /dev/null +++ b/.github/ISSUE_TEMPLATE.md @@ -0,0 +1,9 @@ +## Description + + +## Your environment + + +- [ ] Please provide your OS and version information. +- [ ] Please provide your CLI version. +- [ ] What is the output of ``pip freeze`` command. diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..35e8486 --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,12 @@ +## Description + + + + +## Checklist + +- [ ] I've added this contribution to the `changelog.rst`. +- [ ] I've added my name to the `AUTHORS` file (or it's already there). + +- [ ] I installed pre-commit hooks (`pip install pre-commit && pre-commit install`), and ran `black` on my code. +- [x] Please squash merge this pull request (uncheck if you'd like us to merge as multiple commits) diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..170585d --- /dev/null +++ b/.gitignore @@ -0,0 +1,71 @@ +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] + +# C extensions +*.so + +# Distribution / packaging +.Python +env/ +pyvenv/ +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +*.egg-info/ +.installed.cfg +*.egg + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +.pytest_cache + +# Translations +*.mo +*.pot + +# Django stuff: +*.log + +# Sphinx documentation +docs/_build/ + +# PyBuilder +target/ + +# PyCharm +.idea/ +*.iml + +# Vagrant +.vagrant/ + +# Generated Packages +*.deb +*.rpm + +.vscode/ +venv/ diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..b970ac5 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,7 @@ +repos: +- repo: https://github.com/psf/black + rev: stable + hooks: + - id: black + language_version: python3.7 + diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 0000000..8d50fbd --- /dev/null +++ b/.travis.yml @@ -0,0 +1,51 @@ +dist: xenial + +sudo: required + +language: python + +python: + - "3.6" + - "3.7" + - "3.8" + - "3.9-dev" + +before_install: + - which python + - which pip + - pip install -U setuptools + +install: + - pip install --no-cache-dir . + - pip install -r requirements-dev.txt + - pip install keyrings.alt>=3.1 + +script: + - set -e + - coverage run --source pgcli -m py.test + - cd tests + - behave --no-capture + - cd .. + # check for changelog ReST compliance + - rst2html.py --halt=warning changelog.rst >/dev/null + # check for black code compliance, 3.6 only + - if [[ "$TRAVIS_PYTHON_VERSION" == "3.6" ]]; then pip install black && black --check . ; else echo "Skipping black for $TRAVIS_PYTHON_VERSION"; fi + - set +e + +after_success: + - coverage combine + - codecov + +notifications: + webhooks: + urls: + - YOUR_WEBHOOK_URL + on_success: change # options: [always|never|change] default: always + on_failure: always # options: [always|never|change] default: always + on_start: false # default: false + +services: + - postgresql + +addons: + postgresql: "9.6" diff --git a/AUTHORS b/AUTHORS new file mode 100644 index 0000000..baaf758 --- /dev/null +++ b/AUTHORS @@ -0,0 +1,120 @@ +Many thanks to the following contributors. + +Project Lead: +------------- + * Irina Truong + +Core Devs: +---------- + * Amjith Ramanujam + * Darik Gamble + * Stuart Quin + * Joakim Koljonen + * Daniel Rocco + * Karl-Aksel Puulmann + * Dick Marinus + +Contributors: +------------- + * Brett + * Étienne BERSAC (bersace) + * Daniel Schwarz + * inkn + * Jonathan Slenders + * xalley + * TamasNo1 + * François Pietka + * Michael Kaminsky + * Alexander Kukushkin + * Ludovic Gasc (GMLudo) + * Marc Abramowitz + * Nick Hahner + * Jay Zeng + * Dimitar Roustchev + * Dhaivat Pandit + * Matheus Rosa + * Ali Kargın + * Nathan Jhaveri + * David Celis + * Sven-Hendrik Haase + * Çağatay Yüksel + * Tiago Ribeiro + * Vignesh Anand + * Charlie Arnold + * dwalmsley + * Artur Dryomov + * rrampage + * while0pass + * Eric Workman + * xa + * Hans Roman + * Guewen Baconnier + * Dionysis Grigoropoulos + * Jacob Magnusson + * Johannes Hoff + * vinotheassassin + * Jacek Wielemborek + * Fabien Meghazi + * Manuel Barkhau + * Sergii V + * Emanuele Gaifas + * Owen Stephens + * Russell Davies + * AlexTes + * Hraban Luyat + * Jackson Popkin + * Gustavo Castro + * Alexander Schmolck + * Donnell Muse + * Andrew Speed + * Dmitry B + * Isank + * Marcin Sztolcman + * Bojan Delić + * Chris Vaughn + * Frederic Aoustin + * Pierre Giraud + * Andrew Kuchling + * Dan Clark + * Catherine Devlin + * Jason Ribeiro + * Rishi Ramraj + * Matthieu Guilbert + * Alexandr Korsak + * Saif Hakim + * Artur Balabanov + * Kenny Do + * Max Rothman + * Daniel Egger + * Ignacio Campabadal + * Mikhail Elovskikh (wronglink) + * Marcin Cieślak (saper) + * easteregg (verfriemelt-dot-org) + * Scott Brenstuhl (808sAndBR) + * Nathan Verzemnieks + * raylu + * Zhaolong Zhu + * Zane C. Bowers-Hadley + * Telmo "Trooper" (telmotrooper) + * Alexander Zawadzki + * Pablo A. Bianchi (pabloab) + * Sebastian Janko (sebojanko) + * Pedro Ferrari (petobens) + * Martin Matejek (mmtj) + * Jonas Jelten + * BrownShibaDog + * George Thomas(thegeorgeous) + * Yoni Nakache(lazydba247) + * Gantsev Denis + * Stephano Paraskeva + * Panos Mavrogiorgos (pmav99) + * Igor Kim (igorkim) + * Anthony DeBarros (anthonydb) + * Seungyong Kwak (GUIEEN) + * Tom Caruso (tomplex) + * Jan Brun Rasmussen (janbrunrasmussen) + * Kevin Marsh (kevinmarsh) + +Creator: +-------- +Amjith Ramanujam diff --git a/DEVELOP.rst b/DEVELOP.rst new file mode 100644 index 0000000..18adf9c --- /dev/null +++ b/DEVELOP.rst @@ -0,0 +1,178 @@ +Development Guide +----------------- +This is a guide for developers who would like to contribute to this project. + +GitHub Workflow +--------------- + +If you're interested in contributing to pgcli, first of all my heart felt +thanks. `Fork the project `_ on github. Then +clone your fork into your computer (``git clone ``). Make +the changes and create the commits in your local machine. Then push those +changes to your fork. Then click on the pull request icon on github and create +a new pull request. Add a description about the change and send it along. I +promise to review the pull request in a reasonable window of time and get back +to you. + +In order to keep your fork up to date with any changes from mainline, add a new +git remote to your local copy called 'upstream' and point it to the main pgcli +repo. + +:: + + $ git remote add upstream git@github.com:dbcli/pgcli.git + +Once the 'upstream' end point is added you can then periodically do a ``git +pull upstream master`` to update your local copy and then do a ``git push +origin master`` to keep your own fork up to date. + +Check Github's `Understanding the GitHub flow guide +`_ for a more detailed +explanation of this process. + +Local Setup +----------- + +The installation instructions in the README file are intended for users of +pgcli. If you're developing pgcli, you'll need to install it in a slightly +different way so you can see the effects of your changes right away without +having to go through the install cycle every time you change the code. + +It is highly recommended to use virtualenv for development. If you don't know +what a virtualenv is, `this guide `_ +will help you get started. + +Create a virtualenv (let's call it pgcli-dev). Activate it: + +:: + + source ./pgcli-dev/bin/activate + +Once the virtualenv is activated, `cd` into the local clone of pgcli folder +and install pgcli using pip as follows: + +:: + + $ pip install --editable . + + or + + $ pip install -e . + +This will install the necessary dependencies as well as install pgcli from the +working folder into the virtualenv. By installing it using `pip install -e` +we've linked the pgcli installation with the working copy. Any changes made +to the code are immediately available in the installed version of pgcli. This +makes it easy to change something in the code, launch pgcli and check the +effects of your changes. + +Adding PostgreSQL Special (Meta) Commands +----------------------------------------- + +If you want to work on adding new meta-commands (such as `\dp`, `\ds`, `dy`), +you need to contribute to `pgspecial `_ +project. + +Building RPM and DEB packages +----------------------------- + +You will need Vagrant 1.7.2 or higher. In the project root there is a +Vagrantfile that is setup to do multi-vm provisioning. If you're setting things +up for the first time, then do: + +:: + + $ version=x.y.z vagrant up debian + $ version=x.y.z vagrant up centos + +If you already have those VMs setup and you're merely creating a new version of +DEB or RPM package, then you can do: + +:: + + $ version=x.y.z vagrant provision + +That will create a .deb file and a .rpm file. + +The deb package can be installed as follows: + +:: + + $ sudo dpkg -i pgcli*.deb # if dependencies are available. + + or + + $ sudo apt-get install -f pgcli*.deb # if dependencies are not available. + + +The rpm package can be installed as follows: + +:: + + $ sudo yum install pgcli*.rpm + +Running the integration tests +----------------------------- + +Integration tests use `behave package `_ and +pytest. +Configuration settings for this package are provided via a ``behave.ini`` file +in the ``tests`` directory. An example:: + + [behave] + stderr_capture = false + + [behave.userdata] + pg_test_user = dbuser + pg_test_host = db.example.com + pg_test_port = 30000 + +First, install the requirements for testing: + +:: + + $ pip install -r requirements-dev.txt + +Ensure that the database user has permissions to create and drop test databases +by checking your ``pg_hba.conf`` file. The default user should be ``postgres`` +at ``localhost``. Make sure the authentication method is set to ``trust``. If +you made any changes to your ``pg_hba.conf`` make sure to restart the postgres +service for the changes to take effect. + +:: + + # ONLY IF YOU MADE CHANGES TO YOUR pg_hba.conf FILE + $ sudo service postgresql restart + +After that, tests in the ``/pgcli/tests`` directory can be run with: + +:: + + # on directory /pgcli/tests + $ behave + +And on the ``/pgcli`` directory: + +:: + + # on directory /pgcli + $ py.test + +To see stdout/stderr, use the following command: + +:: + + $ behave --no-capture + +Troubleshooting the integration tests +------------------------------------- + +- Make sure postgres instance on localhost is running +- Check your ``pg_hba.conf`` file to verify local connections are enabled +- Check `this issue `_ for relevant information. +- Contact us on `gitter `_ or `file an issue `_. + +Coding Style +------------ + +``pgcli`` uses `black `_ to format the source code. Make sure to install black. diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..32d341a --- /dev/null +++ b/Dockerfile @@ -0,0 +1,6 @@ +FROM python:3.8 + +COPY . /app +RUN cd /app && pip install -e . + +CMD pgcli diff --git a/LICENSE.txt b/LICENSE.txt new file mode 100644 index 0000000..83226b7 --- /dev/null +++ b/LICENSE.txt @@ -0,0 +1,26 @@ +All rights reserved. + +Redistribution and use in source and binary forms, with or without modification, +are permitted provided that the following conditions are met: + +* Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + +* Redistributions in binary form must reproduce the above copyright notice, this + list of conditions and the following disclaimer in the documentation and/or + other materials provided with the distribution. + +* Neither the name of the {organization} nor the names of its + contributors may be used to endorse or promote products derived from + this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR +ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON +ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/MANIFEST.in b/MANIFEST.in new file mode 100644 index 0000000..1c5f697 --- /dev/null +++ b/MANIFEST.in @@ -0,0 +1,2 @@ +include LICENSE.txt AUTHORS changelog.rst +recursive-include tests *.py *.txt *.feature *.ini diff --git a/README.rst b/README.rst new file mode 100644 index 0000000..d593427 --- /dev/null +++ b/README.rst @@ -0,0 +1,372 @@ +A REPL for Postgres +------------------- + +|Build Status| |CodeCov| |PyPI| |Landscape| |Gitter| + +This is a postgres client that does auto-completion and syntax highlighting. + +Home Page: http://pgcli.com + +MySQL Equivalent: http://mycli.net + +.. image:: screenshots/pgcli.gif +.. image:: screenshots/image01.png + +Quick Start +----------- + +If you already know how to install python packages, then you can simply do: + +:: + + $ pip install -U pgcli + + or + + $ sudo apt-get install pgcli # Only on Debian based Linux (e.g. Ubuntu, Mint, etc) + $ brew install pgcli # Only on macOS + +If you don't know how to install python packages, please check the +`detailed instructions`_. + +If you are restricted to using psycopg2 2.7.x then pip will try to install it from a binary. There are some known issues with the psycopg2 2.7 binary - see the `psycopg docs`_ for more information about this and how to force installation from source. psycopg2 2.8 has fixed these problems, and will build from source. + +.. _`detailed instructions`: https://github.com/dbcli/pgcli#detailed-installation-instructions +.. _`psycopg docs`: http://initd.org/psycopg/docs/install.html#change-in-binary-packages-between-psycopg-2-7-and-2-8 + +Usage +----- + +:: + + $ pgcli [database_name] + + or + + $ pgcli postgresql://[user[:password]@][netloc][:port][/dbname][?extra=value[&other=other-value]] + +Examples: + +:: + + $ pgcli local_database + + $ pgcli postgres://amjith:pa$$w0rd@example.com:5432/app_db?sslmode=verify-ca&sslrootcert=/myrootcert + +For more details: + +:: + + $ pgcli --help + + Usage: pgcli [OPTIONS] [DBNAME] [USERNAME] + + Options: + -h, --host TEXT Host address of the postgres database. + -p, --port INTEGER Port number at which the postgres instance is + listening. + -U, --username TEXT Username to connect to the postgres database. + -u, --user TEXT Username to connect to the postgres database. + -W, --password Force password prompt. + -w, --no-password Never prompt for password. + --single-connection Do not use a separate connection for completions. + -v, --version Version of pgcli. + -d, --dbname TEXT database name to connect to. + --pgclirc PATH Location of pgclirc file. + -D, --dsn TEXT Use DSN configured into the [alias_dsn] section of + pgclirc file. + --list-dsn list of DSN configured into the [alias_dsn] section + of pgclirc file. + --row-limit INTEGER Set threshold for row limit prompt. Use 0 to disable + prompt. + --less-chatty Skip intro on startup and goodbye on exit. + --prompt TEXT Prompt format (Default: "\u@\h:\d> "). + --prompt-dsn TEXT Prompt format for connections using DSN aliases + (Default: "\u@\h:\d> "). + -l, --list list available databases, then exit. + --auto-vertical-output Automatically switch to vertical output mode if the + result is wider than the terminal width. + --warn / --no-warn Warn before running a destructive query. + --help Show this message and exit. + +``pgcli`` also supports many of the same `environment variables`_ as ``psql`` for login options (e.g. ``PGHOST``, ``PGPORT``, ``PGUSER``, ``PGPASSWORD``, ``PGDATABASE``). + +The SSL-related environment variables are also supported, so if you need to connect a postgres database via ssl connection, you can set set environment like this: + +:: + + export PGSSLMODE="verify-full" + export PGSSLCERT="/your-path-to-certs/client.crt" + export PGSSLKEY="/your-path-to-keys/client.key" + export PGSSLROOTCERT="/your-path-to-ca/ca.crt" + pgcli -h localhost -p 5432 -U username postgres + +.. _environment variables: https://www.postgresql.org/docs/current/libpq-envars.html + +Features +-------- + +The `pgcli` is written using prompt_toolkit_. + +* Auto-completes as you type for SQL keywords as well as tables and + columns in the database. +* Syntax highlighting using Pygments. +* Smart-completion (enabled by default) will suggest context-sensitive + completion. + + - ``SELECT * FROM `` will only show table names. + - ``SELECT * FROM users WHERE `` will only show column names. + +* Primitive support for ``psql`` back-slash commands. +* Pretty prints tabular data. + +.. _prompt_toolkit: https://github.com/jonathanslenders/python-prompt-toolkit +.. _tabulate: https://pypi.python.org/pypi/tabulate + +Config +------ +A config file is automatically created at ``~/.config/pgcli/config`` at first launch. +See the file itself for a description of all available options. + +Contributions: +-------------- + +If you're interested in contributing to this project, first of all I would like +to extend my heartfelt gratitude. I've written a small doc to describe how to +get this running in a development setup. + +https://github.com/dbcli/pgcli/blob/master/DEVELOP.rst + +Please feel free to reach out to me if you need help. +My email: amjith.r@gmail.com, Twitter: `@amjithr `_ + +Detailed Installation Instructions: +----------------------------------- + +macOS: +====== + +The easiest way to install pgcli is using Homebrew. + +:: + + $ brew install pgcli + +Done! + +Alternatively, you can install ``pgcli`` as a python package using a package +manager called called ``pip``. You will need postgres installed on your system +for this to work. + +In depth getting started guide for ``pip`` - https://pip.pypa.io/en/latest/installing.html. + +:: + + $ which pip + +If it is installed then you can do: + +:: + + $ pip install pgcli + +If that fails due to permission issues, you might need to run the command with +sudo permissions. + +:: + + $ sudo pip install pgcli + +If pip is not installed check if easy_install is available on the system. + +:: + + $ which easy_install + + $ sudo easy_install pgcli + +Linux: +====== + +In depth getting started guide for ``pip`` - https://pip.pypa.io/en/latest/installing.html. + +Check if pip is already available in your system. + +:: + + $ which pip + +If it doesn't exist, use your linux package manager to install `pip`. This +might look something like: + +:: + + $ sudo apt-get install python-pip # Debian, Ubuntu, Mint etc + + or + + $ sudo yum install python-pip # RHEL, Centos, Fedora etc + +``pgcli`` requires python-dev, libpq-dev and libevent-dev packages. You can +install these via your operating system package manager. + + +:: + + $ sudo apt-get install python-dev libpq-dev libevent-dev + + or + + $ sudo yum install python-devel postgresql-devel + +Then you can install pgcli: + +:: + + $ sudo pip install pgcli + + +Docker +====== + +Pgcli can be run from within Docker. This can be useful to try pgcli without +installing it, or any dependencies, system-wide. + +To build the image: + +:: + + $ docker build -t pgcli . + +To create a container from the image: + +:: + + $ docker run --rm -ti pgcli pgcli + +To access postgresql databases listening on localhost, make sure to run the +docker in "host net mode". E.g. to access a database called "foo" on the +postgresql server running on localhost:5432 (the standard port): + +:: + + $ docker run --rm -ti --net host pgcli pgcli -h localhost foo + +To connect to a locally running instance over a unix socket, bind the socket to +the docker container: + +:: + + $ docker run --rm -ti -v /var/run/postgres:/var/run/postgres pgcli pgcli foo + + +IPython +======= + +Pgcli can be run from within `IPython `_ console. When working on a query, +it may be useful to drop into a pgcli session without leaving the IPython console, iterate on a +query, then quit pgcli to find the query results in your IPython workspace. + +Assuming you have IPython installed: + +:: + + $ pip install ipython-sql + +After that, run ipython and load the ``pgcli.magic`` extension: + +:: + + $ ipython + + In [1]: %load_ext pgcli.magic + + +Connect to a database and construct a query: + +:: + + In [2]: %pgcli postgres://someone@localhost:5432/world + Connected: someone@world + someone@localhost:world> select * from city c where countrycode = 'USA' and population > 1000000; + +------+--------------+---------------+--------------+--------------+ + | id | name | countrycode | district | population | + |------+--------------+---------------+--------------+--------------| + | 3793 | New York | USA | New York | 8008278 | + | 3794 | Los Angeles | USA | California | 3694820 | + | 3795 | Chicago | USA | Illinois | 2896016 | + | 3796 | Houston | USA | Texas | 1953631 | + | 3797 | Philadelphia | USA | Pennsylvania | 1517550 | + | 3798 | Phoenix | USA | Arizona | 1321045 | + | 3799 | San Diego | USA | California | 1223400 | + | 3800 | Dallas | USA | Texas | 1188580 | + | 3801 | San Antonio | USA | Texas | 1144646 | + +------+--------------+---------------+--------------+--------------+ + SELECT 9 + Time: 0.003s + + +Exit out of pgcli session with ``Ctrl + D`` and find the query results: + +:: + + someone@localhost:world> + Goodbye! + 9 rows affected. + Out[2]: + [(3793, u'New York', u'USA', u'New York', 8008278), + (3794, u'Los Angeles', u'USA', u'California', 3694820), + (3795, u'Chicago', u'USA', u'Illinois', 2896016), + (3796, u'Houston', u'USA', u'Texas', 1953631), + (3797, u'Philadelphia', u'USA', u'Pennsylvania', 1517550), + (3798, u'Phoenix', u'USA', u'Arizona', 1321045), + (3799, u'San Diego', u'USA', u'California', 1223400), + (3800, u'Dallas', u'USA', u'Texas', 1188580), + (3801, u'San Antonio', u'USA', u'Texas', 1144646)] + +The results are available in special local variable ``_``, and can be assigned to a variable of your +choice: + +:: + + In [3]: my_result = _ + +Pgcli only runs on Python3.6+ since 2.2.0, if you use an old version of Python, +you should use install ``pgcli <= 2.2.0``. + +Thanks: +------- + +A special thanks to `Jonathan Slenders `_ for +creating `Python Prompt Toolkit `_, +which is quite literally the backbone library, that made this app possible. +Jonathan has also provided valuable feedback and support during the development +of this app. + +`Click `_ is used for command line option parsing +and printing error messages. + +Thanks to `psycopg `_ for providing a rock solid +interface to Postgres database. + +Thanks to all the beta testers and contributors for your time and patience. :) + + +.. |Build Status| image:: https://api.travis-ci.org/dbcli/pgcli.svg?branch=master + :target: https://travis-ci.org/dbcli/pgcli + +.. |CodeCov| image:: https://codecov.io/gh/dbcli/pgcli/branch/master/graph/badge.svg + :target: https://codecov.io/gh/dbcli/pgcli + :alt: Code coverage report + +.. |Landscape| image:: https://landscape.io/github/dbcli/pgcli/master/landscape.svg?style=flat + :target: https://landscape.io/github/dbcli/pgcli/master + :alt: Code Health + +.. |PyPI| image:: https://img.shields.io/pypi/v/pgcli.svg + :target: https://pypi.python.org/pypi/pgcli/ + :alt: Latest Version + +.. |Gitter| image:: https://badges.gitter.im/Join%20Chat.svg + :target: https://gitter.im/dbcli/pgcli?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge&utm_content=badge + :alt: Gitter Chat diff --git a/TODO b/TODO new file mode 100644 index 0000000..2173545 --- /dev/null +++ b/TODO @@ -0,0 +1,12 @@ +# vi: ft=vimwiki +* [ ] Add coverage. +* [ ] Refactor to sqlcompletion to consume the text from left to right and use a state machine to suggest cols or tables instead of relying on hacks. +* [ ] Add a few more special commands. (\l pattern, \dp, \ds, \dy, \z etc) +* [ ] Refactor pgspecial.py to a class. +* [ ] Show/hide docs for a statement using a keybinding. +* [ ] Check how to add the name of the table before printing the table. +* [ ] Add a new trigger for M-/ that does naive completion. +* [ ] New Feature List - Write the current version to config file. At launch if the version has changed, display the changelog between the two versions. +* [ ] Add a test for 'select * from custom.abc where custom.abc.' should suggest columns from abc. +* [ ] pgexecute columns(), tables() etc can be just cursors instead of fetchall() +* [ ] Add colorschemes in config file. diff --git a/Vagrantfile b/Vagrantfile new file mode 100644 index 0000000..0313520 --- /dev/null +++ b/Vagrantfile @@ -0,0 +1,93 @@ +# -*- mode: ruby -*- +# vi: set ft=ruby : + +Vagrant.configure(2) do |config| + + config.vm.synced_folder ".", "/pgcli" + + pgcli_version = ENV['version'] + pgcli_description = "Postgres CLI with autocompletion and syntax highlighting" + + config.vm.define "debian" do |debian| + debian.vm.box = "chef/debian-7.8" + debian.vm.provision "shell", inline: <<-SHELL + echo "-> Building DEB on `lsb_release -s`" + sudo apt-get update + sudo apt-get install -y libpq-dev python-dev python-setuptools rubygems + sudo easy_install pip + sudo pip install virtualenv virtualenv-tools + sudo gem install fpm + echo "-> Cleaning up old workspace" + rm -rf build + mkdir -p build/usr/share + virtualenv build/usr/share/pgcli + build/usr/share/pgcli/bin/pip install -U pip distribute + build/usr/share/pgcli/bin/pip uninstall -y distribute + build/usr/share/pgcli/bin/pip install /pgcli + + echo "-> Cleaning Virtualenv" + cd build/usr/share/pgcli + virtualenv-tools --update-path /usr/share/pgcli > /dev/null + cd /home/vagrant/ + + echo "-> Removing compiled files" + find build -iname '*.pyc' -delete + find build -iname '*.pyo' -delete + + echo "-> Creating PgCLI deb" + sudo fpm -t deb -s dir -C build -n pgcli -v #{pgcli_version} \ + -a all \ + -d libpq-dev \ + -d python-dev \ + -p /pgcli/ \ + --after-install /pgcli/post-install \ + --after-remove /pgcli/post-remove \ + --url https://github.com/dbcli/pgcli \ + --description "#{pgcli_description}" \ + --license 'BSD' + SHELL + end + + config.vm.define "centos" do |centos| + centos.vm.box = "chef/centos-7.0" + centos.vm.provision "shell", inline: <<-SHELL + #!/bin/bash + echo "-> Building RPM on `lsb_release -s`" + sudo yum install -y rpm-build gcc ruby-devel postgresql-devel python-devel rubygems + sudo easy_install pip + sudo pip install virtualenv virtualenv-tools + sudo gem install fpm + echo "-> Cleaning up old workspace" + rm -rf build + mkdir -p build/usr/share + virtualenv build/usr/share/pgcli + build/usr/share/pgcli/bin/pip install -U pip distribute + build/usr/share/pgcli/bin/pip uninstall -y distribute + build/usr/share/pgcli/bin/pip install /pgcli + + echo "-> Cleaning Virtualenv" + cd build/usr/share/pgcli + virtualenv-tools --update-path /usr/share/pgcli > /dev/null + cd /home/vagrant/ + + echo "-> Removing compiled files" + find build -iname '*.pyc' -delete + find build -iname '*.pyo' -delete + + echo "-> Creating PgCLI RPM" + echo $PATH + sudo /usr/local/bin/fpm -t rpm -s dir -C build -n pgcli -v #{pgcli_version} \ + -a all \ + -d postgresql-devel \ + -d python-devel \ + -p /pgcli/ \ + --after-install /pgcli/post-install \ + --after-remove /pgcli/post-remove \ + --url https://github.com/dbcli/pgcli \ + --description "#{pgcli_description}" \ + --license 'BSD' + SHELL + end + +end + diff --git a/changelog.rst b/changelog.rst new file mode 100644 index 0000000..ec50635 --- /dev/null +++ b/changelog.rst @@ -0,0 +1,1064 @@ +3.1.0 +===== + +Features: +--------- + +* Make the output more compact by removing the empty newline. (Thanks: `laixintao`_) +* Add support for using [pspg](https://github.com/okbob/pspg) as a pager (#1102) +* Update python version in Dockerfile +* Support setting color for null, string, number, keyword value +* Support Prompt Toolkit 2 +* Support sqlparse 0.4.x +* Update functions, datatypes literals for auto-suggestion field +* Add suggestion for schema in function auto-complete + +Bug fixes: +---------- + +* Minor typo fixes in `pgclirc`. (Thanks: `anthonydb`_) +* Fix for list index out of range when executing commands from a file (#1193). (Thanks: `Irina Truong`_) +* Move from `humanize` to `pendulum` for displaying query durations (#1015) +* More explicit error message when connecting using DSN alias and it is not found. + +3.0.0 +===== + +Features: +--------- + +* Add `__main__.py` file to execute pgcli as a package directly (#1123). +* Add support for ANSI escape sequences for coloring the prompt (#1122). +* Add support for partitioned tables (relkind "p"). +* Add support for `pg_service.conf` files +* Add config option show_bottom_toolbar. + +Bug fixes: +---------- + +* Fix warning raised for using `is not` to compare string literal +* Close open connection in completion_refresher thread + +Internal: +--------- + +* Drop Python2.7, 3.4, 3.5 support. (Thanks: `laixintao`_) +* Support Python3.8. (Thanks: `laixintao`_) +* Fix dead link in development guide. (Thanks: `BrownShibaDog`_) +* Upgrade python-prompt-toolkit to v3.0. (Thanks: `laixintao`_) + + +2.2.0: +====== + +Features: +--------- + +* Add `\\G` as a terminator to sql statements that will show the results in expanded mode. This feature is copied from mycli. (Thanks: `Amjith Ramanujam`_) +* Removed limit prompt and added automatic row limit on queries with no LIMIT clause (#1079) (Thanks: `Sebastian Janko`_) +* Function argument completions now take account of table aliases (#1048). (Thanks: `Owen Stephens`_) + +Bug fixes: +---------- + +* Error connecting to PostgreSQL 12beta1 (#1058). (Thanks: `Irina Truong`_ and `Amjith Ramanujam`_) +* Empty query caused error message (#1019) (Thanks: `Sebastian Janko`_) +* History navigation bindings in multiline queries (#1004) (Thanks: `Pedro Ferrari`_) +* Can't connect to pgbouncer database (#1093). (Thanks: `Irina Truong`_) +* Fix broken multi-line history search (#1031). (Thanks: `Owen Stephens`_) +* Fix slow typing/movement when multi-line query ends in a semicolon (#994). (Thanks: `Owen Stephens`_) +* Fix for PQconninfo not available in libpq < 9.3 (#1110). (Thanks: `Irina Truong`_) + +Internal: +--------- + +* Add optional but default squash merge request to PULL_REQUEST_TEMPLATE + +2.1.1 +===== + +Bug fixes: +---------- +* Escape switches to VI navigation mode when not canceling completion popup. (Thanks: `Nathan Verzemnieks`_) +* Allow application_name to be overridden. (Thanks: `raylu`_) +* Fix for "no attribute KeyringLocked" (#1040). (Thanks: `Irina Truong`_) +* Pgcli no longer works with password containing spaces (#1043). (Thanks: `Irina Truong`_) +* Load keyring only when keyring is enabled in the config file (#1041). (Thanks: `Zhaolong Zhu`_) +* No longer depend on sqlparse as being less than 0.3.0 with the release of sqlparse 0.3.0. (Thanks: `VVelox`_) +* Fix the broken support for pgservice . (Thanks: `Xavier Francisco`_) +* Connecting using socket is broken in current master. (#1053). (Thanks: `Irina Truong`_) +* Allow usage of newer versions of psycopg2 (Thanks: `Telmo "Trooper"`_) +* Update README in alignment with the usage of newer versions of psycopg2 (Thanks: `Alexander Zawadzki`_) + +Internal: +--------- + +* Add python 3.7 to travis build matrix. (Thanks: `Irina Truong`_) +* Apply `black` to code. (Thanks: `Irina Truong`_) + +2.1.0 +===== + +Features: +--------- + +* Keybindings for closing the autocomplete list. (Thanks: `easteregg`_) +* Reconnect automatically when server closes connection. (Thanks: `Scott Brenstuhl`_) + +Bug fixes: +---------- +* Avoid error message on the server side if hstore extension is not installed in the current database (#991). (Thanks: `Marcin Cieślak`_) +* All pexpect submodules have been moved into the pexpect package as of version 3.0. Use pexpect.TIMEOUT (Thanks: `Marcin Cieślak`_) +* Resizing pgcli terminal kills the connection to postgres in python 2.7 (Thanks: `Amjith Ramanujam`_) +* Fix crash retrieving server version with ``--single-connection``. (Thanks: `Irina Truong`_) +* Cannot quit application without reconnecting to database (#1014). (Thanks: `Irina Truong`_) +* Password authentication failed for user "postgres" when using non-default password (#1020). (Thanks: `Irina Truong`_) + +Internal: +--------- + +* (Fixup) Clean up and add behave logging. (Thanks: `Marcin Cieślak`_, `Dick Marinus`_) +* Override VISUAL environment variable for behave tests. (Thanks: `Marcin Cieślak`_) +* Remove build dir before running sdist, remove stray files from wheel distribution. (Thanks: `Dick Marinus`_) +* Fix unit tests, unhashable formatted text since new python prompttoolkit version. (Thanks: `Dick Marinus`_) + +2.0.2: +====== + +Features: +--------- + +* Allows passing the ``-u`` flag to specify a username. (Thanks: `Ignacio Campabadal`_) +* Fix for lag in v2 (#979). (Thanks: `Irina Truong`_) +* Support for multihost connection string that is convenient if you have postgres cluster. (Thanks: `Mikhail Elovskikh`_) + +Internal: +--------- + +* Added tests for special command completion. (Thanks: `Amjith Ramanujam`_) + +2.0.1: +====== + +Bug fixes: +---------- + +* Tab press on an empty line increases the indentation instead of triggering + the auto-complete pop-up. (Thanks: `Artur Balabanov`_) +* Fix for loading/saving named queries from provided config file (#938). (Thanks: `Daniel Egger`_) +* Set default port in `connect_uri` when none is given. (Thanks: `Daniel Egger`_) +* Fix for error listing databases (#951). (Thanks: `Irina Truong`_) +* Enable Ctrl-Z to suspend the app (Thanks: `Amjith Ramanujam`_). +* Fix StopIteration exception raised at runtime for Python 3.7 (Thanks: `Amjith Ramanujam`_). + +Internal: +--------- + +* Clean up and add behave logging. (Thanks: `Dick Marinus`_) +* Require prompt_toolkit>=2.0.6. (Thanks: `Dick Marinus`_) +* Improve development guide. (Thanks: `Ignacio Campabadal`_) + +2.0.0: +====== + +* Update to ``prompt-toolkit`` 2.0. (Thanks: `Jonathan Slenders`_, `Dick Marinus`_, `Irina Truong`_) + +1.11.0 +====== + +Features: +--------- + +* Respect `\pset pager on` and use pager when output is longer than terminal height (Thanks: `Max Rothman`_) + +1.10.3 +====== + +Bug fixes: +---------- + +* Adapt the query used to get functions metadata to PG11 (#919). (Thanks: `Lele Gaifax`_). +* Fix for error retrieving version in Redshift (#922). (Thanks: `Irina Truong`_) +* Fix for keyring not disabled properly (#920). (Thanks: `Irina Truong`_) + +1.10.2 +====== + +Features: +--------- + +* Make `keyring` optional (Thanks: `Dick Marinus`_) + +1.10.1 +====== + +Bug fixes: +---------- + +* Fix for missing keyring. (Thanks: `Kenny Do`_) +* Fix for "-l" Flag Throws Error (#909). (Thanks: `Irina Truong`_) + +1.10.0 +====== + +Features: +--------- +* Add quit commands to the completion menu. (Thanks: `Jason Ribeiro`_) +* Add table formats to ``\T`` completion. (Thanks: `Jason Ribeiro`_) +* Support `\\ev``, ``\ef`` (#754). (Thanks: `Catherine Devlin`_) +* Add ``application_name`` to help identify pgcli connection to database (issue #868) (Thanks: `François Pietka`_) +* Add `--user` option, duplicate of `--username`, the same cli option like `psql` (Thanks: `Alexandr Korsak`_) + +Internal changes: +----------------- + +* Mark tests requiring a running database server as dbtest (Thanks: `Dick Marinus`_) +* Add an is_special command flag to MetaQuery (Thanks: `Rishi Ramraj`_) +* Ported Destructive Warning from mycli. +* Refactor Destructive Warning behave tests (Thanks: `Dick Marinus`_) + +Bug Fixes: +---------- +* Disable pager when using \watch (#837). (Thanks: `Jason Ribeiro`_) +* Don't offer to reconnect when we can't change a param in realtime (#807). (Thanks: `Amjith Ramanujam`_ and `Saif Hakim`_) +* Make keyring optional. (Thanks: `Dick Marinus`_) +* Fix ipython magic connection (#891). (Thanks: `Irina Truong`_) +* Fix not enough values to unpack. (Thanks: `Matthieu Guilbert`_) +* Fix unbound local error when destructive_warning is false. (Thanks: `Matthieu Guilbert`_) +* Render tab characters as 4 spaces instead of `^I`. (Thanks: `Artur Balabanov`_) + +1.9.1: +====== + +Features: +--------- + +* Change ``\h`` format string in prompt to only return the first part of the hostname, + up to the first '.' character. Add ``\H`` that returns the entire hostname (#858). + (Thanks: `Andrew Kuchling`_) +* Add Color of table by parameter. The color of table is function of syntax style + +Internal changes: +----------------- + +* Add tests, AUTHORS and changelog.rst to release. (Thanks: `Dick Marinus`_) + +Bug Fixes: +---------- +* Fix broken pgcli --list command line option (#850). (Thanks: `Dmitry B`_) + +1.9.0 +===== + +Features: +--------- + +* manage pager by \pset pager and add enable_pager to the config file (Thanks: `Frederic Aoustin`_). +* Add support for `\T` command to change format output. (Thanks: `Frederic Aoustin`_). +* Add option list-dsn (Thanks: `Frederic Aoustin`_). + + +Internal changes: +----------------- + +* Removed support for Python 3.3. (Thanks: `Irina Truong`_) + +1.8.2 +===== + +Features: +--------- + +* Use other prompt (prompt_dsn) when connecting using --dsn parameter. (Thanks: `Marcin Sztolcman`_) +* Include username into password prompt. (Thanks: `Bojan Delić`_) + +Internal changes: +----------------- +* Use temporary dir as config location in tests. (Thanks: `Dmitry B`_) +* Fix errors in the ``tee`` test (#795 and #797). (Thanks: `Irina Truong`_) +* Increase timeout for quitting pgcli. (Thanks: `Dick Marinus`_) + +Bug Fixes: +---------- +* Do NOT quote the database names in the completion menu (Thanks: `Amjith Ramanujam`_) +* Fix error in ``unix_socket_directories`` (#805). (Thanks: `Irina Truong`_) +* Fix the --list command line option tries to connect to 'personal' DB (#816). (Thanks: `Isank`_) + +1.8.1 +===== + +Internal changes: +----------------- +* Remove shebang and git execute permission from pgcli/main.py. (Thanks: `Dick Marinus`_) +* Require cli_helpers 0.2.3 (fix #791). (Thanks: `Dick Marinus`_) + +1.8.0 +===== + +Features: +--------- + +* Add fish-style auto-suggestion from history. (Thanks: `Amjith Ramanujam`_) +* Improved formatting of arrays in output (Thanks: `Joakim Koljonen`_) +* Don't quote identifiers that are non-reserved keywords. (Thanks: `Joakim Koljonen`_) +* Remove the ``...`` in the continuation prompt and use empty space instead. (Thanks: `Amjith Ramanujam`_) +* Add \conninfo and handle more parameters with \c (issue #716) (Thanks: `François Pietka`_) + +Internal changes: +----------------- +* Preliminary work for a future change in outputting results that uses less memory. (Thanks: `Dick Marinus`_) +* Remove import workaround for OrderedDict, required for python < 2.7. (Thanks: `Andrew Speed`_) +* Use less memory when formatting results for display (Thanks: `Dick Marinus`_). +* Port auto_vertical feature test from mycli to pgcli. (Thanks: `Dick Marinus`_) +* Drop wcwidth dependency (Thanks: `Dick Marinus`_) + +Bug Fixes: +---------- + +* Fix the way we get host when using DSN (issue #765) (Thanks: `François Pietka`_) +* Add missing keyword COLUMN after DROP (issue #769) (Thanks: `François Pietka`_) +* Don't include arguments in function suggestions for backslash commands (Thanks: `Joakim Koljonen`_) +* Optionally use POSTGRES_USER, POSTGRES_HOST POSTGRES_PASSWORD from environment (Thanks: `Dick Marinus`_) + +1.7.0 +===== + +* Refresh completions after `COMMIT` or `ROLLBACK`. (Thanks: `Irina Truong`_) +* Fixed DSN aliases not being read from custom pgclirc (issue #717). (Thanks: `Irina Truong`_). +* Use dbcli's Homebrew tap for installing pgcli on macOS (issue #718) (Thanks: `Thomas Roten`_). +* Only set `LESS` environment variable if it's unset. (Thanks: `Irina Truong`_) +* Quote schema in `SET SCHEMA` statement (issue #469) (Thanks: `Irina Truong`_) +* Include arguments in function suggestions (Thanks: `Joakim Koljonen`_) +* Use CLI Helpers for pretty printing query results (Thanks: `Thomas Roten`_). +* Skip serial columns when expanding * for `INSERT INTO foo(*` (Thanks: `Joakim Koljonen`_). +* Command line option to list databases (issue #206) (Thanks: `François Pietka`_) + +1.6.0 +===== + +Features: +--------- +* Add time option for prompt (Thanks: `Gustavo Castro`_) +* Suggest objects from all schemas (not just those in search_path) (Thanks: `Joakim Koljonen`_) +* Casing for column headers (Thanks: `Joakim Koljonen`_) +* Allow configurable character to be used for multi-line query continuations. (Thanks: `Owen Stephens`_) +* Completions after ORDER BY and DISTINCT now take account of table aliases. (Thanks: `Owen Stephens`_) +* Narrow keyword candidates based on previous keyword. (Thanks: `Étienne Bersac`_) +* Opening an external editor will edit the last-run query. (Thanks: `Thomas Roten`_) +* Support query options in postgres URIs such as ?sslcert=foo.pem (Thanks: `Alexander Schmolck`_) + +Bug fixes: +---------- +* Fixed external editor bug (issue #668). (Thanks: `Irina Truong`_). +* Standardize command line option names. (Thanks: `Russell Davies`_) +* Improve handling of ``lock_not_available`` error (issue #700). (Thanks: `Jackson Popkin `_) +* Fixed user option precedence (issue #697). (Thanks: `Irina Truong`_). + +Internal changes: +----------------- +* Run pep8 checks in travis (Thanks: `Irina Truong`_). +* Add pager wrapper for behave tests (Thanks: `Dick Marinus`_). +* Behave quit pgcli nicely (Thanks: `Dick Marinus`_). +* Behave test source command (Thanks: `Dick Marinus`_). +* Behave fix clean up. (Thanks: `Dick Marinus`_). +* Test using behave the tee command (Thanks: `Dick Marinus`_). +* Behave remove boiler plate code (Thanks: `Dick Marinus`_). +* Behave fix pgspecial update (Thanks: `Dick Marinus`_). +* Add behave to tox (Thanks: `Dick Marinus`_). + +1.5.1 +===== + +Features: +--------- +* Better suggestions when editing functions (Thanks: `Joakim Koljonen`_) +* Command line option for ``--less-chatty``. (Thanks: `tk`_) +* Added ``MATERIALIZED VIEW`` keywords. (Thanks: `Joakim Koljonen`_). + +Bug fixes: +---------- + +* Support unicode chars in expanded mode. (Thanks: `Amjith Ramanujam`_) +* Fixed "set_session cannot be used inside a transaction" when using dsn. (Thanks: `Irina Truong`_). + +1.5.0 +===== + +Features: +--------- +* Upgraded pgspecial to 1.7.0. (See `pgspecial changelog `_ for list of fixes) +* Add a new config setting to allow expandable mode (Thanks: `Jonathan Boudreau `_) +* Make pgcli prompt width short when the prompt is too long (Thanks: `Jonathan Virga `_) +* Add additional completion for ``ALTER`` keyword (Thanks: `Darik Gamble`_) +* Make the menu size configurable. (Thanks `Darik Gamble`_) + +Bug Fixes: +---------- +* Handle more connection failure cases. (Thanks: `Amjith Ramanujam`_) +* Fix the connection failure issues with latest psycopg2. (Thanks: `Amjith Ramanujam`_) + +Internal Changes: +----------------- + +* Add testing for Python 3.5 and 3.6. (Thanks: `Amjith Ramanujam`_) + +1.4.0 +===== + +Features: +--------- + +* Search table suggestions using initialisms. (Thanks: `Joakim Koljonen`_). +* Support for table-qualifying column suggestions. (Thanks: `Joakim Koljonen`_). +* Display transaction status in the toolbar. (Thanks: `Joakim Koljonen`_). +* Display vi mode in the toolbar. (Thanks: `Joakim Koljonen`_). +* Added --prompt option. (Thanks: `Irina Truong`_). + +Bug Fixes: +---------- + +* Fix scoping for columns from CTEs. (Thanks: `Joakim Koljonen`_) +* Fix crash after `with`. (Thanks: `Joakim Koljonen`_). +* Fix issue #603 (`\i` raises a TypeError). (Thanks: `Lele Gaifax`_). + + +Internal Changes: +----------------- + +* Set default data_formatting to nothing. (Thanks: `Amjith Ramanujam`_). +* Increased minimum prompt_toolkit requirement to 1.0.9. (Thanks: `Irina Truong`_). + + +1.3.1 +===== + +Bug Fixes: +---------- +* Fix a crashing bug due to sqlparse upgrade. (Thanks: `Darik Gamble`_) + + +1.3.0 +===== + +IMPORTANT: Python 2.6 is not officially supported anymore. + +Features: +--------- +* Add delimiters to displayed numbers. This can be configured via the config file. (Thanks: `Sergii`_). +* Fix broken 'SHOW ALL' in redshift. (Thanks: `Manuel Barkhau`_). +* Support configuring keyword casing preferences. (Thanks: `Darik Gamble`_). +* Add a new multi_line_mode option in config file. The values can be `psql` or `safe`. (Thanks: `Joakim Koljonen`_) + Setting ``multi_line_mode = safe`` will make sure that a query will only be executed when Alt+Enter is pressed. + +Bug Fixes: +---------- +* Fix crash bug with leading parenthesis. (Thanks: `Joakim Koljonen`_). +* Remove cumulative addition of timing data. (Thanks: `Amjith Ramanujam`_). +* Handle unrecognized keywords gracefully. (Thanks: `Darik Gamble`_) +* Use raw strings in regex specifiers. This preemptively fixes a crash in Python 3.6. (Thanks `Lele Gaifax`_) + +Internal Changes: +----------------- +* Set sqlparse version dependency to >0.2.0, <0.3.0. (Thanks: `Amjith Ramanujam`_). +* XDG_CONFIG_HOME support for config file location. (Thanks: `Fabien Meghazi`_). +* Remove Python 2.6 from travis test suite. (Thanks: `Amjith Ramanujam`_) + +1.2.0 +===== + +Features: +--------- + +* Add more specifiers to pgcli prompt. (Thanks: `Julien Rouhaud`_). + ``\p`` for port info ``\#`` for super user and ``\i`` for pid. +* Add `\watch` command to periodically execute a command. (Thanks: `Stuart Quin`_). + ``> SELECT * FROM django_migrations; \watch 1 /* Runs the command every second */`` +* Add command-line option --single-connection to prevent pgcli from using multiple connections. (Thanks: `Joakim Koljonen`_). +* Add priority to the suggestions to sort based on relevance. (Thanks: `Joakim Koljonen`_). +* Configurable null format via the config file. (Thanks: `Adrian Dries`_). +* Add support for CTE aware auto-completion. (Thanks: `Darik Gamble`_). +* Add host and user information to default pgcli prompt. (Thanks: `Lim H`_). +* Better scoping for tables in insert statements to improve suggestions. (Thanks: `Joakim Koljonen`_). + +Bug Fixes: +---------- + +* Do not install setproctitle on cygwin. (Thanks: `Janus Troelsen`_). +* Work around sqlparse crashing after AS keyword. (Thanks: `Joakim Koljonen`_). +* Fix a crashing bug with named queries. (Thanks: `Joakim Koljonen`_). +* Replace timestampz alias since AWS Redshift does not support it. (Thanks: `Tahir Butt`_). +* Prevent pgcli from hanging indefinitely when Postgres instance is not running. (Thanks: `Darik Gamble`_) + +Internal Changes: +----------------- + +* Upgrade to sqlparse-0.2.0. (Thanks: `Tiziano Müller`_). +* Upgrade to pgspecial 1.6.0. (Thanks: `Stuart Quin`_). + + +1.1.0 +===== + +Features: +--------- + +* Add support for ``\db`` command. (Thanks: `Irina Truong`_) + +Bugs: +----- + +* Fix the crash at startup while parsing the postgres url with port number. (Thanks: `Eric Wald`_) +* Fix the crash with Redshift databases. (Thanks: `Darik Gamble`_) + +Internal Changes: +----------------- + +* Upgrade pgspecial to 1.5.0 and above. + +1.0.0 +===== + +Features: +--------- + +* Upgrade to prompt-toolkit 1.0.0. (Thanks: `Jonathan Slenders`_). +* Add support for `\o` command to redirect query output to a file. (Thanks: `Tim Sanders`_). +* Add `\i` path completion. (Thanks: `Anthony Lai`_). +* Connect to a dsn saved in config file. (Thanks: `Rodrigo Ramírez Norambuena`_). +* Upgrade sqlparse requirement to version 0.1.19. (Thanks: `Fernando L. Canizo`_). +* Add timestamptz to DATE custom extension. (Thanks: `Fernando Mora`_). +* Ensure target dir exists when copying config. (Thanks: `David Szotten`_). +* Handle dates that fall in the B.C. range. (Thanks: `Stuart Quin`_). +* Pager is selected from config file or else from environment variable. (Thanks: `Fernando Mora`_). +* Add support for Amazon Redshift. (Thanks: `Timothy Cleaver`_). +* Add support for Postgres 8.x. (Thanks: `Timothy Cleaver`_ and `Darik Gamble`_) +* Don't error when completing parameter-less functions. (Thanks: `David Szotten`_). +* Concat and return all available notices. (Thanks: `Stuart Quin`_). +* Handle unicode in record type. (Thanks: `Amjith Ramanujam`_). +* Added humanized time display. Connect #396. (Thanks: `Irina Truong`_). +* Add EXPLAIN keyword to the completion list. (Thanks: `Amjith Ramanujam`_). +* Added sdist upload to release script. (Thanks: `Irina Truong`_). +* Sort completions based on most recently used. (Thanks: `Darik Gamble`) +* Expand '*' into column list during completion. This can be triggered by hitting `` after the '*' character in the sql while typing. (Thanks: `Joakim Koljonen`_) +* Add a limit to the warning about too many rows. This is controlled by a new config value in ~/.config/pgcli/config. (Thanks: `Anže Pečar`_) +* Improved argument list in function parameter completions. (Thanks: `Joakim Koljonen`_) +* Column suggestions after the COLUMN keyword. (Thanks: `Darik Gamble`_) +* Filter out trigger implemented functions from the suggestion list. (Thanks: `Daniel Rocco`_) +* State of the art JOIN clause completions that suggest entire conditions. (Thanks: `Joakim Koljonen`_) +* Suggest fully formed JOIN clauses based on Foreign Key relations. (Thanks: `Joakim Koljonen`_) +* Add support for `\dx` meta command to list the installed extensions. (Thanks: `Darik Gamble`_) +* Add support for `\copy` command. (Thanks: `Catherine Devlin`_) + +Bugs: +----- + +* Fix bug where config writing would leave a '~' dir. (Thanks: `James Munson`_). +* Fix auto-completion breaking for table names with caps. (Thanks: `Anthony Lai`_). +* Fix lexical ordering bug. (Thanks: `Anthony Lai`_). +* Use lexical order to break ties when fuzzy matching. (Thanks: `Daniel Rocco`_). +* Fix the bug in auto-expand mode when there are no rows to display. (Thanks: `Amjith Ramanujam`_). +* Fix broken `\i` after #395. (Thanks: `David Szotten`_). +* Fix multi-way joins in auto-completion. (Thanks: `Darik Gamble`_) +* Display null values as in expanded output. (Thanks: `Amjith Ramanujam`_). +* Robust support for Postgres version less than 9.x. (Thanks: `Darik Gamble`_) + +Internal Changes: +----------------- + +* Update config file location in README. (Thanks: `Ari Summer`_). +* Explicitly add wcwidth as a dependency. (Thanks: `Amjith Ramanujam`_). +* Add tests for the format_output. (Thanks: `Amjith Ramanujam`_). +* Lots of tests for pgcompleter. (Thanks: `Darik Gamble`_). +* Update pgspecial dependency to 1.4.0. + + +0.20.1 +====== + +Bug Fixes: +---------- +* Fixed logging in Windows by switching the location of log and history file based on OS. (Thanks: Amjith, `Darik Gamble`_, `Irina Truong`_). + +0.20.0 +====== + +Features: +--------- +* Perform auto-completion refresh in background. (Thanks: Amjith, `Darik Gamble`_, `Irina Truong`_). + When the auto-completion entries are refreshed, the update now happens in a + background thread. This means large databases with thousands of tables are + handled without blocking. +* Add ``CONCURRENTLY`` to keyword completion. (Thanks: `Johannes Hoff`_). +* Add support for ``\h`` command. (Thanks: `Stuart Quin`_). + This is a huge deal. Users can now get help on an SQL command by typing: + ``\h COMMAND_NAME`` in the pgcli prompt. +* Add support for ``\x auto``. (Thanks: `Stuart Quin`_). + ``\\x auto`` will automatically switch to expanded mode if the output is wider + than the display window. +* Don't hide functions from pg_catalog. (Thanks: `Darik Gamble`_). +* Suggest set-returning functions as tables. (Thanks: `Darik Gamble`_). + Functions that return table like results will now be suggested in places of tables. +* Suggest fields from functions used as tables. (Thanks: `Darik Gamble`_). +* Using ``pgspecial`` as a separate module. (Thanks: `Irina Truong`_). +* Make "enter" key behave as "tab" key when the completion menu is displayed. (Thanks: `Matheus Rosa`_). +* Support different error-handling options when running multiple queries. (Thanks: `Darik Gamble`_). + When ``on_error = STOP`` in the config file, pgcli will abort execution if one of the queries results in an error. +* Hide the password displayed in the process name in ``ps``. (Thanks: `Stuart Quin`_) + +Bug Fixes: +---------- +* Fix the ordering bug in `\\d+` display, this bug was displaying the wrong table name in the reference. (Thanks: `Tamas Boros`_). +* Only show expanded layout if valid list of headers provided. (Thanks: `Stuart Quin`_). +* Fix suggestions in compound join clauses. (Thanks: `Darik Gamble`_). +* Fix completion refresh in multiple query scenario. (Thanks: `Darik Gamble`_). +* Fix the broken timing information. +* Fix the removal of whitespaces in the output. (Thanks: `Jacek Wielemborek`_) +* Fix PyPI badge. (Thanks: `Artur Dryomov`_). + +Improvements: +------------- +* Move config file to `~/.config/pgcli/config` instead of `~/.pgclirc` (Thanks: `inkn`_). +* Move literal definitions to standalone JSON files. (Thanks: `Darik Gamble`_). + +Internal Changes: +----------------- +* Improvements to integration tests to make it more robust. (Thanks: `Irina Truong`_). + +0.19.2 +====== + +Features: +--------- + +* Autocompletion for database name in \c and \connect. (Thanks: `Darik Gamble`_). +* Improved multiline query support by correctly handling open quotes. (Thanks: `Darik Gamble`_). +* Added \pager command. +* Enhanced \i to run multiple queries and display the results for each of them +* Added keywords to suggestions after WHERE clause. +* Enabled autocompletion in named queries. (Thanks: `Irina Truong`_). +* Path to .pgclirc can be specified in command line. (Thanks: `Irina Truong`_). +* Added support for pg_service_conf file. (Thanks: `Irina Truong`_). +* Added custom styles. (Contributor: `Darik Gamble`_). + +Internal Changes: +----------------- + +* More completer test cases. (Thanks: `Darik Gamble`_). +* Updated sqlparse version from 0.1.14 to 0.1.16. (Thanks: `Darik Gamble`_). +* Upgraded to prompt_toolkit 0.46. (Thanks: `Jonathan Slenders`_). + +BugFixes: +--------- +* Fixed the completer crashing on invalid SQL. (Thanks: `Darik Gamble`_). +* Fixed unicode issues, updated tests and fixed broken tests. + +0.19.1 +====== + +BugFixes: +--------- + +* Fix an autocompletion bug that was crashing the completion engine when unknown keyword is entered. (Thanks: `Darik Gamble`_) + +0.19.0 +====== + +Features: +--------- + +* Wider completion menus can be enabled via the config file. (Thanks: `Jonathan Slenders`_) + + Open the config file (~/.pgclirc) and check if you have + ``wider_completion_menu`` option available. If not add it in and set it to + ``True``. + +* Completion menu now has metadata information such as schema, table, column, view, etc., next to the suggestions. (Thanks: `Darik Gamble`_) +* Customizable history file location via config file. (Thanks: `Çağatay Yüksel`_) + + Add this line to your config file (~/.pgclirc) to customize where to store the history file. + +:: + + history_file = /path/to/history/file + +* Add support for running queries from a file using ``\i`` special command. (Thanks: `Michael Kaminsky`_) + +BugFixes: +--------- + +* Always use utf-8 for database encoding regardless of the default encoding used by the database. +* Fix for None dereference on ``\d schemaname.`` with sequence. (Thanks: `Nathan Jhaveri`_) +* Fix a crashing bug in the autocompletion engine for some ``JOIN`` queries. +* Handle KeyboardInterrupt in pager and not quit pgcli as a consequence. + +Internal Changes: +----------------- + +* Added more behaviorial tests (Thanks: `Irina Truong`_) +* Added code coverage to the tests. (Thanks: `Irina Truong`_) +* Run behaviorial tests as part of TravisCI (Thanks: `Irina Truong`_) +* Upgraded prompt_toolkit version to 0.45 (Thanks: `Jonathan Slenders`_) +* Update the minumum required version of click to 4.1. + +0.18.0 +====== + +Features: +--------- + +* Add fuzzy matching for the table names and column names. + + Matching very long table/column names are now easier with fuzzy matching. The + fuzzy match works like the fuzzy open in SublimeText or Vim's Ctrl-P plugin. + + eg: Typing ``djmv`` will match `django_migration_views` since it is able to + match parts of the input to the full table name. + +* Change the timing information to seconds. + + The ``Command Time`` and ``Format Time`` are now displayed in seconds instead + of a unitless number displayed in scientific notation. + +* Support for named queries (favorite queries). (Thanks: `Brett Atoms`_) + + Frequently typed queries can now be saved and recalled using a name using + newly added special commands (``\n[+]``, ``\ns``, ``\nd``). + + eg: + +:: + + # Save a query + pgcli> \ns simple select * from foo + saved + + # List all saved queries + pgcli> \n+ + + # Execute a saved query + pgcli> \n simple + + # Delete a saved query + pgcli> \nd simple + +* Pasting queries into the pgcli repl is orders of magnitude faster. (Thanks: `Jonathan Slenders`_) + +* Add support for PGPASSWORD environment variable to pass the password for the + postgres database. (Thanks: `Irina Truong`_) + +* Add the ability to manually refresh autocompletions by typing ``\#`` or + ``\refresh``. This is useful if the database was updated by an external means + and you'd like to refresh the auto-completions to pick up the new change. + +Bug Fixes: +---------- + +* Fix an error when running ``\d table_name`` when running on a table with rules. (Thanks: `Ali Kargın`_) +* Fix a pgcli crash when entering non-ascii characters in Windows. (Thanks: `Darik Gamble`_, `Jonathan Slenders`_) +* Faster rendering of expanded mode output by making the horizontal separator a fixed length string. +* Completion suggestions for the ``\c`` command are not auto-escaped by default. + +Internal Changes: +----------------- + +* Complete refactor of handling the back-slash commands. +* Upgrade prompt_toolkit to 0.42. (Thanks: `Jonathan Slenders`_) +* Change the config file management to use ConfigObj.(Thanks: `Brett Atoms`_) +* Add integration tests using ``behave``. (Thanks: `Irina Truong`_) + +0.17.0 +====== + +Features: +--------- + +* Add support for auto-completing view names. (Thanks: `Darik Gamble`_) +* Add support for building RPM and DEB packages. (Thanks: dp_) +* Add subsequence matching for completion. (Thanks: `Daniel Rocco`_) + Previously completions only matched a table name if it started with the + partially typed word. Now completions will match even if the partially typed + word is in the middle of a suggestion. + eg: When you type 'mig', 'django_migrations' will be suggested. +* Completion for built-in tables and temporary tables are suggested after entering a prefix of ``pg_``. (Thanks: `Darik Gamble`_) +* Add place holder doc strings for special commands that are planned for implementation. (Thanks: `Irina Truong`_) +* Updated version of prompt_toolkit, now matching braces are highlighted. (Thanks: `Jonathan Slenders`_) +* Added support of ``\\e`` command. Queries can be edited in an external editor. (Thanks: `Irina Truong`_) + eg: When you type ``SELECT * FROM \e`` it will be opened in an external editor. +* Add special command ``\dT`` to show datatypes. (Thanks: `Darik Gamble`_) +* Add auto-completion support for datatypes in CREATE, SELECT etc. (Thanks: `Darik Gamble`_) +* Improve the auto-completion in WHERE clause with logical operators. (Thanks: `Darik Gamble`_) +* + +Bug Fixes: +---------- + +* Fix the table formatting while printing multi-byte characters (Chinese, Japanese etc). (Thanks: `蔡佳男`_) +* Fix a crash when pg_catalog was present in search path. (Thanks: `Darik Gamble`_) +* Fixed a bug that broke `\\e` when prompt_tookit was updated. (Thanks: `François Pietka`_) +* Fix the display of triggers as shown in the ``\d`` output. (Thanks: `Dimitar Roustchev`_) +* Fix broken auto-completion for INNER JOIN, LEFT JOIN etc. (Thanks: `Darik Gamble`_) +* Fix incorrect super() calls in pgbuffer, pgtoolbar and pgcompleter. No change in functionality but protects against future problems. (Thanks: `Daniel Rocco`_) +* Add missing schema completion for CREATE and DROP statements. (Thanks: `Darik Gamble`_) +* Minor fixes around cursor cleanup. + +0.16.3 +====== + +Bug Fixes: +---------- +* Add more SQL keywords for auto-complete suggestion. +* Messages raised as part of stored procedures are no longer ignored. +* Use postgres flavored syntax highlighting instead of generic ANSI SQL. + +0.16.2 +====== + +Bug Fixes: +---------- +* Fix a bug where the schema qualifier was ignored by the auto-completion. + As a result the suggestions for tables vs functions are cleaner. (Thanks: `Darik Gamble`_) +* Remove scientific notation when formatting large numbers. (Thanks: `Daniel Rocco`_) +* Add the FUNCTION keyword to auto-completion. +* Display NULL values as instead of empty strings. +* Fix the completion refresh when ``\connect`` is executed. + +0.16.1 +====== + +Bug Fixes: +---------- +* Fix unicode issues with hstore. +* Fix a silent error when database is changed using \\c. + +0.16.0 +====== + +Features: +--------- +* Add \ds special command to show sequences. +* Add Vi mode for keybindings. This can be enabled by adding 'vi = True' in ~/.pgclirc. (Thanks: `Jay Zeng`_) +* Add a -v/--version flag to pgcli. +* Add completion for TEMPLATE keyword and smart-completion for + 'CREATE DATABASE blah WITH TEMPLATE '. (Thanks: `Daniel Rocco`_) +* Add custom decoders to json/jsonb to emulate the behavior of psql. This + removes the unicode prefix (eg: u'Éowyn') in the output. (Thanks: `Daniel Rocco`_) +* Add \df special command to show functions. (Thanks: `Darik Gamble`_) +* Make suggestions for special commands smarter. eg: \dn - only suggests schemas. (Thanks: `Darik Gamble`_) +* Print out the version and other meta info about pgcli at startup. + +Bug Fixes: +---------- +* Fix a rare crash caused by adding new schemas to a database. (Thanks: `Darik Gamble`_) +* Make \dt command honor the explicit schema specified in the arg. (Thanks: `Darik Gamble`_) +* Print BIGSERIAL type as Integer instead of Float. +* Show completions for special commands at the beginning of a statement. (Thanks: `Daniel Rocco`_) +* Allow special commands to work in a multi-statement case where multiple sql + statements are separated by semi-colon in the same line. + +0.15.4 +====== +* Dummy version to replace accidental PyPI entry deletion. + +0.15.3 +====== +* Override the LESS options completely instead of appending to it. + +0.15.2 +====== +* Revert back to using psycopg2 as the postgres adapter. psycopg2cffi fails for some tests in Python 3. + +0.15.0 +====== + +Features: +--------- +* Add syntax color styles to config. +* Add auto-completion for COPY statements. +* Change Postgres adapter to psycopg2cffi, to make it PyPy compatible. + Now pgcli can be run by PyPy. + +Bug Fixes: +---------- +* Treat boolean values as strings instead of ints. +* Make \di, \dv and \dt to be schema aware. (Thanks: `Darik Gamble`_) +* Make column name display unicode compatible. + +0.14.0 +====== + +Features: +--------- +* Add alias completion support to ON keyword. (Thanks: `Irina Truong`_) +* Add LIMIT keyword to completion. +* Auto-completion for Postgres schemas. (Thanks: `Darik Gamble`_) +* Better unicode handling for datatypes, dbname and roles. +* Add \timing command to time the sql commands. + This can be set via config file (~/.pgclirc) using `timing = True`. +* Add different table styles for displaying output. + This can be changed via config file (~/.pgclirc) using `table_format = fancy_grid`. +* Add confirmation before printing results that have more than 1000 rows. + +Bug Fixes: +---------- + +* Performance improvements to expanded view display (\x). +* Cast bytea files to text while displaying. (Thanks: `Daniel Rocco`_) +* Added a list of reserved words that should be auto-escaped. +* Auto-completion is now case-insensitive. +* Fix the broken completion for multiple sql statements. (Thanks: `Darik Gamble`_) + +0.13.0 +====== + +Features: +--------- + +* Add -d/--dbname option to the commandline. + eg: pgcli -d database +* Add the username as an argument after the database. + eg: pgcli dbname user + +Bug Fixes: +---------- +* Fix the crash when \c fails. +* Fix the error thrown by \d when triggers are present. +* Fix broken behavior on \?. (Thanks: `Darik Gamble`_) + +0.12.0 +====== + +Features: +--------- + +* Upgrade to prompt_toolkit version 0.26 (Thanks: https://github.com/macobo) + * Adds Ctrl-left/right to move the cursor one word left/right respectively. + * Internal API changes. +* IPython integration through `ipython-sql`_ (Thanks: `Darik Gamble`_) + * Add an ipython magic extension to embed pgcli inside ipython. + * Results from a pgcli query are sent back to ipython. +* Multiple sql statments in the same line separated by semi-colon. (Thanks: https://github.com/macobo) + +.. _`ipython-sql`: https://github.com/catherinedevlin/ipython-sql + +Bug Fixes: +---------- + +* Fix 'message' attribute not found exception in Python 3. (Thanks: https://github.com/GMLudo) +* Use the database username as the database name instead of defaulting to OS username. (Thanks: https://github.com/fpietka) +* Auto-completion for auto-escaped column/table names. +* Fix i-reverse-search to work in prompt_toolkit version 0.26. + +0.11.0 +====== + +Features: +--------- + +* Add \dn command. (Thanks: https://github.com/CyberDem0n) +* Add \x command. (Thanks: https://github.com/stuartquin) +* Auto-escape special column/table names. (Thanks: https://github.com/qwesda) +* Cancel a command using Ctrl+C. (Thanks: https://github.com/macobo) +* Faster startup by reading all columns and tables in a single query. (Thanks: https://github.com/macobo) +* Improved psql compliance with env vars and password prompting. (Thanks: `Darik Gamble`_) +* Pressing Alt-Enter will introduce a line break. This is a way to break up the query into multiple lines without switching to multi-line mode. (Thanks: https://github.com/pabloab). + +Bug Fixes: +---------- +* Fix the broken behavior of \d+. (Thanks: https://github.com/macobo) +* Fix a crash during auto-completion. (Thanks: https://github.com/Erethon) +* Avoid losing pre_run_callables on error in editing. (Thanks: https://github.com/catherinedevlin) + +Improvements: +------------- +* Faster test runs on TravisCI. (Thanks: https://github.com/macobo) +* Integration tests with Postgres!! (Thanks: https://github.com/macobo) + +.. _`Amjith Ramanujam`: https://blog.amjith.com +.. _`Andrew Kuchling`: https://github.com/akuchling +.. _`Darik Gamble`: https://github.com/darikg +.. _`Daniel Rocco`: https://github.com/drocco007 +.. _`Jay Zeng`: https://github.com/jayzeng +.. _`蔡佳男`: https://github.com/xalley +.. _dp: https://github.com/ceocoder +.. _`Jonathan Slenders`: https://github.com/jonathanslenders +.. _`Dimitar Roustchev`: https://github.com/droustchev +.. _`François Pietka`: https://github.com/fpietka +.. _`Ali Kargın`: https://github.com/sancopanco +.. _`Brett Atoms`: https://github.com/brettatoms +.. _`Nathan Jhaveri`: https://github.com/nathanjhaveri +.. _`Çağatay Yüksel`: https://github.com/cagatay +.. _`Michael Kaminsky`: https://github.com/mikekaminsky +.. _`inkn`: inkn +.. _`Johannes Hoff`: Johannes Hoff +.. _`Matheus Rosa`: Matheus Rosa +.. _`Artur Dryomov`: https://github.com/ming13 +.. _`Stuart Quin`: https://github.com/stuartquin +.. _`Tamas Boros`: https://github.com/TamasNo1 +.. _`Jacek Wielemborek`: https://github.com/d33tah +.. _`Rodrigo Ramírez Norambuena`: https://github.com/roramirez +.. _`Anthony Lai`: https://github.com/ajlai +.. _`Ari Summer`: Ari Summer +.. _`David Szotten`: David Szotten +.. _`Fernando L. Canizo`: Fernando L. Canizo +.. _`Tim Sanders`: https://github.com/Gollum999 +.. _`Irina Truong`: https://github.com/j-bennet +.. _`James Munson`: https://github.com/jmunson +.. _`Fernando Mora`: https://github.com/fernandomora +.. _`Timothy Cleaver`: Timothy Cleaver +.. _`gtxx`: gtxx +.. _`Joakim Koljonen`: https://github.com/koljonen +.. _`Anže Pečar`: https://github.com/Smotko +.. _`Catherine Devlin`: https://github.com/catherinedevlin +.. _`Eric Wald`: https://github.com/eswald +.. _`avdd`: https://github.com/avdd +.. _`Adrian Dries`: Adrian Dries +.. _`Julien Rouhaud`: https://github.com/rjuju +.. _`Lim H`: Lim H +.. _`Tahir Butt`: Tahir Butt +.. _`Tiziano Müller`: https://github.com/dev-zero +.. _`Janus Troelsen`: https://github.com/ysangkok +.. _`Fabien Meghazi`: https://github.com/amigrave +.. _`Manuel Barkhau`: https://github.com/mbarkhau +.. _`Sergii`: https://github.com/foxyterkel +.. _`Lele Gaifax`: https://github.com/lelit +.. _`tk`: https://github.com/kanet77 +.. _`Owen Stephens`: https://github.com/owst +.. _`Russell Davies`: https://github.com/russelldavies +.. _`Dick Marinus`: https://github.com/meeuw +.. _`Étienne Bersac`: https://github.com/bersace +.. _`Thomas Roten`: https://github.com/tsroten +.. _`Gustavo Castro`: https://github.com/gustavo-castro +.. _`Alexander Schmolck`: https://github.com/aschmolck +.. _`Andrew Speed`: https://github.com/AndrewSpeed +.. _`Dmitry B`: https://github.com/oxitnik +.. _`Marcin Sztolcman`: https://github.com/msztolcman +.. _`Isank`: https://github.com/isank +.. _`Bojan Delić`: https://github.com/delicb +.. _`Frederic Aoustin`: https://github.com/fraoustin +.. _`Jason Ribeiro`: https://github.com/jrib +.. _`Rishi Ramraj`: https://github.com/RishiRamraj +.. _`Matthieu Guilbert`: https://github.com/gma2th +.. _`Alexandr Korsak`: https://github.com/oivoodoo +.. _`Saif Hakim`: https://github.com/saifelse +.. _`Artur Balabanov`: https://github.com/arturbalabanov +.. _`Kenny Do`: https://github.com/kennydo +.. _`Max Rothman`: https://github.com/maxrothman +.. _`Daniel Egger`: https://github.com/DanEEStar +.. _`Ignacio Campabadal`: https://github.com/igncampa +.. _`Mikhail Elovskikh`: https://github.com/wronglink +.. _`Marcin Cieślak`: https://github.com/saper +.. _`Scott Brenstuhl`: https://github.com/808sAndBR +.. _`easteregg`: https://github.com/verfriemelt-dot-org +.. _`Nathan Verzemnieks`: https://github.com/njvrzm +.. _`raylu`: https://github.com/raylu +.. _`Zhaolong Zhu`: https://github.com/zzl0 +.. _`Xavier Francisco`: https://github.com/Qu4tro +.. _`VVelox`: https://github.com/VVelox +.. _`Telmo "Trooper"`: https://github.com/telmotrooper +.. _`Alexander Zawadzki`: https://github.com/zadacka +.. _`Sebastian Janko`: https://github.com/sebojanko +.. _`Pedro Ferrari`: https://github.com/petobens +.. _`BrownShibaDog`: https://github.com/BrownShibaDog +.. _`thegeorgeous`: https://github.com/thegeorgeous +.. _`laixintao`: https://github.com/laixintao +.. _`anthonydb`: https://github.com/anthonydb diff --git a/pgcli-completion.bash b/pgcli-completion.bash new file mode 100644 index 0000000..3549b56 --- /dev/null +++ b/pgcli-completion.bash @@ -0,0 +1,61 @@ +_pg_databases() +{ + # -w was introduced in 8.4, https://launchpad.net/bugs/164772 + # "Access privileges" in output may contain linefeeds, hence the NF > 1 + COMPREPLY=( $( compgen -W "$( psql -AtqwlF $'\t' 2>/dev/null | \ + awk 'NF > 1 { print $1 }' )" -- "$cur" ) ) +} + +_pg_users() +{ + # -w was introduced in 8.4, https://launchpad.net/bugs/164772 + COMPREPLY=( $( compgen -W "$( psql -Atqwc 'select usename from pg_user' \ + template1 2>/dev/null )" -- "$cur" ) ) + [[ ${#COMPREPLY[@]} -eq 0 ]] && COMPREPLY=( $( compgen -u -- "$cur" ) ) +} + +_pgcli() +{ + local cur prev words cword + _init_completion -s || return + + case $prev in + -h|--host) + _known_hosts_real "$cur" + return 0 + ;; + -U|--user) + _pg_users + return 0 + ;; + -d|--dbname) + _pg_databases + return 0 + ;; + --help|-v|--version|-p|--port|-R|--row-limit) + # all other arguments are noop with these + return 0 + ;; + esac + + case "$cur" in + --*) + # return list of available options + COMPREPLY=( $( compgen -W '--host --port --user --password --no-password + --single-connection --version --dbname --pgclirc --dsn + --row-limit --help' -- "$cur" ) ) + [[ $COMPREPLY == *= ]] && compopt -o nospace + return 0 + ;; + -) + # only complete long options + compopt -o nospace + COMPREPLY=( -- ) + return 0 + ;; + *) + # return list of available databases + _pg_databases + esac +} && +complete -F _pgcli pgcli diff --git a/pgcli/__init__.py b/pgcli/__init__.py new file mode 100644 index 0000000..f5f41e5 --- /dev/null +++ b/pgcli/__init__.py @@ -0,0 +1 @@ +__version__ = "3.1.0" diff --git a/pgcli/__main__.py b/pgcli/__main__.py new file mode 100644 index 0000000..ddf1662 --- /dev/null +++ b/pgcli/__main__.py @@ -0,0 +1,9 @@ +""" +pgcli package main entry point +""" + +from .main import cli + + +if __name__ == "__main__": + cli() diff --git a/pgcli/completion_refresher.py b/pgcli/completion_refresher.py new file mode 100644 index 0000000..cf0879f --- /dev/null +++ b/pgcli/completion_refresher.py @@ -0,0 +1,150 @@ +import threading +import os +from collections import OrderedDict + +from .pgcompleter import PGCompleter +from .pgexecute import PGExecute + + +class CompletionRefresher(object): + + refreshers = OrderedDict() + + def __init__(self): + self._completer_thread = None + self._restart_refresh = threading.Event() + + def refresh(self, executor, special, callbacks, history=None, settings=None): + """ + Creates a PGCompleter object and populates it with the relevant + completion suggestions in a background thread. + + executor - PGExecute object, used to extract the credentials to connect + to the database. + special - PGSpecial object used for creating a new completion object. + settings - dict of settings for completer object + callbacks - A function or a list of functions to call after the thread + has completed the refresh. The newly created completion + object will be passed in as an argument to each callback. + """ + if self.is_refreshing(): + self._restart_refresh.set() + return [(None, None, None, "Auto-completion refresh restarted.")] + else: + self._completer_thread = threading.Thread( + target=self._bg_refresh, + args=(executor, special, callbacks, history, settings), + name="completion_refresh", + ) + self._completer_thread.setDaemon(True) + self._completer_thread.start() + return [ + (None, None, None, "Auto-completion refresh started in the background.") + ] + + def is_refreshing(self): + return self._completer_thread and self._completer_thread.is_alive() + + def _bg_refresh(self, pgexecute, special, callbacks, history=None, settings=None): + settings = settings or {} + completer = PGCompleter( + smart_completion=True, pgspecial=special, settings=settings + ) + + if settings.get("single_connection"): + executor = pgexecute + else: + # Create a new pgexecute method to populate the completions. + executor = pgexecute.copy() + # If callbacks is a single function then push it into a list. + if callable(callbacks): + callbacks = [callbacks] + + while 1: + for refresher in self.refreshers.values(): + refresher(completer, executor) + if self._restart_refresh.is_set(): + self._restart_refresh.clear() + break + else: + # Break out of while loop if the for loop finishes natually + # without hitting the break statement. + break + + # Start over the refresh from the beginning if the for loop hit the + # break statement. + continue + + # Load history into pgcompleter so it can learn user preferences + n_recent = 100 + if history: + for recent in history.get_strings()[-n_recent:]: + completer.extend_query_history(recent, is_init=True) + + for callback in callbacks: + callback(completer) + + if not settings.get("single_connection") and executor.conn: + # close connection established with pgexecute.copy() + executor.conn.close() + + +def refresher(name, refreshers=CompletionRefresher.refreshers): + """Decorator to populate the dictionary of refreshers with the current + function. + """ + + def wrapper(wrapped): + refreshers[name] = wrapped + return wrapped + + return wrapper + + +@refresher("schemata") +def refresh_schemata(completer, executor): + completer.set_search_path(executor.search_path()) + completer.extend_schemata(executor.schemata()) + + +@refresher("tables") +def refresh_tables(completer, executor): + completer.extend_relations(executor.tables(), kind="tables") + completer.extend_columns(executor.table_columns(), kind="tables") + completer.extend_foreignkeys(executor.foreignkeys()) + + +@refresher("views") +def refresh_views(completer, executor): + completer.extend_relations(executor.views(), kind="views") + completer.extend_columns(executor.view_columns(), kind="views") + + +@refresher("types") +def refresh_types(completer, executor): + completer.extend_datatypes(executor.datatypes()) + + +@refresher("databases") +def refresh_databases(completer, executor): + completer.extend_database_names(executor.databases()) + + +@refresher("casing") +def refresh_casing(completer, executor): + casing_file = completer.casing_file + if not casing_file: + return + generate_casing_file = completer.generate_casing_file + if generate_casing_file and not os.path.isfile(casing_file): + casing_prefs = "\n".join(executor.casing()) + with open(casing_file, "w") as f: + f.write(casing_prefs) + if os.path.isfile(casing_file): + with open(casing_file, "r") as f: + completer.extend_casing([line.strip() for line in f]) + + +@refresher("functions") +def refresh_functions(completer, executor): + completer.extend_functions(executor.functions()) diff --git a/pgcli/config.py b/pgcli/config.py new file mode 100644 index 0000000..0fc42dd --- /dev/null +++ b/pgcli/config.py @@ -0,0 +1,64 @@ +import errno +import shutil +import os +import platform +from os.path import expanduser, exists, dirname +from configobj import ConfigObj + + +def config_location(): + if "XDG_CONFIG_HOME" in os.environ: + return "%s/pgcli/" % expanduser(os.environ["XDG_CONFIG_HOME"]) + elif platform.system() == "Windows": + return os.getenv("USERPROFILE") + "\\AppData\\Local\\dbcli\\pgcli\\" + else: + return expanduser("~/.config/pgcli/") + + +def load_config(usr_cfg, def_cfg=None): + cfg = ConfigObj() + cfg.merge(ConfigObj(def_cfg, interpolation=False)) + cfg.merge(ConfigObj(expanduser(usr_cfg), interpolation=False, encoding="utf-8")) + cfg.filename = expanduser(usr_cfg) + + return cfg + + +def ensure_dir_exists(path): + parent_dir = expanduser(dirname(path)) + os.makedirs(parent_dir, exist_ok=True) + + +def write_default_config(source, destination, overwrite=False): + destination = expanduser(destination) + if not overwrite and exists(destination): + return + + ensure_dir_exists(destination) + + shutil.copyfile(source, destination) + + +def upgrade_config(config, def_config): + cfg = load_config(config, def_config) + cfg.write() + + +def get_config(pgclirc_file=None): + from pgcli import __file__ as package_root + + package_root = os.path.dirname(package_root) + + pgclirc_file = pgclirc_file or "%sconfig" % config_location() + + default_config = os.path.join(package_root, "pgclirc") + write_default_config(default_config, pgclirc_file) + + return load_config(pgclirc_file, default_config) + + +def get_casing_file(config): + casing_file = config["main"]["casing_file"] + if casing_file == "default": + casing_file = config_location() + "casing" + return casing_file diff --git a/pgcli/key_bindings.py b/pgcli/key_bindings.py new file mode 100644 index 0000000..23174b6 --- /dev/null +++ b/pgcli/key_bindings.py @@ -0,0 +1,127 @@ +import logging +from prompt_toolkit.enums import EditingMode +from prompt_toolkit.key_binding import KeyBindings +from prompt_toolkit.filters import ( + completion_is_selected, + is_searching, + has_completions, + has_selection, + vi_mode, +) + +from .pgbuffer import buffer_should_be_handled + +_logger = logging.getLogger(__name__) + + +def pgcli_bindings(pgcli): + """Custom key bindings for pgcli.""" + kb = KeyBindings() + + tab_insert_text = " " * 4 + + @kb.add("f2") + def _(event): + """Enable/Disable SmartCompletion Mode.""" + _logger.debug("Detected F2 key.") + pgcli.completer.smart_completion = not pgcli.completer.smart_completion + + @kb.add("f3") + def _(event): + """Enable/Disable Multiline Mode.""" + _logger.debug("Detected F3 key.") + pgcli.multi_line = not pgcli.multi_line + + @kb.add("f4") + def _(event): + """Toggle between Vi and Emacs mode.""" + _logger.debug("Detected F4 key.") + pgcli.vi_mode = not pgcli.vi_mode + event.app.editing_mode = EditingMode.VI if pgcli.vi_mode else EditingMode.EMACS + + @kb.add("tab") + def _(event): + """Force autocompletion at cursor on non-empty lines.""" + + _logger.debug("Detected key.") + + buff = event.app.current_buffer + doc = buff.document + + if doc.on_first_line or doc.current_line.strip(): + if buff.complete_state: + buff.complete_next() + else: + buff.start_completion(select_first=True) + else: + buff.insert_text(tab_insert_text, fire_event=False) + + @kb.add("escape", filter=has_completions) + def _(event): + """Force closing of autocompletion.""" + _logger.debug("Detected key.") + + event.current_buffer.complete_state = None + event.app.current_buffer.complete_state = None + + @kb.add("c-space") + def _(event): + """ + Initialize autocompletion at cursor. + + If the autocompletion menu is not showing, display it with the + appropriate completions for the context. + + If the menu is showing, select the next completion. + """ + _logger.debug("Detected key.") + + b = event.app.current_buffer + if b.complete_state: + b.complete_next() + else: + b.start_completion(select_first=False) + + @kb.add("enter", filter=completion_is_selected) + def _(event): + """Makes the enter key work as the tab key only when showing the menu. + + In other words, don't execute query when enter is pressed in + the completion dropdown menu, instead close the dropdown menu + (accept current selection). + + """ + _logger.debug("Detected enter key during completion selection.") + + event.current_buffer.complete_state = None + event.app.current_buffer.complete_state = None + + # When using multi_line input mode the buffer is not handled on Enter (a new line is + # inserted instead), so we force the handling if we're not in a completion or + # history search, and one of several conditions are True + @kb.add( + "enter", + filter=~(completion_is_selected | is_searching) + & buffer_should_be_handled(pgcli), + ) + def _(event): + _logger.debug("Detected enter key.") + event.current_buffer.validate_and_handle() + + @kb.add("escape", "enter", filter=~vi_mode) + def _(event): + """Introduces a line break regardless of multi-line mode or not.""" + _logger.debug("Detected alt-enter key.") + event.app.current_buffer.insert_text("\n") + + @kb.add("c-p", filter=~has_selection) + def _(event): + """Move up in history.""" + event.current_buffer.history_backward(count=event.arg) + + @kb.add("c-n", filter=~has_selection) + def _(event): + """Move down in history.""" + event.current_buffer.history_forward(count=event.arg) + + return kb diff --git a/pgcli/magic.py b/pgcli/magic.py new file mode 100644 index 0000000..f58f415 --- /dev/null +++ b/pgcli/magic.py @@ -0,0 +1,67 @@ +from .main import PGCli +import sql.parse +import sql.connection +import logging + +_logger = logging.getLogger(__name__) + + +def load_ipython_extension(ipython): + """This is called via the ipython command '%load_ext pgcli.magic'""" + + # first, load the sql magic if it isn't already loaded + if not ipython.find_line_magic("sql"): + ipython.run_line_magic("load_ext", "sql") + + # register our own magic + ipython.register_magic_function(pgcli_line_magic, "line", "pgcli") + + +def pgcli_line_magic(line): + _logger.debug("pgcli magic called: %r", line) + parsed = sql.parse.parse(line, {}) + # "get" was renamed to "set" in ipython-sql: + # https://github.com/catherinedevlin/ipython-sql/commit/f4283c65aaf68f961e84019e8b939e4a3c501d43 + if hasattr(sql.connection.Connection, "get"): + conn = sql.connection.Connection.get(parsed["connection"]) + else: + conn = sql.connection.Connection.set(parsed["connection"]) + + try: + # A corresponding pgcli object already exists + pgcli = conn._pgcli + _logger.debug("Reusing existing pgcli") + except AttributeError: + # I can't figure out how to get the underylying psycopg2 connection + # from the sqlalchemy connection, so just grab the url and make a + # new connection + pgcli = PGCli() + u = conn.session.engine.url + _logger.debug("New pgcli: %r", str(u)) + + pgcli.connect(u.database, u.host, u.username, u.port, u.password) + conn._pgcli = pgcli + + # For convenience, print the connection alias + print("Connected: {}".format(conn.name)) + + try: + pgcli.run_cli() + except SystemExit: + pass + + if not pgcli.query_history: + return + + q = pgcli.query_history[-1] + + if not q.successful: + _logger.debug("Unsuccessful query - ignoring") + return + + if q.meta_changed or q.db_changed or q.path_changed: + _logger.debug("Dangerous query detected -- ignoring") + return + + ipython = get_ipython() + return ipython.run_cell_magic("sql", line, q.query) diff --git a/pgcli/main.py b/pgcli/main.py new file mode 100644 index 0000000..b146898 --- /dev/null +++ b/pgcli/main.py @@ -0,0 +1,1516 @@ +import platform +import warnings +from os.path import expanduser + +from configobj import ConfigObj +from pgspecial.namedqueries import NamedQueries + +warnings.filterwarnings("ignore", category=UserWarning, module="psycopg2") + +import os +import re +import sys +import traceback +import logging +import threading +import shutil +import functools +import pendulum +import datetime as dt +import itertools +import platform +from time import time, sleep +from codecs import open + +keyring = None # keyring will be loaded later + +from cli_helpers.tabular_output import TabularOutputFormatter +from cli_helpers.tabular_output.preprocessors import align_decimals, format_numbers +import click + +try: + import setproctitle +except ImportError: + setproctitle = None +from prompt_toolkit.completion import DynamicCompleter, ThreadedCompleter +from prompt_toolkit.enums import DEFAULT_BUFFER, EditingMode +from prompt_toolkit.shortcuts import PromptSession, CompleteStyle +from prompt_toolkit.document import Document +from prompt_toolkit.filters import HasFocus, IsDone +from prompt_toolkit.formatted_text import ANSI +from prompt_toolkit.lexers import PygmentsLexer +from prompt_toolkit.layout.processors import ( + ConditionalProcessor, + HighlightMatchingBracketProcessor, + TabsProcessor, +) +from prompt_toolkit.history import FileHistory +from prompt_toolkit.auto_suggest import AutoSuggestFromHistory +from pygments.lexers.sql import PostgresLexer + +from pgspecial.main import PGSpecial, NO_QUERY, PAGER_OFF, PAGER_LONG_OUTPUT +import pgspecial as special + +from .pgcompleter import PGCompleter +from .pgtoolbar import create_toolbar_tokens_func +from .pgstyle import style_factory, style_factory_output +from .pgexecute import PGExecute +from .completion_refresher import CompletionRefresher +from .config import ( + get_casing_file, + load_config, + config_location, + ensure_dir_exists, + get_config, +) +from .key_bindings import pgcli_bindings +from .packages.prompt_utils import confirm_destructive_query +from .__init__ import __version__ + +click.disable_unicode_literals_warning = True + +try: + from urlparse import urlparse, unquote, parse_qs +except ImportError: + from urllib.parse import urlparse, unquote, parse_qs + +from getpass import getuser +from psycopg2 import OperationalError, InterfaceError +import psycopg2 + +from collections import namedtuple + +from textwrap import dedent + +# Ref: https://stackoverflow.com/questions/30425105/filter-special-chars-such-as-color-codes-from-shell-output +COLOR_CODE_REGEX = re.compile(r"\x1b(\[.*?[@-~]|\].*?(\x07|\x1b\\))") + +# Query tuples are used for maintaining history +MetaQuery = namedtuple( + "Query", + [ + "query", # The entire text of the command + "successful", # True If all subqueries were successful + "total_time", # Time elapsed executing the query and formatting results + "execution_time", # Time elapsed executing the query + "meta_changed", # True if any subquery executed create/alter/drop + "db_changed", # True if any subquery changed the database + "path_changed", # True if any subquery changed the search path + "mutated", # True if any subquery executed insert/update/delete + "is_special", # True if the query is a special command + ], +) +MetaQuery.__new__.__defaults__ = ("", False, 0, 0, False, False, False, False) + +OutputSettings = namedtuple( + "OutputSettings", + "table_format dcmlfmt floatfmt missingval expanded max_width case_function style_output", +) +OutputSettings.__new__.__defaults__ = ( + None, + None, + None, + "", + False, + None, + lambda x: x, + None, +) + + +class PgCliQuitError(Exception): + pass + + +class PGCli(object): + default_prompt = "\\u@\\h:\\d> " + max_len_prompt = 30 + + def set_default_pager(self, config): + configured_pager = config["main"].get("pager") + os_environ_pager = os.environ.get("PAGER") + + if configured_pager: + self.logger.info( + 'Default pager found in config file: "%s"', configured_pager + ) + os.environ["PAGER"] = configured_pager + elif os_environ_pager: + self.logger.info( + 'Default pager found in PAGER environment variable: "%s"', + os_environ_pager, + ) + os.environ["PAGER"] = os_environ_pager + else: + self.logger.info( + "No default pager found in environment. Using os default pager" + ) + + # Set default set of less recommended options, if they are not already set. + # They are ignored if pager is different than less. + if not os.environ.get("LESS"): + os.environ["LESS"] = "-SRXF" + + def __init__( + self, + force_passwd_prompt=False, + never_passwd_prompt=False, + pgexecute=None, + pgclirc_file=None, + row_limit=None, + single_connection=False, + less_chatty=None, + prompt=None, + prompt_dsn=None, + auto_vertical_output=False, + warn=None, + ): + + self.force_passwd_prompt = force_passwd_prompt + self.never_passwd_prompt = never_passwd_prompt + self.pgexecute = pgexecute + self.dsn_alias = None + self.watch_command = None + + # Load config. + c = self.config = get_config(pgclirc_file) + + NamedQueries.instance = NamedQueries.from_config(self.config) + + self.logger = logging.getLogger(__name__) + self.initialize_logging() + + self.set_default_pager(c) + self.output_file = None + self.pgspecial = PGSpecial() + + self.multi_line = c["main"].as_bool("multi_line") + self.multiline_mode = c["main"].get("multi_line_mode", "psql") + self.vi_mode = c["main"].as_bool("vi") + self.auto_expand = auto_vertical_output or c["main"].as_bool("auto_expand") + self.expanded_output = c["main"].as_bool("expand") + self.pgspecial.timing_enabled = c["main"].as_bool("timing") + if row_limit is not None: + self.row_limit = row_limit + else: + self.row_limit = c["main"].as_int("row_limit") + + self.min_num_menu_lines = c["main"].as_int("min_num_menu_lines") + self.multiline_continuation_char = c["main"]["multiline_continuation_char"] + self.table_format = c["main"]["table_format"] + self.syntax_style = c["main"]["syntax_style"] + self.cli_style = c["colors"] + self.wider_completion_menu = c["main"].as_bool("wider_completion_menu") + c_dest_warning = c["main"].as_bool("destructive_warning") + self.destructive_warning = c_dest_warning if warn is None else warn + self.less_chatty = bool(less_chatty) or c["main"].as_bool("less_chatty") + self.null_string = c["main"].get("null_string", "") + self.prompt_format = ( + prompt + if prompt is not None + else c["main"].get("prompt", self.default_prompt) + ) + self.prompt_dsn_format = prompt_dsn + self.on_error = c["main"]["on_error"].upper() + self.decimal_format = c["data_formats"]["decimal"] + self.float_format = c["data_formats"]["float"] + self.initialize_keyring() + self.show_bottom_toolbar = c["main"].as_bool("show_bottom_toolbar") + + self.pgspecial.pset_pager( + self.config["main"].as_bool("enable_pager") and "on" or "off" + ) + + self.style_output = style_factory_output(self.syntax_style, c["colors"]) + + self.now = dt.datetime.today() + + self.completion_refresher = CompletionRefresher() + + self.query_history = [] + + # Initialize completer + smart_completion = c["main"].as_bool("smart_completion") + keyword_casing = c["main"]["keyword_casing"] + self.settings = { + "casing_file": get_casing_file(c), + "generate_casing_file": c["main"].as_bool("generate_casing_file"), + "generate_aliases": c["main"].as_bool("generate_aliases"), + "asterisk_column_order": c["main"]["asterisk_column_order"], + "qualify_columns": c["main"]["qualify_columns"], + "case_column_headers": c["main"].as_bool("case_column_headers"), + "search_path_filter": c["main"].as_bool("search_path_filter"), + "single_connection": single_connection, + "less_chatty": less_chatty, + "keyword_casing": keyword_casing, + } + + completer = PGCompleter( + smart_completion, pgspecial=self.pgspecial, settings=self.settings + ) + self.completer = completer + self._completer_lock = threading.Lock() + self.register_special_commands() + + self.prompt_app = None + + def quit(self): + raise PgCliQuitError + + def register_special_commands(self): + + self.pgspecial.register( + self.change_db, + "\\c", + "\\c[onnect] database_name", + "Change to a new database.", + aliases=("use", "\\connect", "USE"), + ) + + refresh_callback = lambda: self.refresh_completions(persist_priorities="all") + + self.pgspecial.register( + self.quit, + "\\q", + "\\q", + "Quit pgcli.", + arg_type=NO_QUERY, + case_sensitive=True, + aliases=(":q",), + ) + self.pgspecial.register( + self.quit, + "quit", + "quit", + "Quit pgcli.", + arg_type=NO_QUERY, + case_sensitive=False, + aliases=("exit",), + ) + self.pgspecial.register( + refresh_callback, + "\\#", + "\\#", + "Refresh auto-completions.", + arg_type=NO_QUERY, + ) + self.pgspecial.register( + refresh_callback, + "\\refresh", + "\\refresh", + "Refresh auto-completions.", + arg_type=NO_QUERY, + ) + self.pgspecial.register( + self.execute_from_file, "\\i", "\\i filename", "Execute commands from file." + ) + self.pgspecial.register( + self.write_to_file, + "\\o", + "\\o [filename]", + "Send all query results to file.", + ) + self.pgspecial.register( + self.info_connection, "\\conninfo", "\\conninfo", "Get connection details" + ) + self.pgspecial.register( + self.change_table_format, + "\\T", + "\\T [format]", + "Change the table format used to output results", + ) + + def change_table_format(self, pattern, **_): + try: + if pattern not in TabularOutputFormatter().supported_formats: + raise ValueError() + self.table_format = pattern + yield (None, None, None, "Changed table format to {}".format(pattern)) + except ValueError: + msg = "Table format {} not recognized. Allowed formats:".format(pattern) + for table_type in TabularOutputFormatter().supported_formats: + msg += "\n\t{}".format(table_type) + msg += "\nCurrently set to: %s" % self.table_format + yield (None, None, None, msg) + + def info_connection(self, **_): + if self.pgexecute.host.startswith("/"): + host = 'socket "%s"' % self.pgexecute.host + else: + host = 'host "%s"' % self.pgexecute.host + + yield ( + None, + None, + None, + 'You are connected to database "%s" as user ' + '"%s" on %s at port "%s".' + % (self.pgexecute.dbname, self.pgexecute.user, host, self.pgexecute.port), + ) + + def change_db(self, pattern, **_): + if pattern: + # Get all the parameters in pattern, handling double quotes if any. + infos = re.findall(r'"[^"]*"|[^"\'\s]+', pattern) + # Now removing quotes. + list(map(lambda s: s.strip('"'), infos)) + + infos.extend([None] * (4 - len(infos))) + db, user, host, port = infos + try: + self.pgexecute.connect( + database=db, + user=user, + host=host, + port=port, + **self.pgexecute.extra_args, + ) + except OperationalError as e: + click.secho(str(e), err=True, fg="red") + click.echo("Previous connection kept") + else: + self.pgexecute.connect() + + yield ( + None, + None, + None, + 'You are now connected to database "%s" as ' + 'user "%s"' % (self.pgexecute.dbname, self.pgexecute.user), + ) + + def execute_from_file(self, pattern, **_): + if not pattern: + message = "\\i: missing required argument" + return [(None, None, None, message, "", False, True)] + try: + with open(os.path.expanduser(pattern), encoding="utf-8") as f: + query = f.read() + except IOError as e: + return [(None, None, None, str(e), "", False, True)] + + if self.destructive_warning and confirm_destructive_query(query) is False: + message = "Wise choice. Command execution stopped." + return [(None, None, None, message)] + + on_error_resume = self.on_error == "RESUME" + return self.pgexecute.run( + query, self.pgspecial, on_error_resume=on_error_resume + ) + + def write_to_file(self, pattern, **_): + if not pattern: + self.output_file = None + message = "File output disabled" + return [(None, None, None, message, "", True, True)] + filename = os.path.abspath(os.path.expanduser(pattern)) + if not os.path.isfile(filename): + try: + open(filename, "w").close() + except IOError as e: + self.output_file = None + message = str(e) + "\nFile output disabled" + return [(None, None, None, message, "", False, True)] + self.output_file = filename + message = 'Writing to file "%s"' % self.output_file + return [(None, None, None, message, "", True, True)] + + def initialize_logging(self): + + log_file = self.config["main"]["log_file"] + if log_file == "default": + log_file = config_location() + "log" + ensure_dir_exists(log_file) + log_level = self.config["main"]["log_level"] + + # Disable logging if value is NONE by switching to a no-op handler. + # Set log level to a high value so it doesn't even waste cycles getting called. + if log_level.upper() == "NONE": + handler = logging.NullHandler() + else: + handler = logging.FileHandler(os.path.expanduser(log_file)) + + level_map = { + "CRITICAL": logging.CRITICAL, + "ERROR": logging.ERROR, + "WARNING": logging.WARNING, + "INFO": logging.INFO, + "DEBUG": logging.DEBUG, + "NONE": logging.CRITICAL, + } + + log_level = level_map[log_level.upper()] + + formatter = logging.Formatter( + "%(asctime)s (%(process)d/%(threadName)s) " + "%(name)s %(levelname)s - %(message)s" + ) + + handler.setFormatter(formatter) + + root_logger = logging.getLogger("pgcli") + root_logger.addHandler(handler) + root_logger.setLevel(log_level) + + root_logger.debug("Initializing pgcli logging.") + root_logger.debug("Log file %r.", log_file) + + pgspecial_logger = logging.getLogger("pgspecial") + pgspecial_logger.addHandler(handler) + pgspecial_logger.setLevel(log_level) + + def initialize_keyring(self): + global keyring + + keyring_enabled = self.config["main"].as_bool("keyring") + if keyring_enabled: + # Try best to load keyring (issue #1041). + import importlib + + try: + keyring = importlib.import_module("keyring") + except Exception as e: # ImportError for Python 2, ModuleNotFoundError for Python 3 + self.logger.warning("import keyring failed: %r.", e) + + def connect_dsn(self, dsn, **kwargs): + self.connect(dsn=dsn, **kwargs) + + def connect_service(self, service, user): + service_config, file = parse_service_info(service) + if service_config is None: + click.secho( + "service '%s' was not found in %s" % (service, file), err=True, fg="red" + ) + exit(1) + self.connect( + database=service_config.get("dbname"), + host=service_config.get("host"), + user=user or service_config.get("user"), + port=service_config.get("port"), + passwd=service_config.get("password"), + ) + + def connect_uri(self, uri): + kwargs = psycopg2.extensions.parse_dsn(uri) + remap = {"dbname": "database", "password": "passwd"} + kwargs = {remap.get(k, k): v for k, v in kwargs.items()} + self.connect(**kwargs) + + def connect( + self, database="", host="", user="", port="", passwd="", dsn="", **kwargs + ): + # Connect to the database. + + if not user: + user = getuser() + + if not database: + database = user + + kwargs.setdefault("application_name", "pgcli") + + # If password prompt is not forced but no password is provided, try + # getting it from environment variable. + if not self.force_passwd_prompt and not passwd: + passwd = os.environ.get("PGPASSWORD", "") + + # Find password from store + key = "%s@%s" % (user, host) + keyring_error_message = dedent( + """\ + {} + {} + To remove this message do one of the following: + - prepare keyring as described at: https://keyring.readthedocs.io/en/stable/ + - uninstall keyring: pip uninstall keyring + - disable keyring in our configuration: add keyring = False to [main]""" + ) + if not passwd and keyring: + + try: + passwd = keyring.get_password("pgcli", key) + except (RuntimeError, keyring.errors.InitError) as e: + click.secho( + keyring_error_message.format( + "Load your password from keyring returned:", str(e) + ), + err=True, + fg="red", + ) + + # Prompt for a password immediately if requested via the -W flag. This + # avoids wasting time trying to connect to the database and catching a + # no-password exception. + # If we successfully parsed a password from a URI, there's no need to + # prompt for it, even with the -W flag + if self.force_passwd_prompt and not passwd: + passwd = click.prompt( + "Password for %s" % user, hide_input=True, show_default=False, type=str + ) + + def should_ask_for_password(exc): + # Prompt for a password after 1st attempt to connect + # fails. Don't prompt if the -w flag is supplied + if self.never_passwd_prompt: + return False + error_msg = exc.args[0] + if "no password supplied" in error_msg: + return True + if "password authentication failed" in error_msg: + return True + return False + + # Attempt to connect to the database. + # Note that passwd may be empty on the first attempt. If connection + # fails because of a missing or incorrect password, but we're allowed to + # prompt for a password (no -w flag), prompt for a passwd and try again. + try: + try: + pgexecute = PGExecute(database, user, passwd, host, port, dsn, **kwargs) + except (OperationalError, InterfaceError) as e: + if should_ask_for_password(e): + passwd = click.prompt( + "Password for %s" % user, + hide_input=True, + show_default=False, + type=str, + ) + pgexecute = PGExecute( + database, user, passwd, host, port, dsn, **kwargs + ) + else: + raise e + if passwd and keyring: + try: + keyring.set_password("pgcli", key, passwd) + except (RuntimeError, keyring.errors.KeyringError) as e: + click.secho( + keyring_error_message.format( + "Set password in keyring returned:", str(e) + ), + err=True, + fg="red", + ) + + except Exception as e: # Connecting to a database could fail. + self.logger.debug("Database connection failed: %r.", e) + self.logger.error("traceback: %r", traceback.format_exc()) + click.secho(str(e), err=True, fg="red") + exit(1) + + self.pgexecute = pgexecute + + def handle_editor_command(self, text): + r""" + Editor command is any query that is prefixed or suffixed + by a '\e'. The reason for a while loop is because a user + might edit a query multiple times. + For eg: + "select * from \e" to edit it in vim, then come + back to the prompt with the edited query "select * from + blah where q = 'abc'\e" to edit it again. + :param text: Document + :return: Document + """ + editor_command = special.editor_command(text) + while editor_command: + if editor_command == "\\e": + filename = special.get_filename(text) + query = special.get_editor_query(text) or self.get_last_query() + else: # \ev or \ef + filename = None + spec = text.split()[1] + if editor_command == "\\ev": + query = self.pgexecute.view_definition(spec) + elif editor_command == "\\ef": + query = self.pgexecute.function_definition(spec) + sql, message = special.open_external_editor(filename, sql=query) + if message: + # Something went wrong. Raise an exception and bail. + raise RuntimeError(message) + while True: + try: + text = self.prompt_app.prompt(default=sql) + break + except KeyboardInterrupt: + sql = "" + + editor_command = special.editor_command(text) + return text + + def execute_command(self, text): + logger = self.logger + + query = MetaQuery(query=text, successful=False) + + try: + if self.destructive_warning: + destroy = confirm = confirm_destructive_query(text) + if destroy is False: + click.secho("Wise choice!") + raise KeyboardInterrupt + elif destroy: + click.secho("Your call!") + output, query = self._evaluate_command(text) + except KeyboardInterrupt: + # Restart connection to the database + self.pgexecute.connect() + logger.debug("cancelled query, sql: %r", text) + click.secho("cancelled query", err=True, fg="red") + except NotImplementedError: + click.secho("Not Yet Implemented.", fg="yellow") + except OperationalError as e: + logger.error("sql: %r, error: %r", text, e) + logger.error("traceback: %r", traceback.format_exc()) + self._handle_server_closed_connection(text) + except (PgCliQuitError, EOFError) as e: + raise + except Exception as e: + logger.error("sql: %r, error: %r", text, e) + logger.error("traceback: %r", traceback.format_exc()) + click.secho(str(e), err=True, fg="red") + else: + try: + if self.output_file and not text.startswith(("\\o ", "\\? ")): + try: + with open(self.output_file, "a", encoding="utf-8") as f: + click.echo(text, file=f) + click.echo("\n".join(output), file=f) + click.echo("", file=f) # extra newline + except IOError as e: + click.secho(str(e), err=True, fg="red") + else: + if output: + self.echo_via_pager("\n".join(output)) + except KeyboardInterrupt: + pass + + if self.pgspecial.timing_enabled: + # Only add humanized time display if > 1 second + if query.total_time > 1: + print( + "Time: %0.03fs (%s), executed in: %0.03fs (%s)" + % ( + query.total_time, + pendulum.Duration(seconds=query.total_time).in_words(), + query.execution_time, + pendulum.Duration(seconds=query.execution_time).in_words(), + ) + ) + else: + print("Time: %0.03fs" % query.total_time) + + # Check if we need to update completions, in order of most + # to least drastic changes + if query.db_changed: + with self._completer_lock: + self.completer.reset_completions() + self.refresh_completions(persist_priorities="keywords") + elif query.meta_changed: + self.refresh_completions(persist_priorities="all") + elif query.path_changed: + logger.debug("Refreshing search path") + with self._completer_lock: + self.completer.set_search_path(self.pgexecute.search_path()) + logger.debug("Search path: %r", self.completer.search_path) + return query + + def run_cli(self): + logger = self.logger + + history_file = self.config["main"]["history_file"] + if history_file == "default": + history_file = config_location() + "history" + history = FileHistory(os.path.expanduser(history_file)) + self.refresh_completions(history=history, persist_priorities="none") + + self.prompt_app = self._build_cli(history) + + if not self.less_chatty: + print("Server: PostgreSQL", self.pgexecute.server_version) + print("Version:", __version__) + print("Chat: https://gitter.im/dbcli/pgcli") + print("Home: http://pgcli.com") + + try: + while True: + try: + text = self.prompt_app.prompt() + except KeyboardInterrupt: + continue + + try: + text = self.handle_editor_command(text) + except RuntimeError as e: + logger.error("sql: %r, error: %r", text, e) + logger.error("traceback: %r", traceback.format_exc()) + click.secho(str(e), err=True, fg="red") + continue + + # Initialize default metaquery in case execution fails + self.watch_command, timing = special.get_watch_command(text) + if self.watch_command: + while self.watch_command: + try: + query = self.execute_command(self.watch_command) + click.echo( + "Waiting for {0} seconds before repeating".format( + timing + ) + ) + sleep(timing) + except KeyboardInterrupt: + self.watch_command = None + else: + query = self.execute_command(text) + + self.now = dt.datetime.today() + + # Allow PGCompleter to learn user's preferred keywords, etc. + with self._completer_lock: + self.completer.extend_query_history(text) + + self.query_history.append(query) + + except (PgCliQuitError, EOFError): + if not self.less_chatty: + print("Goodbye!") + + def _build_cli(self, history): + key_bindings = pgcli_bindings(self) + + def get_message(): + if self.dsn_alias and self.prompt_dsn_format is not None: + prompt_format = self.prompt_dsn_format + else: + prompt_format = self.prompt_format + + prompt = self.get_prompt(prompt_format) + + if ( + prompt_format == self.default_prompt + and len(prompt) > self.max_len_prompt + ): + prompt = self.get_prompt("\\d> ") + + prompt = prompt.replace("\\x1b", "\x1b") + return ANSI(prompt) + + def get_continuation(width, line_number, is_soft_wrap): + continuation = self.multiline_continuation_char * (width - 1) + " " + return [("class:continuation", continuation)] + + get_toolbar_tokens = create_toolbar_tokens_func(self) + + if self.wider_completion_menu: + complete_style = CompleteStyle.MULTI_COLUMN + else: + complete_style = CompleteStyle.COLUMN + + with self._completer_lock: + prompt_app = PromptSession( + lexer=PygmentsLexer(PostgresLexer), + reserve_space_for_menu=self.min_num_menu_lines, + message=get_message, + prompt_continuation=get_continuation, + bottom_toolbar=get_toolbar_tokens if self.show_bottom_toolbar else None, + complete_style=complete_style, + input_processors=[ + # Highlight matching brackets while editing. + ConditionalProcessor( + processor=HighlightMatchingBracketProcessor(chars="[](){}"), + filter=HasFocus(DEFAULT_BUFFER) & ~IsDone(), + ), + # Render \t as 4 spaces instead of "^I" + TabsProcessor(char1=" ", char2=" "), + ], + auto_suggest=AutoSuggestFromHistory(), + tempfile_suffix=".sql", + # N.b. pgcli's multi-line mode controls submit-on-Enter (which + # overrides the default behaviour of prompt_toolkit) and is + # distinct from prompt_toolkit's multiline mode here, which + # controls layout/display of the prompt/buffer + multiline=True, + history=history, + completer=ThreadedCompleter(DynamicCompleter(lambda: self.completer)), + complete_while_typing=True, + style=style_factory(self.syntax_style, self.cli_style), + include_default_pygments_style=False, + key_bindings=key_bindings, + enable_open_in_editor=True, + enable_system_prompt=True, + enable_suspend=True, + editing_mode=EditingMode.VI if self.vi_mode else EditingMode.EMACS, + search_ignore_case=True, + ) + + return prompt_app + + def _should_limit_output(self, sql, cur): + """returns True if the output should be truncated, False otherwise.""" + if not is_select(sql): + return False + + return ( + not self._has_limit(sql) + and self.row_limit != 0 + and cur + and cur.rowcount > self.row_limit + ) + + def _has_limit(self, sql): + if not sql: + return False + return "limit " in sql.lower() + + def _limit_output(self, cur): + limit = min(self.row_limit, cur.rowcount) + new_cur = itertools.islice(cur, limit) + new_status = "SELECT " + str(limit) + click.secho("The result was limited to %s rows" % limit, fg="red") + + return new_cur, new_status + + def _evaluate_command(self, text): + """Used to run a command entered by the user during CLI operation + (Puts the E in REPL) + + returns (results, MetaQuery) + """ + logger = self.logger + logger.debug("sql: %r", text) + + all_success = True + meta_changed = False # CREATE, ALTER, DROP, etc + mutated = False # INSERT, DELETE, etc + db_changed = False + path_changed = False + output = [] + total = 0 + execution = 0 + + # Run the query. + start = time() + on_error_resume = self.on_error == "RESUME" + res = self.pgexecute.run( + text, self.pgspecial, exception_formatter, on_error_resume + ) + + is_special = None + + for title, cur, headers, status, sql, success, is_special in res: + logger.debug("headers: %r", headers) + logger.debug("rows: %r", cur) + logger.debug("status: %r", status) + + if self._should_limit_output(sql, cur): + cur, status = self._limit_output(cur) + + if self.pgspecial.auto_expand or self.auto_expand: + max_width = self.prompt_app.output.get_size().columns + else: + max_width = None + + expanded = self.pgspecial.expanded_output or self.expanded_output + settings = OutputSettings( + table_format=self.table_format, + dcmlfmt=self.decimal_format, + floatfmt=self.float_format, + missingval=self.null_string, + expanded=expanded, + max_width=max_width, + case_function=( + self.completer.case + if self.settings["case_column_headers"] + else lambda x: x + ), + style_output=self.style_output, + ) + execution = time() - start + formatted = format_output(title, cur, headers, status, settings) + + output.extend(formatted) + total = time() - start + + # Keep track of whether any of the queries are mutating or changing + # the database + if success: + mutated = mutated or is_mutating(status) + db_changed = db_changed or has_change_db_cmd(sql) + meta_changed = meta_changed or has_meta_cmd(sql) + path_changed = path_changed or has_change_path_cmd(sql) + else: + all_success = False + + meta_query = MetaQuery( + text, + all_success, + total, + execution, + meta_changed, + db_changed, + path_changed, + mutated, + is_special, + ) + + return output, meta_query + + def _handle_server_closed_connection(self, text): + """Used during CLI execution.""" + try: + click.secho("Reconnecting...", fg="green") + self.pgexecute.connect() + click.secho("Reconnected!", fg="green") + self.execute_command(text) + except OperationalError as e: + click.secho("Reconnect Failed", fg="red") + click.secho(str(e), err=True, fg="red") + + def refresh_completions(self, history=None, persist_priorities="all"): + """Refresh outdated completions + + :param history: A prompt_toolkit.history.FileHistory object. Used to + load keyword and identifier preferences + + :param persist_priorities: 'all' or 'keywords' + """ + + callback = functools.partial( + self._on_completions_refreshed, persist_priorities=persist_priorities + ) + self.completion_refresher.refresh( + self.pgexecute, + self.pgspecial, + callback, + history=history, + settings=self.settings, + ) + return [ + (None, None, None, "Auto-completion refresh started in the background.") + ] + + def _on_completions_refreshed(self, new_completer, persist_priorities): + self._swap_completer_objects(new_completer, persist_priorities) + + if self.prompt_app: + # After refreshing, redraw the CLI to clear the statusbar + # "Refreshing completions..." indicator + self.prompt_app.app.invalidate() + + def _swap_completer_objects(self, new_completer, persist_priorities): + """Swap the completer object with the newly created completer. + + persist_priorities is a string specifying how the old completer's + learned prioritizer should be transferred to the new completer. + + 'none' - The new prioritizer is left in a new/clean state + + 'all' - The new prioritizer is updated to exactly reflect + the old one + + 'keywords' - The new prioritizer is updated with old keyword + priorities, but not any other. + + """ + with self._completer_lock: + old_completer = self.completer + self.completer = new_completer + + if persist_priorities == "all": + # Just swap over the entire prioritizer + new_completer.prioritizer = old_completer.prioritizer + elif persist_priorities == "keywords": + # Swap over the entire prioritizer, but clear name priorities, + # leaving learned keyword priorities alone + new_completer.prioritizer = old_completer.prioritizer + new_completer.prioritizer.clear_names() + elif persist_priorities == "none": + # Leave the new prioritizer as is + pass + self.completer = new_completer + + def get_completions(self, text, cursor_positition): + with self._completer_lock: + return self.completer.get_completions( + Document(text=text, cursor_position=cursor_positition), None + ) + + def get_prompt(self, string): + # should be before replacing \\d + string = string.replace("\\dsn_alias", self.dsn_alias or "") + string = string.replace("\\t", self.now.strftime("%x %X")) + string = string.replace("\\u", self.pgexecute.user or "(none)") + string = string.replace("\\H", self.pgexecute.host or "(none)") + string = string.replace("\\h", self.pgexecute.short_host or "(none)") + string = string.replace("\\d", self.pgexecute.dbname or "(none)") + string = string.replace( + "\\p", + str(self.pgexecute.port) if self.pgexecute.port is not None else "5432", + ) + string = string.replace("\\i", str(self.pgexecute.pid) or "(none)") + string = string.replace("\\#", "#" if (self.pgexecute.superuser) else ">") + string = string.replace("\\n", "\n") + return string + + def get_last_query(self): + """Get the last query executed or None.""" + return self.query_history[-1][0] if self.query_history else None + + def is_too_wide(self, line): + """Will this line be too wide to fit into terminal?""" + if not self.prompt_app: + return False + return ( + len(COLOR_CODE_REGEX.sub("", line)) + > self.prompt_app.output.get_size().columns + ) + + def is_too_tall(self, lines): + """Are there too many lines to fit into terminal?""" + if not self.prompt_app: + return False + return len(lines) >= (self.prompt_app.output.get_size().rows - 4) + + def echo_via_pager(self, text, color=None): + if self.pgspecial.pager_config == PAGER_OFF or self.watch_command: + click.echo(text, color=color) + elif "pspg" in os.environ.get("PAGER", "") and self.table_format == "csv": + click.echo_via_pager(text, color) + elif self.pgspecial.pager_config == PAGER_LONG_OUTPUT: + lines = text.split("\n") + + # The last 4 lines are reserved for the pgcli menu and padding + if self.is_too_tall(lines) or any(self.is_too_wide(l) for l in lines): + click.echo_via_pager(text, color=color) + else: + click.echo(text, color=color) + else: + click.echo_via_pager(text, color) + + +@click.command() +# Default host is '' so psycopg2 can default to either localhost or unix socket +@click.option( + "-h", + "--host", + default="", + envvar="PGHOST", + help="Host address of the postgres database.", +) +@click.option( + "-p", + "--port", + default=5432, + help="Port number at which the " "postgres instance is listening.", + envvar="PGPORT", + type=click.INT, +) +@click.option( + "-U", + "--username", + "username_opt", + help="Username to connect to the postgres database.", +) +@click.option( + "-u", "--user", "username_opt", help="Username to connect to the postgres database." +) +@click.option( + "-W", + "--password", + "prompt_passwd", + is_flag=True, + default=False, + help="Force password prompt.", +) +@click.option( + "-w", + "--no-password", + "never_prompt", + is_flag=True, + default=False, + help="Never prompt for password.", +) +@click.option( + "--single-connection", + "single_connection", + is_flag=True, + default=False, + help="Do not use a separate connection for completions.", +) +@click.option("-v", "--version", is_flag=True, help="Version of pgcli.") +@click.option("-d", "--dbname", "dbname_opt", help="database name to connect to.") +@click.option( + "--pgclirc", + default=config_location() + "config", + envvar="PGCLIRC", + help="Location of pgclirc file.", + type=click.Path(dir_okay=False), +) +@click.option( + "-D", + "--dsn", + default="", + envvar="DSN", + help="Use DSN configured into the [alias_dsn] section of pgclirc file.", +) +@click.option( + "--list-dsn", + "list_dsn", + is_flag=True, + help="list of DSN configured into the [alias_dsn] section of pgclirc file.", +) +@click.option( + "--row-limit", + default=None, + envvar="PGROWLIMIT", + type=click.INT, + help="Set threshold for row limit prompt. Use 0 to disable prompt.", +) +@click.option( + "--less-chatty", + "less_chatty", + is_flag=True, + default=False, + help="Skip intro on startup and goodbye on exit.", +) +@click.option("--prompt", help='Prompt format (Default: "\\u@\\h:\\d> ").') +@click.option( + "--prompt-dsn", + help='Prompt format for connections using DSN aliases (Default: "\\u@\\h:\\d> ").', +) +@click.option( + "-l", + "--list", + "list_databases", + is_flag=True, + help="list " "available databases, then exit.", +) +@click.option( + "--auto-vertical-output", + is_flag=True, + help="Automatically switch to vertical output mode if the result is wider than the terminal width.", +) +@click.option( + "--warn/--no-warn", default=None, help="Warn before running a destructive query." +) +@click.argument("dbname", default=lambda: None, envvar="PGDATABASE", nargs=1) +@click.argument("username", default=lambda: None, envvar="PGUSER", nargs=1) +def cli( + dbname, + username_opt, + host, + port, + prompt_passwd, + never_prompt, + single_connection, + dbname_opt, + username, + version, + pgclirc, + dsn, + row_limit, + less_chatty, + prompt, + prompt_dsn, + list_databases, + auto_vertical_output, + list_dsn, + warn, +): + if version: + print("Version:", __version__) + sys.exit(0) + + config_dir = os.path.dirname(config_location()) + if not os.path.exists(config_dir): + os.makedirs(config_dir) + + # Migrate the config file from old location. + config_full_path = config_location() + "config" + if os.path.exists(os.path.expanduser("~/.pgclirc")): + if not os.path.exists(config_full_path): + shutil.move(os.path.expanduser("~/.pgclirc"), config_full_path) + print("Config file (~/.pgclirc) moved to new location", config_full_path) + else: + print("Config file is now located at", config_full_path) + print( + "Please move the existing config file ~/.pgclirc to", + config_full_path, + ) + if list_dsn: + try: + cfg = load_config(pgclirc, config_full_path) + for alias in cfg["alias_dsn"]: + click.secho(alias + " : " + cfg["alias_dsn"][alias]) + sys.exit(0) + except Exception as err: + click.secho( + "Invalid DSNs found in the config file. " + 'Please check the "[alias_dsn]" section in pgclirc.', + err=True, + fg="red", + ) + exit(1) + + pgcli = PGCli( + prompt_passwd, + never_prompt, + pgclirc_file=pgclirc, + row_limit=row_limit, + single_connection=single_connection, + less_chatty=less_chatty, + prompt=prompt, + prompt_dsn=prompt_dsn, + auto_vertical_output=auto_vertical_output, + warn=warn, + ) + + # Choose which ever one has a valid value. + if dbname_opt and dbname: + # work as psql: when database is given as option and argument use the argument as user + username = dbname + database = dbname_opt or dbname or "" + user = username_opt or username + service = None + if database.startswith("service="): + service = database[8:] + elif os.getenv("PGSERVICE") is not None: + service = os.getenv("PGSERVICE") + # because option --list or -l are not supposed to have a db name + if list_databases: + database = "postgres" + + if dsn != "": + try: + cfg = load_config(pgclirc, config_full_path) + dsn_config = cfg["alias_dsn"][dsn] + except KeyError: + click.secho( + f"Could not find a DSN with alias {dsn}. " + 'Please check the "[alias_dsn]" section in pgclirc.', + err=True, + fg="red", + ) + exit(1) + except Exception: + click.secho( + "Invalid DSNs found in the config file. " + 'Please check the "[alias_dsn]" section in pgclirc.', + err=True, + fg="red", + ) + exit(1) + pgcli.connect_uri(dsn_config) + pgcli.dsn_alias = dsn + elif "://" in database: + pgcli.connect_uri(database) + elif "=" in database and service is None: + pgcli.connect_dsn(database, user=user) + elif service is not None: + pgcli.connect_service(service, user) + else: + pgcli.connect(database, host, user, port) + + if list_databases: + cur, headers, status = pgcli.pgexecute.full_databases() + + title = "List of databases" + settings = OutputSettings(table_format="ascii", missingval="") + formatted = format_output(title, cur, headers, status, settings) + pgcli.echo_via_pager("\n".join(formatted)) + + sys.exit(0) + + pgcli.logger.debug( + "Launch Params: \n" "\tdatabase: %r" "\tuser: %r" "\thost: %r" "\tport: %r", + database, + user, + host, + port, + ) + + if setproctitle: + obfuscate_process_password() + + pgcli.run_cli() + + +def obfuscate_process_password(): + process_title = setproctitle.getproctitle() + if "://" in process_title: + process_title = re.sub(r":(.*):(.*)@", r":\1:xxxx@", process_title) + elif "=" in process_title: + process_title = re.sub( + r"password=(.+?)((\s[a-zA-Z]+=)|$)", r"password=xxxx\2", process_title + ) + + setproctitle.setproctitle(process_title) + + +def has_meta_cmd(query): + """Determines if the completion needs a refresh by checking if the sql + statement is an alter, create, drop, commit or rollback.""" + try: + first_token = query.split()[0] + if first_token.lower() in ("alter", "create", "drop", "commit", "rollback"): + return True + except Exception: + return False + + return False + + +def has_change_db_cmd(query): + """Determines if the statement is a database switch such as 'use' or '\\c'""" + try: + first_token = query.split()[0] + if first_token.lower() in ("use", "\\c", "\\connect"): + return True + except Exception: + return False + + return False + + +def has_change_path_cmd(sql): + """Determines if the search_path should be refreshed by checking if the + sql has 'set search_path'.""" + return "set search_path" in sql.lower() + + +def is_mutating(status): + """Determines if the statement is mutating based on the status.""" + if not status: + return False + + mutating = set(["insert", "update", "delete"]) + return status.split(None, 1)[0].lower() in mutating + + +def is_select(status): + """Returns true if the first word in status is 'select'.""" + if not status: + return False + return status.split(None, 1)[0].lower() == "select" + + +def exception_formatter(e): + return click.style(str(e), fg="red") + + +def format_output(title, cur, headers, status, settings): + output = [] + expanded = settings.expanded or settings.table_format == "vertical" + table_format = "vertical" if settings.expanded else settings.table_format + max_width = settings.max_width + case_function = settings.case_function + formatter = TabularOutputFormatter(format_name=table_format) + + def format_array(val): + if val is None: + return settings.missingval + if not isinstance(val, list): + return val + return "{" + ",".join(str(format_array(e)) for e in val) + "}" + + def format_arrays(data, headers, **_): + data = list(data) + for row in data: + row[:] = [ + format_array(val) if isinstance(val, list) else val for val in row + ] + + return data, headers + + output_kwargs = { + "sep_title": "RECORD {n}", + "sep_character": "-", + "sep_length": (1, 25), + "missing_value": settings.missingval, + "integer_format": settings.dcmlfmt, + "float_format": settings.floatfmt, + "preprocessors": (format_numbers, format_arrays), + "disable_numparse": True, + "preserve_whitespace": True, + "style": settings.style_output, + } + if not settings.floatfmt: + output_kwargs["preprocessors"] = (align_decimals,) + + if table_format == "csv": + # The default CSV dialect is "excel" which is not handling newline values correctly + # Nevertheless, we want to keep on using "excel" on Windows since it uses '\r\n' + # as the line terminator + # https://github.com/dbcli/pgcli/issues/1102 + dialect = "excel" if platform.system() == "Windows" else "unix" + output_kwargs["dialect"] = dialect + + if title: # Only print the title if it's not None. + output.append(title) + + if cur: + headers = [case_function(x) for x in headers] + if max_width is not None: + cur = list(cur) + column_types = None + if hasattr(cur, "description"): + column_types = [] + for d in cur.description: + if ( + d[1] in psycopg2.extensions.DECIMAL.values + or d[1] in psycopg2.extensions.FLOAT.values + ): + column_types.append(float) + if ( + d[1] == psycopg2.extensions.INTEGER.values + or d[1] in psycopg2.extensions.LONGINTEGER.values + ): + column_types.append(int) + else: + column_types.append(str) + + formatted = formatter.format_output(cur, headers, **output_kwargs) + if isinstance(formatted, str): + formatted = iter(formatted.splitlines()) + first_line = next(formatted) + formatted = itertools.chain([first_line], formatted) + if not expanded and max_width and len(first_line) > max_width and headers: + formatted = formatter.format_output( + cur, headers, format_name="vertical", column_types=None, **output_kwargs + ) + if isinstance(formatted, str): + formatted = iter(formatted.splitlines()) + + output = itertools.chain(output, formatted) + + # Only print the status if it's not None and we are not producing CSV + if status and table_format != "csv": + output = itertools.chain(output, [status]) + + return output + + +def parse_service_info(service): + service = service or os.getenv("PGSERVICE") + service_file = os.getenv("PGSERVICEFILE") + if not service_file: + # try ~/.pg_service.conf (if that exists) + if platform.system() == "Windows": + service_file = os.getenv("PGSYSCONFDIR") + "\\pg_service.conf" + elif os.getenv("PGSYSCONFDIR"): + service_file = os.path.join(os.getenv("PGSYSCONFDIR"), ".pg_service.conf") + else: + service_file = expanduser("~/.pg_service.conf") + if not service: + # nothing to do + return None, service_file + service_file_config = ConfigObj(service_file) + if service not in service_file_config: + return None, service_file + service_conf = service_file_config.get(service) + return service_conf, service_file + + +if __name__ == "__main__": + cli() diff --git a/pgcli/packages/__init__.py b/pgcli/packages/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/pgcli/packages/parseutils/__init__.py b/pgcli/packages/parseutils/__init__.py new file mode 100644 index 0000000..a11e7bf --- /dev/null +++ b/pgcli/packages/parseutils/__init__.py @@ -0,0 +1,22 @@ +import sqlparse + + +def query_starts_with(query, prefixes): + """Check if the query starts with any item from *prefixes*.""" + prefixes = [prefix.lower() for prefix in prefixes] + formatted_sql = sqlparse.format(query.lower(), strip_comments=True).strip() + return bool(formatted_sql) and formatted_sql.split()[0] in prefixes + + +def queries_start_with(queries, prefixes): + """Check if any queries start with any item from *prefixes*.""" + for query in sqlparse.split(queries): + if query and query_starts_with(query, prefixes) is True: + return True + return False + + +def is_destructive(queries): + """Returns if any of the queries in *queries* is destructive.""" + keywords = ("drop", "shutdown", "delete", "truncate", "alter") + return queries_start_with(queries, keywords) diff --git a/pgcli/packages/parseutils/ctes.py b/pgcli/packages/parseutils/ctes.py new file mode 100644 index 0000000..e1f9088 --- /dev/null +++ b/pgcli/packages/parseutils/ctes.py @@ -0,0 +1,141 @@ +from sqlparse import parse +from sqlparse.tokens import Keyword, CTE, DML +from sqlparse.sql import Identifier, IdentifierList, Parenthesis +from collections import namedtuple +from .meta import TableMetadata, ColumnMetadata + + +# TableExpression is a namedtuple representing a CTE, used internally +# name: cte alias assigned in the query +# columns: list of column names +# start: index into the original string of the left parens starting the CTE +# stop: index into the original string of the right parens ending the CTE +TableExpression = namedtuple("TableExpression", "name columns start stop") + + +def isolate_query_ctes(full_text, text_before_cursor): + """Simplify a query by converting CTEs into table metadata objects""" + + if not full_text or not full_text.strip(): + return full_text, text_before_cursor, tuple() + + ctes, remainder = extract_ctes(full_text) + if not ctes: + return full_text, text_before_cursor, () + + current_position = len(text_before_cursor) + meta = [] + + for cte in ctes: + if cte.start < current_position < cte.stop: + # Currently editing a cte - treat its body as the current full_text + text_before_cursor = full_text[cte.start : current_position] + full_text = full_text[cte.start : cte.stop] + return full_text, text_before_cursor, meta + + # Append this cte to the list of available table metadata + cols = (ColumnMetadata(name, None, ()) for name in cte.columns) + meta.append(TableMetadata(cte.name, cols)) + + # Editing past the last cte (ie the main body of the query) + full_text = full_text[ctes[-1].stop :] + text_before_cursor = text_before_cursor[ctes[-1].stop : current_position] + + return full_text, text_before_cursor, tuple(meta) + + +def extract_ctes(sql): + """Extract constant table expresseions from a query + + Returns tuple (ctes, remainder_sql) + + ctes is a list of TableExpression namedtuples + remainder_sql is the text from the original query after the CTEs have + been stripped. + """ + + p = parse(sql)[0] + + # Make sure the first meaningful token is "WITH" which is necessary to + # define CTEs + idx, tok = p.token_next(-1, skip_ws=True, skip_cm=True) + if not (tok and tok.ttype == CTE): + return [], sql + + # Get the next (meaningful) token, which should be the first CTE + idx, tok = p.token_next(idx) + if not tok: + return ([], "") + start_pos = token_start_pos(p.tokens, idx) + ctes = [] + + if isinstance(tok, IdentifierList): + # Multiple ctes + for t in tok.get_identifiers(): + cte_start_offset = token_start_pos(tok.tokens, tok.token_index(t)) + cte = get_cte_from_token(t, start_pos + cte_start_offset) + if not cte: + continue + ctes.append(cte) + elif isinstance(tok, Identifier): + # A single CTE + cte = get_cte_from_token(tok, start_pos) + if cte: + ctes.append(cte) + + idx = p.token_index(tok) + 1 + + # Collapse everything after the ctes into a remainder query + remainder = "".join(str(tok) for tok in p.tokens[idx:]) + + return ctes, remainder + + +def get_cte_from_token(tok, pos0): + cte_name = tok.get_real_name() + if not cte_name: + return None + + # Find the start position of the opening parens enclosing the cte body + idx, parens = tok.token_next_by(Parenthesis) + if not parens: + return None + + start_pos = pos0 + token_start_pos(tok.tokens, idx) + cte_len = len(str(parens)) # includes parens + stop_pos = start_pos + cte_len + + column_names = extract_column_names(parens) + + return TableExpression(cte_name, column_names, start_pos, stop_pos) + + +def extract_column_names(parsed): + # Find the first DML token to check if it's a SELECT or INSERT/UPDATE/DELETE + idx, tok = parsed.token_next_by(t=DML) + tok_val = tok and tok.value.lower() + + if tok_val in ("insert", "update", "delete"): + # Jump ahead to the RETURNING clause where the list of column names is + idx, tok = parsed.token_next_by(idx, (Keyword, "returning")) + elif not tok_val == "select": + # Must be invalid CTE + return () + + # The next token should be either a column name, or a list of column names + idx, tok = parsed.token_next(idx, skip_ws=True, skip_cm=True) + return tuple(t.get_name() for t in _identifiers(tok)) + + +def token_start_pos(tokens, idx): + return sum(len(str(t)) for t in tokens[:idx]) + + +def _identifiers(tok): + if isinstance(tok, IdentifierList): + for t in tok.get_identifiers(): + # NB: IdentifierList.get_identifiers() can return non-identifiers! + if isinstance(t, Identifier): + yield t + elif isinstance(tok, Identifier): + yield tok diff --git a/pgcli/packages/parseutils/meta.py b/pgcli/packages/parseutils/meta.py new file mode 100644 index 0000000..108c01a --- /dev/null +++ b/pgcli/packages/parseutils/meta.py @@ -0,0 +1,170 @@ +from collections import namedtuple + +_ColumnMetadata = namedtuple( + "ColumnMetadata", ["name", "datatype", "foreignkeys", "default", "has_default"] +) + + +def ColumnMetadata(name, datatype, foreignkeys=None, default=None, has_default=False): + return _ColumnMetadata(name, datatype, foreignkeys or [], default, has_default) + + +ForeignKey = namedtuple( + "ForeignKey", + [ + "parentschema", + "parenttable", + "parentcolumn", + "childschema", + "childtable", + "childcolumn", + ], +) +TableMetadata = namedtuple("TableMetadata", "name columns") + + +def parse_defaults(defaults_string): + """Yields default values for a function, given the string provided by + pg_get_expr(pg_catalog.pg_proc.proargdefaults, 0)""" + if not defaults_string: + return + current = "" + in_quote = None + for char in defaults_string: + if current == "" and char == " ": + # Skip space after comma separating default expressions + continue + if char == '"' or char == "'": + if in_quote and char == in_quote: + # End quote + in_quote = None + elif not in_quote: + # Begin quote + in_quote = char + elif char == "," and not in_quote: + # End of expression + yield current + current = "" + continue + current += char + yield current + + +class FunctionMetadata(object): + def __init__( + self, + schema_name, + func_name, + arg_names, + arg_types, + arg_modes, + return_type, + is_aggregate, + is_window, + is_set_returning, + is_extension, + arg_defaults, + ): + """Class for describing a postgresql function""" + + self.schema_name = schema_name + self.func_name = func_name + + self.arg_modes = tuple(arg_modes) if arg_modes else None + self.arg_names = tuple(arg_names) if arg_names else None + + # Be flexible in not requiring arg_types -- use None as a placeholder + # for each arg. (Used for compatibility with old versions of postgresql + # where such info is hard to get. + if arg_types: + self.arg_types = tuple(arg_types) + elif arg_modes: + self.arg_types = tuple([None] * len(arg_modes)) + elif arg_names: + self.arg_types = tuple([None] * len(arg_names)) + else: + self.arg_types = None + + self.arg_defaults = tuple(parse_defaults(arg_defaults)) + + self.return_type = return_type.strip() + self.is_aggregate = is_aggregate + self.is_window = is_window + self.is_set_returning = is_set_returning + self.is_extension = bool(is_extension) + self.is_public = self.schema_name and self.schema_name == "public" + + def __eq__(self, other): + return isinstance(other, self.__class__) and self.__dict__ == other.__dict__ + + def __ne__(self, other): + return not self.__eq__(other) + + def _signature(self): + return ( + self.schema_name, + self.func_name, + self.arg_names, + self.arg_types, + self.arg_modes, + self.return_type, + self.is_aggregate, + self.is_window, + self.is_set_returning, + self.is_extension, + self.arg_defaults, + ) + + def __hash__(self): + return hash(self._signature()) + + def __repr__(self): + return ( + "%s(schema_name=%r, func_name=%r, arg_names=%r, " + "arg_types=%r, arg_modes=%r, return_type=%r, is_aggregate=%r, " + "is_window=%r, is_set_returning=%r, is_extension=%r, arg_defaults=%r)" + ) % ((self.__class__.__name__,) + self._signature()) + + def has_variadic(self): + return self.arg_modes and any(arg_mode == "v" for arg_mode in self.arg_modes) + + def args(self): + """Returns a list of input-parameter ColumnMetadata namedtuples.""" + if not self.arg_names: + return [] + modes = self.arg_modes or ["i"] * len(self.arg_names) + args = [ + (name, typ) + for name, typ, mode in zip(self.arg_names, self.arg_types, modes) + if mode in ("i", "b", "v") # IN, INOUT, VARIADIC + ] + + def arg(name, typ, num): + num_args = len(args) + num_defaults = len(self.arg_defaults) + has_default = num + num_defaults >= num_args + default = ( + self.arg_defaults[num - num_args + num_defaults] + if has_default + else None + ) + return ColumnMetadata(name, typ, [], default, has_default) + + return [arg(name, typ, num) for num, (name, typ) in enumerate(args)] + + def fields(self): + """Returns a list of output-field ColumnMetadata namedtuples""" + + if self.return_type.lower() == "void": + return [] + elif not self.arg_modes: + # For functions without output parameters, the function name + # is used as the name of the output column. + # E.g. 'SELECT unnest FROM unnest(...);' + return [ColumnMetadata(self.func_name, self.return_type, [])] + + return [ + ColumnMetadata(name, typ, []) + for name, typ, mode in zip(self.arg_names, self.arg_types, self.arg_modes) + if mode in ("o", "b", "t") + ] # OUT, INOUT, TABLE diff --git a/pgcli/packages/parseutils/tables.py b/pgcli/packages/parseutils/tables.py new file mode 100644 index 0000000..0ec3e69 --- /dev/null +++ b/pgcli/packages/parseutils/tables.py @@ -0,0 +1,170 @@ +import sqlparse +from collections import namedtuple +from sqlparse.sql import IdentifierList, Identifier, Function +from sqlparse.tokens import Keyword, DML, Punctuation + +TableReference = namedtuple( + "TableReference", ["schema", "name", "alias", "is_function"] +) +TableReference.ref = property( + lambda self: self.alias + or ( + self.name + if self.name.islower() or self.name[0] == '"' + else '"' + self.name + '"' + ) +) + + +# This code is borrowed from sqlparse example script. +# +def is_subselect(parsed): + if not parsed.is_group: + return False + for item in parsed.tokens: + if item.ttype is DML and item.value.upper() in ( + "SELECT", + "INSERT", + "UPDATE", + "CREATE", + "DELETE", + ): + return True + return False + + +def _identifier_is_function(identifier): + return any(isinstance(t, Function) for t in identifier.tokens) + + +def extract_from_part(parsed, stop_at_punctuation=True): + tbl_prefix_seen = False + for item in parsed.tokens: + if tbl_prefix_seen: + if is_subselect(item): + for x in extract_from_part(item, stop_at_punctuation): + yield x + elif stop_at_punctuation and item.ttype is Punctuation: + return + # An incomplete nested select won't be recognized correctly as a + # sub-select. eg: 'SELECT * FROM (SELECT id FROM user'. This causes + # the second FROM to trigger this elif condition resulting in a + # `return`. So we need to ignore the keyword if the keyword + # FROM. + # Also 'SELECT * FROM abc JOIN def' will trigger this elif + # condition. So we need to ignore the keyword JOIN and its variants + # INNER JOIN, FULL OUTER JOIN, etc. + elif ( + item.ttype is Keyword + and (not item.value.upper() == "FROM") + and (not item.value.upper().endswith("JOIN")) + ): + tbl_prefix_seen = False + else: + yield item + elif item.ttype is Keyword or item.ttype is Keyword.DML: + item_val = item.value.upper() + if ( + item_val + in ( + "COPY", + "FROM", + "INTO", + "UPDATE", + "TABLE", + ) + or item_val.endswith("JOIN") + ): + tbl_prefix_seen = True + # 'SELECT a, FROM abc' will detect FROM as part of the column list. + # So this check here is necessary. + elif isinstance(item, IdentifierList): + for identifier in item.get_identifiers(): + if identifier.ttype is Keyword and identifier.value.upper() == "FROM": + tbl_prefix_seen = True + break + + +def extract_table_identifiers(token_stream, allow_functions=True): + """yields tuples of TableReference namedtuples""" + + # We need to do some massaging of the names because postgres is case- + # insensitive and '"Foo"' is not the same table as 'Foo' (while 'foo' is) + def parse_identifier(item): + name = item.get_real_name() + schema_name = item.get_parent_name() + alias = item.get_alias() + if not name: + schema_name = None + name = item.get_name() + alias = alias or name + schema_quoted = schema_name and item.value[0] == '"' + if schema_name and not schema_quoted: + schema_name = schema_name.lower() + quote_count = item.value.count('"') + name_quoted = quote_count > 2 or (quote_count and not schema_quoted) + alias_quoted = alias and item.value[-1] == '"' + if alias_quoted or name_quoted and not alias and name.islower(): + alias = '"' + (alias or name) + '"' + if name and not name_quoted and not name.islower(): + if not alias: + alias = name + name = name.lower() + return schema_name, name, alias + + try: + for item in token_stream: + if isinstance(item, IdentifierList): + for identifier in item.get_identifiers(): + # Sometimes Keywords (such as FROM ) are classified as + # identifiers which don't have the get_real_name() method. + try: + schema_name = identifier.get_parent_name() + real_name = identifier.get_real_name() + is_function = allow_functions and _identifier_is_function( + identifier + ) + except AttributeError: + continue + if real_name: + yield TableReference( + schema_name, real_name, identifier.get_alias(), is_function + ) + elif isinstance(item, Identifier): + schema_name, real_name, alias = parse_identifier(item) + is_function = allow_functions and _identifier_is_function(item) + + yield TableReference(schema_name, real_name, alias, is_function) + elif isinstance(item, Function): + schema_name, real_name, alias = parse_identifier(item) + yield TableReference(None, real_name, alias, allow_functions) + except StopIteration: + return + + +# extract_tables is inspired from examples in the sqlparse lib. +def extract_tables(sql): + """Extract the table names from an SQL statment. + + Returns a list of TableReference namedtuples + + """ + parsed = sqlparse.parse(sql) + if not parsed: + return () + + # INSERT statements must stop looking for tables at the sign of first + # Punctuation. eg: INSERT INTO abc (col1, col2) VALUES (1, 2) + # abc is the table name, but if we don't stop at the first lparen, then + # we'll identify abc, col1 and col2 as table names. + insert_stmt = parsed[0].token_first().value.lower() == "insert" + stream = extract_from_part(parsed[0], stop_at_punctuation=insert_stmt) + + # Kludge: sqlparse mistakenly identifies insert statements as + # function calls due to the parenthesized column list, e.g. interprets + # "insert into foo (bar, baz)" as a function call to foo with arguments + # (bar, baz). So don't allow any identifiers in insert statements + # to have is_function=True + identifiers = extract_table_identifiers(stream, allow_functions=not insert_stmt) + # In the case 'sche.', we get an empty TableReference; remove that + return tuple(i for i in identifiers if i.name) diff --git a/pgcli/packages/parseutils/utils.py b/pgcli/packages/parseutils/utils.py new file mode 100644 index 0000000..034c96e --- /dev/null +++ b/pgcli/packages/parseutils/utils.py @@ -0,0 +1,140 @@ +import re +import sqlparse +from sqlparse.sql import Identifier +from sqlparse.tokens import Token, Error + +cleanup_regex = { + # This matches only alphanumerics and underscores. + "alphanum_underscore": re.compile(r"(\w+)$"), + # This matches everything except spaces, parens, colon, and comma + "many_punctuations": re.compile(r"([^():,\s]+)$"), + # This matches everything except spaces, parens, colon, comma, and period + "most_punctuations": re.compile(r"([^\.():,\s]+)$"), + # This matches everything except a space. + "all_punctuations": re.compile(r"([^\s]+)$"), +} + + +def last_word(text, include="alphanum_underscore"): + r""" + Find the last word in a sentence. + + >>> last_word('abc') + 'abc' + >>> last_word(' abc') + 'abc' + >>> last_word('') + '' + >>> last_word(' ') + '' + >>> last_word('abc ') + '' + >>> last_word('abc def') + 'def' + >>> last_word('abc def ') + '' + >>> last_word('abc def;') + '' + >>> last_word('bac $def') + 'def' + >>> last_word('bac $def', include='most_punctuations') + '$def' + >>> last_word('bac \def', include='most_punctuations') + '\\\\def' + >>> last_word('bac \def;', include='most_punctuations') + '\\\\def;' + >>> last_word('bac::def', include='most_punctuations') + 'def' + >>> last_word('"foo*bar', include='most_punctuations') + '"foo*bar' + """ + + if not text: # Empty string + return "" + + if text[-1].isspace(): + return "" + else: + regex = cleanup_regex[include] + matches = regex.search(text) + if matches: + return matches.group(0) + else: + return "" + + +def find_prev_keyword(sql, n_skip=0): + """Find the last sql keyword in an SQL statement + + Returns the value of the last keyword, and the text of the query with + everything after the last keyword stripped + """ + if not sql.strip(): + return None, "" + + parsed = sqlparse.parse(sql)[0] + flattened = list(parsed.flatten()) + flattened = flattened[: len(flattened) - n_skip] + + logical_operators = ("AND", "OR", "NOT", "BETWEEN") + + for t in reversed(flattened): + if t.value == "(" or ( + t.is_keyword and (t.value.upper() not in logical_operators) + ): + # Find the location of token t in the original parsed statement + # We can't use parsed.token_index(t) because t may be a child token + # inside a TokenList, in which case token_index throws an error + # Minimal example: + # p = sqlparse.parse('select * from foo where bar') + # t = list(p.flatten())[-3] # The "Where" token + # p.token_index(t) # Throws ValueError: not in list + idx = flattened.index(t) + + # Combine the string values of all tokens in the original list + # up to and including the target keyword token t, to produce a + # query string with everything after the keyword token removed + text = "".join(tok.value for tok in flattened[: idx + 1]) + return t, text + + return None, "" + + +# Postgresql dollar quote signs look like `$$` or `$tag$` +dollar_quote_regex = re.compile(r"^\$[^$]*\$$") + + +def is_open_quote(sql): + """Returns true if the query contains an unclosed quote""" + + # parsed can contain one or more semi-colon separated commands + parsed = sqlparse.parse(sql) + return any(_parsed_is_open_quote(p) for p in parsed) + + +def _parsed_is_open_quote(parsed): + # Look for unmatched single quotes, or unmatched dollar sign quotes + return any(tok.match(Token.Error, ("'", "$")) for tok in parsed.flatten()) + + +def parse_partial_identifier(word): + """Attempt to parse a (partially typed) word as an identifier + + word may include a schema qualification, like `schema_name.partial_name` + or `schema_name.` There may also be unclosed quotation marks, like + `"schema`, or `schema."partial_name` + + :param word: string representing a (partially complete) identifier + :return: sqlparse.sql.Identifier, or None + """ + + p = sqlparse.parse(word)[0] + n_tok = len(p.tokens) + if n_tok == 1 and isinstance(p.tokens[0], Identifier): + return p.tokens[0] + elif p.token_next_by(m=(Error, '"'))[1]: + # An unmatched double quote, e.g. '"foo', 'foo."', or 'foo."bar' + # Close the double quote, then reparse + return parse_partial_identifier(word + '"') + else: + return None diff --git a/pgcli/packages/pgliterals/__init__.py b/pgcli/packages/pgliterals/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/pgcli/packages/pgliterals/main.py b/pgcli/packages/pgliterals/main.py new file mode 100644 index 0000000..5c39296 --- /dev/null +++ b/pgcli/packages/pgliterals/main.py @@ -0,0 +1,15 @@ +import os +import json + +root = os.path.dirname(__file__) +literal_file = os.path.join(root, "pgliterals.json") + +with open(literal_file) as f: + literals = json.load(f) + + +def get_literals(literal_type, type_=tuple): + # Where `literal_type` is one of 'keywords', 'functions', 'datatypes', + # returns a tuple of literal values of that type. + + return type_(literals[literal_type]) diff --git a/pgcli/packages/pgliterals/pgliterals.json b/pgcli/packages/pgliterals/pgliterals.json new file mode 100644 index 0000000..c7b74b5 --- /dev/null +++ b/pgcli/packages/pgliterals/pgliterals.json @@ -0,0 +1,629 @@ +{ + "keywords": { + "ACCESS": [], + "ADD": [], + "ALL": [], + "ALTER": [ + "AGGREGATE", + "COLLATION", + "COLUMN", + "CONVERSION", + "DATABASE", + "DEFAULT", + "DOMAIN", + "EVENT TRIGGER", + "EXTENSION", + "FOREIGN", + "FUNCTION", + "GROUP", + "INDEX", + "LANGUAGE", + "LARGE OBJECT", + "MATERIALIZED VIEW", + "OPERATOR", + "POLICY", + "ROLE", + "RULE", + "SCHEMA", + "SEQUENCE", + "SERVER", + "SYSTEM", + "TABLE", + "TABLESPACE", + "TEXT SEARCH", + "TRIGGER", + "TYPE", + "USER", + "VIEW" + ], + "AND": [], + "ANY": [], + "AS": [], + "ASC": [], + "AUDIT": [], + "BEGIN": [], + "BETWEEN": [], + "BY": [], + "CASE": [], + "CHAR": [], + "CHECK": [], + "CLUSTER": [], + "COLUMN": [], + "COMMENT": [], + "COMMIT": [], + "COMPRESS": [], + "CONCURRENTLY": [], + "CONNECT": [], + "COPY": [], + "CREATE": [ + "ACCESS METHOD", + "AGGREGATE", + "CAST", + "COLLATION", + "CONVERSION", + "DATABASE", + "DOMAIN", + "EVENT TRIGGER", + "EXTENSION", + "FOREIGN DATA WRAPPER", + "FOREIGN EXTENSION", + "FUNCTION", + "GLOBAL", + "GROUP", + "IF NOT EXISTS", + "INDEX", + "LANGUAGE", + "LOCAL", + "MATERIALIZED VIEW", + "OPERATOR", + "OR REPLACE", + "POLICY", + "ROLE", + "RULE", + "SCHEMA", + "SEQUENCE", + "SERVER", + "TABLE", + "TABLESPACE", + "TEMPORARY", + "TEXT SEARCH", + "TRIGGER", + "TYPE", + "UNIQUE", + "UNLOGGED", + "USER", + "USER MAPPING", + "VIEW" + ], + "CURRENT": [], + "DATABASE": [], + "DATE": [], + "DECIMAL": [], + "DEFAULT": [], + "DELETE FROM": [], + "DELIMITER": [], + "DESC": [], + "DESCRIBE": [], + "DISTINCT": [], + "DROP": [ + "ACCESS METHOD", + "AGGREGATE", + "CAST", + "COLLATION", + "COLUMN", + "CONVERSION", + "DATABASE", + "DOMAIN", + "EVENT TRIGGER", + "EXTENSION", + "FOREIGN DATA WRAPPER", + "FOREIGN TABLE", + "FUNCTION", + "GROUP", + "INDEX", + "LANGUAGE", + "MATERIALIZED VIEW", + "OPERATOR", + "OWNED", + "POLICY", + "ROLE", + "RULE", + "SCHEMA", + "SEQUENCE", + "SERVER", + "TABLE", + "TABLESPACE", + "TEXT SEARCH", + "TRANSFORM", + "TRIGGER", + "TYPE", + "USER", + "USER MAPPING", + "VIEW" + ], + "EXPLAIN": [], + "ELSE": [], + "ENCODING": [], + "ESCAPE": [], + "EXCLUSIVE": [], + "EXISTS": [], + "EXTENSION": [], + "FILE": [], + "FLOAT": [], + "FOR": [], + "FORMAT": [], + "FORCE_QUOTE": [], + "FORCE_NOT_NULL": [], + "FREEZE": [], + "FROM": [], + "FULL": [], + "FUNCTION": [], + "GRANT": [], + "GROUP BY": [], + "HAVING": [], + "HEADER": [], + "IDENTIFIED": [], + "IMMEDIATE": [], + "IN": [], + "INCREMENT": [], + "INDEX": [], + "INITIAL": [], + "INSERT INTO": [], + "INTEGER": [], + "INTERSECT": [], + "INTERVAL": [], + "INTO": [], + "IS": [], + "JOIN": [], + "LANGUAGE": [], + "LEFT": [], + "LEVEL": [], + "LIKE": [], + "LIMIT": [], + "LOCK": [], + "LONG": [], + "MATERIALIZED VIEW": [], + "MAXEXTENTS": [], + "MINUS": [], + "MLSLABEL": [], + "MODE": [], + "MODIFY": [], + "NOT": [], + "NOAUDIT": [], + "NOTICE": [], + "NOCOMPRESS": [], + "NOWAIT": [], + "NULL": [], + "NUMBER": [], + "OIDS": [], + "OF": [], + "OFFLINE": [], + "ON": [], + "ONLINE": [], + "OPTION": [], + "OR": [], + "ORDER BY": [], + "OUTER": [], + "OWNER": [], + "PCTFREE": [], + "PRIMARY": [], + "PRIOR": [], + "PRIVILEGES": [], + "QUOTE": [], + "RAISE": [], + "RENAME": [], + "REPLACE": [], + "RESET": ["ALL"], + "RAW": [], + "REFRESH MATERIALIZED VIEW": [], + "RESOURCE": [], + "RETURNS": [], + "REVOKE": [], + "RIGHT": [], + "ROLLBACK": [], + "ROW": [], + "ROWID": [], + "ROWNUM": [], + "ROWS": [], + "SELECT": [], + "SESSION": [], + "SET": [], + "SHARE": [], + "SHOW": [], + "SIZE": [], + "SMALLINT": [], + "START": [], + "SUCCESSFUL": [], + "SYNONYM": [], + "SYSDATE": [], + "TABLE": [], + "TEMPLATE": [], + "THEN": [], + "TO": [], + "TRIGGER": [], + "TRUNCATE": [], + "UID": [], + "UNION": [], + "UNIQUE": [], + "UPDATE": [], + "USE": [], + "USER": [], + "USING": [], + "VALIDATE": [], + "VALUES": [], + "VARCHAR": [], + "VARCHAR2": [], + "VIEW": [], + "WHEN": [], + "WHENEVER": [], + "WHERE": [], + "WITH": [] + }, + "functions": [ + "ABBREV", + "ABS", + "AGE", + "AREA", + "ARRAY_AGG", + "ARRAY_APPEND", + "ARRAY_CAT", + "ARRAY_DIMS", + "ARRAY_FILL", + "ARRAY_LENGTH", + "ARRAY_LOWER", + "ARRAY_NDIMS", + "ARRAY_POSITION", + "ARRAY_POSITIONS", + "ARRAY_PREPEND", + "ARRAY_REMOVE", + "ARRAY_REPLACE", + "ARRAY_TO_STRING", + "ARRAY_UPPER", + "ASCII", + "AVG", + "BIT_AND", + "BIT_LENGTH", + "BIT_OR", + "BOOL_AND", + "BOOL_OR", + "BOUND_BOX", + "BOX", + "BROADCAST", + "BTRIM", + "CARDINALITY", + "CBRT", + "CEIL", + "CEILING", + "CENTER", + "CHAR_LENGTH", + "CHR", + "CIRCLE", + "CLOCK_TIMESTAMP", + "CONCAT", + "CONCAT_WS", + "CONVERT", + "CONVERT_FROM", + "CONVERT_TO", + "COUNT", + "CUME_DIST", + "CURRENT_DATE", + "CURRENT_TIME", + "CURRENT_TIMESTAMP", + "DATE_PART", + "DATE_TRUNC", + "DECODE", + "DEGREES", + "DENSE_RANK", + "DIAMETER", + "DIV", + "ENCODE", + "ENUM_FIRST", + "ENUM_LAST", + "ENUM_RANGE", + "EVERY", + "EXP", + "EXTRACT", + "FAMILY", + "FIRST_VALUE", + "FLOOR", + "FORMAT", + "GET_BIT", + "GET_BYTE", + "HEIGHT", + "HOST", + "HOSTMASK", + "INET_MERGE", + "INET_SAME_FAMILY", + "INITCAP", + "ISCLOSED", + "ISFINITE", + "ISOPEN", + "JUSTIFY_DAYS", + "JUSTIFY_HOURS", + "JUSTIFY_INTERVAL", + "LAG", + "LAST_VALUE", + "LEAD", + "LEFT", + "LENGTH", + "LINE", + "LN", + "LOCALTIME", + "LOCALTIMESTAMP", + "LOG", + "LOG10", + "LOWER", + "LPAD", + "LSEG", + "LTRIM", + "MAKE_DATE", + "MAKE_INTERVAL", + "MAKE_TIME", + "MAKE_TIMESTAMP", + "MAKE_TIMESTAMPTZ", + "MASKLEN", + "MAX", + "MD5", + "MIN", + "MOD", + "NETMASK", + "NETWORK", + "NOW", + "NPOINTS", + "NTH_VALUE", + "NTILE", + "NUM_NONNULLS", + "NUM_NULLS", + "OCTET_LENGTH", + "OVERLAY", + "PARSE_IDENT", + "PATH", + "PCLOSE", + "PERCENT_RANK", + "PG_CLIENT_ENCODING", + "PI", + "POINT", + "POLYGON", + "POPEN", + "POSITION", + "POWER", + "QUOTE_IDENT", + "QUOTE_LITERAL", + "QUOTE_NULLABLE", + "RADIANS", + "RADIUS", + "RANK", + "REGEXP_MATCH", + "REGEXP_MATCHES", + "REGEXP_REPLACE", + "REGEXP_SPLIT_TO_ARRAY", + "REGEXP_SPLIT_TO_TABLE", + "REPEAT", + "REPLACE", + "REVERSE", + "RIGHT", + "ROUND", + "ROW_NUMBER", + "RPAD", + "RTRIM", + "SCALE", + "SET_BIT", + "SET_BYTE", + "SET_MASKLEN", + "SHA224", + "SHA256", + "SHA384", + "SHA512", + "SIGN", + "SPLIT_PART", + "SQRT", + "STARTS_WITH", + "STATEMENT_TIMESTAMP", + "STRING_TO_ARRAY", + "STRPOS", + "SUBSTR", + "SUBSTRING", + "SUM", + "TEXT", + "TIMEOFDAY", + "TO_ASCII", + "TO_CHAR", + "TO_DATE", + "TO_HEX", + "TO_NUMBER", + "TO_TIMESTAMP", + "TRANSACTION_TIMESTAMP", + "TRANSLATE", + "TRIM", + "TRUNC", + "UNNEST", + "UPPER", + "WIDTH", + "WIDTH_BUCKET", + "XMLAGG" + ], + "datatypes": [ + "ANY", + "ANYARRAY", + "ANYELEMENT", + "ANYENUM", + "ANYNONARRAY", + "ANYRANGE", + "BIGINT", + "BIGSERIAL", + "BIT", + "BIT VARYING", + "BOOL", + "BOOLEAN", + "BOX", + "BYTEA", + "CHAR", + "CHARACTER", + "CHARACTER VARYING", + "CIDR", + "CIRCLE", + "CSTRING", + "DATE", + "DECIMAL", + "DOUBLE PRECISION", + "EVENT_TRIGGER", + "FDW_HANDLER", + "FLOAT4", + "FLOAT8", + "INET", + "INT", + "INT2", + "INT4", + "INT8", + "INTEGER", + "INTERNAL", + "INTERVAL", + "JSON", + "JSONB", + "LANGUAGE_HANDLER", + "LINE", + "LSEG", + "MACADDR", + "MACADDR8", + "MONEY", + "NUMERIC", + "OID", + "OPAQUE", + "PATH", + "PG_LSN", + "POINT", + "POLYGON", + "REAL", + "RECORD", + "REGCLASS", + "REGCONFIG", + "REGDICTIONARY", + "REGNAMESPACE", + "REGOPER", + "REGOPERATOR", + "REGPROC", + "REGPROCEDURE", + "REGROLE", + "REGTYPE", + "SERIAL", + "SERIAL2", + "SERIAL4", + "SERIAL8", + "SMALLINT", + "SMALLSERIAL", + "TEXT", + "TIME", + "TIMESTAMP", + "TRIGGER", + "TSQUERY", + "TSVECTOR", + "TXID_SNAPSHOT", + "UUID", + "VARBIT", + "VARCHAR", + "VOID", + "XML" + ], + "reserved": [ + "ALL", + "ANALYSE", + "ANALYZE", + "AND", + "ANY", + "ARRAY", + "AS", + "ASC", + "ASYMMETRIC", + "BOTH", + "CASE", + "CAST", + "CHECK", + "COLLATE", + "COLUMN", + "CONSTRAINT", + "CREATE", + "CURRENT_CATALOG", + "CURRENT_DATE", + "CURRENT_ROLE", + "CURRENT_TIME", + "CURRENT_TIMESTAMP", + "CURRENT_USER", + "DEFAULT", + "DEFERRABLE", + "DESC", + "DISTINCT", + "DO", + "ELSE", + "END", + "EXCEPT", + "FALSE", + "FETCH", + "FOR", + "FOREIGN", + "FROM", + "GRANT", + "GROUP", + "HAVING", + "IN", + "INITIALLY", + "INTERSECT", + "INTO", + "LATERAL", + "LEADING", + "LIMIT", + "LOCALTIME", + "LOCALTIMESTAMP", + "NOT", + "NULL", + "OFFSET", + "ON", + "ONLY", + "OR", + "ORDER", + "PLACING", + "PRIMARY", + "REFERENCES", + "RETURNING", + "SELECT", + "SESSION_USER", + "SOME", + "SYMMETRIC", + "TABLE", + "THEN", + "TO", + "TRAILING", + "TRUE", + "UNION", + "UNIQUE", + "USER", + "USING", + "VARIADIC", + "WHEN", + "WHERE", + "WINDOW", + "WITH", + "AUTHORIZATION", + "BINARY", + "COLLATION", + "CONCURRENTLY", + "CROSS", + "CURRENT_SCHEMA", + "FREEZE", + "FULL", + "ILIKE", + "INNER", + "IS", + "ISNULL", + "JOIN", + "LEFT", + "LIKE", + "NATURAL", + "NOTNULL", + "OUTER", + "OVERLAPS", + "RIGHT", + "SIMILAR", + "TABLESAMPLE", + "VERBOSE" + ] +} diff --git a/pgcli/packages/prioritization.py b/pgcli/packages/prioritization.py new file mode 100644 index 0000000..e92dcbb --- /dev/null +++ b/pgcli/packages/prioritization.py @@ -0,0 +1,51 @@ +import re +import sqlparse +from sqlparse.tokens import Name +from collections import defaultdict +from .pgliterals.main import get_literals + + +white_space_regex = re.compile("\\s+", re.MULTILINE) + + +def _compile_regex(keyword): + # Surround the keyword with word boundaries and replace interior whitespace + # with whitespace wildcards + pattern = "\\b" + white_space_regex.sub(r"\\s+", keyword) + "\\b" + return re.compile(pattern, re.MULTILINE | re.IGNORECASE) + + +keywords = get_literals("keywords") +keyword_regexs = dict((kw, _compile_regex(kw)) for kw in keywords) + + +class PrevalenceCounter(object): + def __init__(self): + self.keyword_counts = defaultdict(int) + self.name_counts = defaultdict(int) + + def update(self, text): + self.update_keywords(text) + self.update_names(text) + + def update_names(self, text): + for parsed in sqlparse.parse(text): + for token in parsed.flatten(): + if token.ttype in Name: + self.name_counts[token.value] += 1 + + def clear_names(self): + self.name_counts = defaultdict(int) + + def update_keywords(self, text): + # Count keywords. Can't rely for sqlparse for this, because it's + # database agnostic + for keyword, regex in keyword_regexs.items(): + for _ in regex.finditer(text): + self.keyword_counts[keyword] += 1 + + def keyword_count(self, keyword): + return self.keyword_counts[keyword] + + def name_count(self, name): + return self.name_counts[name] diff --git a/pgcli/packages/prompt_utils.py b/pgcli/packages/prompt_utils.py new file mode 100644 index 0000000..3c58490 --- /dev/null +++ b/pgcli/packages/prompt_utils.py @@ -0,0 +1,35 @@ +import sys +import click +from .parseutils import is_destructive + + +def confirm_destructive_query(queries): + """Check if the query is destructive and prompts the user to confirm. + + Returns: + * None if the query is non-destructive or we can't prompt the user. + * True if the query is destructive and the user wants to proceed. + * False if the query is destructive and the user doesn't want to proceed. + + """ + prompt_text = ( + "You're about to run a destructive command.\n" "Do you want to proceed? (y/n)" + ) + if is_destructive(queries) and sys.stdin.isatty(): + return prompt(prompt_text, type=bool) + + +def confirm(*args, **kwargs): + """Prompt for confirmation (yes/no) and handle any abort exceptions.""" + try: + return click.confirm(*args, **kwargs) + except click.Abort: + return False + + +def prompt(*args, **kwargs): + """Prompt the user for input and handle any abort exceptions.""" + try: + return click.prompt(*args, **kwargs) + except click.Abort: + return False diff --git a/pgcli/packages/sqlcompletion.py b/pgcli/packages/sqlcompletion.py new file mode 100644 index 0000000..6ef8859 --- /dev/null +++ b/pgcli/packages/sqlcompletion.py @@ -0,0 +1,608 @@ +import sys +import re +import sqlparse +from collections import namedtuple +from sqlparse.sql import Comparison, Identifier, Where +from .parseutils.utils import last_word, find_prev_keyword, parse_partial_identifier +from .parseutils.tables import extract_tables +from .parseutils.ctes import isolate_query_ctes +from pgspecial.main import parse_special_command + + +Special = namedtuple("Special", []) +Database = namedtuple("Database", []) +Schema = namedtuple("Schema", ["quoted"]) +Schema.__new__.__defaults__ = (False,) +# FromClauseItem is a table/view/function used in the FROM clause +# `table_refs` contains the list of tables/... already in the statement, +# used to ensure that the alias we suggest is unique +FromClauseItem = namedtuple("FromClauseItem", "schema table_refs local_tables") +Table = namedtuple("Table", ["schema", "table_refs", "local_tables"]) +TableFormat = namedtuple("TableFormat", []) +View = namedtuple("View", ["schema", "table_refs"]) +# JoinConditions are suggested after ON, e.g. 'foo.barid = bar.barid' +JoinCondition = namedtuple("JoinCondition", ["table_refs", "parent"]) +# Joins are suggested after JOIN, e.g. 'foo ON foo.barid = bar.barid' +Join = namedtuple("Join", ["table_refs", "schema"]) + +Function = namedtuple("Function", ["schema", "table_refs", "usage"]) +# For convenience, don't require the `usage` argument in Function constructor +Function.__new__.__defaults__ = (None, tuple(), None) +Table.__new__.__defaults__ = (None, tuple(), tuple()) +View.__new__.__defaults__ = (None, tuple()) +FromClauseItem.__new__.__defaults__ = (None, tuple(), tuple()) + +Column = namedtuple( + "Column", + ["table_refs", "require_last_table", "local_tables", "qualifiable", "context"], +) +Column.__new__.__defaults__ = (None, None, tuple(), False, None) + +Keyword = namedtuple("Keyword", ["last_token"]) +Keyword.__new__.__defaults__ = (None,) +NamedQuery = namedtuple("NamedQuery", []) +Datatype = namedtuple("Datatype", ["schema"]) +Alias = namedtuple("Alias", ["aliases"]) + +Path = namedtuple("Path", []) + + +class SqlStatement(object): + def __init__(self, full_text, text_before_cursor): + self.identifier = None + self.word_before_cursor = word_before_cursor = last_word( + text_before_cursor, include="many_punctuations" + ) + full_text = _strip_named_query(full_text) + text_before_cursor = _strip_named_query(text_before_cursor) + + full_text, text_before_cursor, self.local_tables = isolate_query_ctes( + full_text, text_before_cursor + ) + + self.text_before_cursor_including_last_word = text_before_cursor + + # If we've partially typed a word then word_before_cursor won't be an + # empty string. In that case we want to remove the partially typed + # string before sending it to the sqlparser. Otherwise the last token + # will always be the partially typed string which renders the smart + # completion useless because it will always return the list of + # keywords as completion. + if self.word_before_cursor: + if word_before_cursor[-1] == "(" or word_before_cursor[0] == "\\": + parsed = sqlparse.parse(text_before_cursor) + else: + text_before_cursor = text_before_cursor[: -len(word_before_cursor)] + parsed = sqlparse.parse(text_before_cursor) + self.identifier = parse_partial_identifier(word_before_cursor) + else: + parsed = sqlparse.parse(text_before_cursor) + + full_text, text_before_cursor, parsed = _split_multiple_statements( + full_text, text_before_cursor, parsed + ) + + self.full_text = full_text + self.text_before_cursor = text_before_cursor + self.parsed = parsed + + self.last_token = parsed and parsed.token_prev(len(parsed.tokens))[1] or "" + + def is_insert(self): + return self.parsed.token_first().value.lower() == "insert" + + def get_tables(self, scope="full"): + """Gets the tables available in the statement. + param `scope:` possible values: 'full', 'insert', 'before' + If 'insert', only the first table is returned. + If 'before', only tables before the cursor are returned. + If not 'insert' and the stmt is an insert, the first table is skipped. + """ + tables = extract_tables( + self.full_text if scope == "full" else self.text_before_cursor + ) + if scope == "insert": + tables = tables[:1] + elif self.is_insert(): + tables = tables[1:] + return tables + + def get_previous_token(self, token): + return self.parsed.token_prev(self.parsed.token_index(token))[1] + + def get_identifier_schema(self): + schema = (self.identifier and self.identifier.get_parent_name()) or None + # If schema name is unquoted, lower-case it + if schema and self.identifier.value[0] != '"': + schema = schema.lower() + + return schema + + def reduce_to_prev_keyword(self, n_skip=0): + prev_keyword, self.text_before_cursor = find_prev_keyword( + self.text_before_cursor, n_skip=n_skip + ) + return prev_keyword + + +def suggest_type(full_text, text_before_cursor): + """Takes the full_text that is typed so far and also the text before the + cursor to suggest completion type and scope. + + Returns a tuple with a type of entity ('table', 'column' etc) and a scope. + A scope for a column category will be a list of tables. + """ + + if full_text.startswith("\\i "): + return (Path(),) + + # This is a temporary hack; the exception handling + # here should be removed once sqlparse has been fixed + try: + stmt = SqlStatement(full_text, text_before_cursor) + except (TypeError, AttributeError): + return [] + + # Check for special commands and handle those separately + if stmt.parsed: + # Be careful here because trivial whitespace is parsed as a + # statement, but the statement won't have a first token + tok1 = stmt.parsed.token_first() + if tok1 and tok1.value.startswith("\\"): + text = stmt.text_before_cursor + stmt.word_before_cursor + return suggest_special(text) + + return suggest_based_on_last_token(stmt.last_token, stmt) + + +named_query_regex = re.compile(r"^\s*\\ns\s+[A-z0-9\-_]+\s+") + + +def _strip_named_query(txt): + """ + This will strip "save named query" command in the beginning of the line: + '\ns zzz SELECT * FROM abc' -> 'SELECT * FROM abc' + ' \ns zzz SELECT * FROM abc' -> 'SELECT * FROM abc' + """ + + if named_query_regex.match(txt): + txt = named_query_regex.sub("", txt) + return txt + + +function_body_pattern = re.compile(r"(\$.*?\$)([\s\S]*?)\1", re.M) + + +def _find_function_body(text): + split = function_body_pattern.search(text) + return (split.start(2), split.end(2)) if split else (None, None) + + +def _statement_from_function(full_text, text_before_cursor, statement): + current_pos = len(text_before_cursor) + body_start, body_end = _find_function_body(full_text) + if body_start is None: + return full_text, text_before_cursor, statement + if not body_start <= current_pos < body_end: + return full_text, text_before_cursor, statement + full_text = full_text[body_start:body_end] + text_before_cursor = text_before_cursor[body_start:] + parsed = sqlparse.parse(text_before_cursor) + return _split_multiple_statements(full_text, text_before_cursor, parsed) + + +def _split_multiple_statements(full_text, text_before_cursor, parsed): + if len(parsed) > 1: + # Multiple statements being edited -- isolate the current one by + # cumulatively summing statement lengths to find the one that bounds + # the current position + current_pos = len(text_before_cursor) + stmt_start, stmt_end = 0, 0 + + for statement in parsed: + stmt_len = len(str(statement)) + stmt_start, stmt_end = stmt_end, stmt_end + stmt_len + + if stmt_end >= current_pos: + text_before_cursor = full_text[stmt_start:current_pos] + full_text = full_text[stmt_start:] + break + + elif parsed: + # A single statement + statement = parsed[0] + else: + # The empty string + return full_text, text_before_cursor, None + + token2 = None + if statement.get_type() in ("CREATE", "CREATE OR REPLACE"): + token1 = statement.token_first() + if token1: + token1_idx = statement.token_index(token1) + token2 = statement.token_next(token1_idx)[1] + if token2 and token2.value.upper() == "FUNCTION": + full_text, text_before_cursor, statement = _statement_from_function( + full_text, text_before_cursor, statement + ) + return full_text, text_before_cursor, statement + + +SPECIALS_SUGGESTION = { + "dT": Datatype, + "df": Function, + "dt": Table, + "dv": View, + "sf": Function, +} + + +def suggest_special(text): + text = text.lstrip() + cmd, _, arg = parse_special_command(text) + + if cmd == text: + # Trying to complete the special command itself + return (Special(),) + + if cmd in ("\\c", "\\connect"): + return (Database(),) + + if cmd == "\\T": + return (TableFormat(),) + + if cmd == "\\dn": + return (Schema(),) + + if arg: + # Try to distinguish "\d name" from "\d schema.name" + # Note that this will fail to obtain a schema name if wildcards are + # used, e.g. "\d schema???.name" + parsed = sqlparse.parse(arg)[0].tokens[0] + try: + schema = parsed.get_parent_name() + except AttributeError: + schema = None + else: + schema = None + + if cmd[1:] == "d": + # \d can describe tables or views + if schema: + return (Table(schema=schema), View(schema=schema)) + else: + return (Schema(), Table(schema=None), View(schema=None)) + elif cmd[1:] in SPECIALS_SUGGESTION: + rel_type = SPECIALS_SUGGESTION[cmd[1:]] + if schema: + if rel_type == Function: + return (Function(schema=schema, usage="special"),) + return (rel_type(schema=schema),) + else: + if rel_type == Function: + return (Schema(), Function(schema=None, usage="special")) + return (Schema(), rel_type(schema=None)) + + if cmd in ["\\n", "\\ns", "\\nd"]: + return (NamedQuery(),) + + return (Keyword(), Special()) + + +def suggest_based_on_last_token(token, stmt): + + if isinstance(token, str): + token_v = token.lower() + elif isinstance(token, Comparison): + # If 'token' is a Comparison type such as + # 'select * FROM abc a JOIN def d ON a.id = d.'. Then calling + # token.value on the comparison type will only return the lhs of the + # comparison. In this case a.id. So we need to do token.tokens to get + # both sides of the comparison and pick the last token out of that + # list. + token_v = token.tokens[-1].value.lower() + elif isinstance(token, Where): + # sqlparse groups all tokens from the where clause into a single token + # list. This means that token.value may be something like + # 'where foo > 5 and '. We need to look "inside" token.tokens to handle + # suggestions in complicated where clauses correctly + prev_keyword = stmt.reduce_to_prev_keyword() + return suggest_based_on_last_token(prev_keyword, stmt) + elif isinstance(token, Identifier): + # If the previous token is an identifier, we can suggest datatypes if + # we're in a parenthesized column/field list, e.g.: + # CREATE TABLE foo (Identifier + # CREATE FUNCTION foo (Identifier + # If we're not in a parenthesized list, the most likely scenario is the + # user is about to specify an alias, e.g.: + # SELECT Identifier + # SELECT foo FROM Identifier + prev_keyword, _ = find_prev_keyword(stmt.text_before_cursor) + if prev_keyword and prev_keyword.value == "(": + # Suggest datatypes + return suggest_based_on_last_token("type", stmt) + else: + return (Keyword(),) + else: + token_v = token.value.lower() + + if not token: + return (Keyword(), Special()) + elif token_v.endswith("("): + p = sqlparse.parse(stmt.text_before_cursor)[0] + + if p.tokens and isinstance(p.tokens[-1], Where): + # Four possibilities: + # 1 - Parenthesized clause like "WHERE foo AND (" + # Suggest columns/functions + # 2 - Function call like "WHERE foo(" + # Suggest columns/functions + # 3 - Subquery expression like "WHERE EXISTS (" + # Suggest keywords, in order to do a subquery + # 4 - Subquery OR array comparison like "WHERE foo = ANY(" + # Suggest columns/functions AND keywords. (If we wanted to be + # really fancy, we could suggest only array-typed columns) + + column_suggestions = suggest_based_on_last_token("where", stmt) + + # Check for a subquery expression (cases 3 & 4) + where = p.tokens[-1] + prev_tok = where.token_prev(len(where.tokens) - 1)[1] + + if isinstance(prev_tok, Comparison): + # e.g. "SELECT foo FROM bar WHERE foo = ANY(" + prev_tok = prev_tok.tokens[-1] + + prev_tok = prev_tok.value.lower() + if prev_tok == "exists": + return (Keyword(),) + else: + return column_suggestions + + # Get the token before the parens + prev_tok = p.token_prev(len(p.tokens) - 1)[1] + + if ( + prev_tok + and prev_tok.value + and prev_tok.value.lower().split(" ")[-1] == "using" + ): + # tbl1 INNER JOIN tbl2 USING (col1, col2) + tables = stmt.get_tables("before") + + # suggest columns that are present in more than one table + return ( + Column( + table_refs=tables, + require_last_table=True, + local_tables=stmt.local_tables, + ), + ) + + elif p.token_first().value.lower() == "select": + # If the lparen is preceeded by a space chances are we're about to + # do a sub-select. + if last_word(stmt.text_before_cursor, "all_punctuations").startswith("("): + return (Keyword(),) + prev_prev_tok = prev_tok and p.token_prev(p.token_index(prev_tok))[1] + if prev_prev_tok and prev_prev_tok.normalized == "INTO": + return (Column(table_refs=stmt.get_tables("insert"), context="insert"),) + # We're probably in a function argument list + return _suggest_expression(token_v, stmt) + elif token_v == "set": + return (Column(table_refs=stmt.get_tables(), local_tables=stmt.local_tables),) + elif token_v in ("select", "where", "having", "order by", "distinct"): + return _suggest_expression(token_v, stmt) + elif token_v == "as": + # Don't suggest anything for aliases + return () + elif (token_v.endswith("join") and token.is_keyword) or ( + token_v in ("copy", "from", "update", "into", "describe", "truncate") + ): + + schema = stmt.get_identifier_schema() + tables = extract_tables(stmt.text_before_cursor) + is_join = token_v.endswith("join") and token.is_keyword + + # Suggest tables from either the currently-selected schema or the + # public schema if no schema has been specified + suggest = [] + + if not schema: + # Suggest schemas + suggest.insert(0, Schema()) + + if token_v == "from" or is_join: + suggest.append( + FromClauseItem( + schema=schema, table_refs=tables, local_tables=stmt.local_tables + ) + ) + elif token_v == "truncate": + suggest.append(Table(schema)) + else: + suggest.extend((Table(schema), View(schema))) + + if is_join and _allow_join(stmt.parsed): + tables = stmt.get_tables("before") + suggest.append(Join(table_refs=tables, schema=schema)) + + return tuple(suggest) + + elif token_v == "function": + schema = stmt.get_identifier_schema() + + # stmt.get_previous_token will fail for e.g. `SELECT 1 FROM functions WHERE function:` + try: + prev = stmt.get_previous_token(token).value.lower() + if prev in ("drop", "alter", "create", "create or replace"): + + # Suggest functions from either the currently-selected schema or the + # public schema if no schema has been specified + suggest = [] + + if not schema: + # Suggest schemas + suggest.insert(0, Schema()) + + suggest.append(Function(schema=schema, usage="signature")) + return tuple(suggest) + + except ValueError: + pass + return tuple() + + elif token_v in ("table", "view"): + # E.g. 'ALTER TABLE ' + rel_type = {"table": Table, "view": View, "function": Function}[token_v] + schema = stmt.get_identifier_schema() + if schema: + return (rel_type(schema=schema),) + else: + return (Schema(), rel_type(schema=schema)) + + elif token_v == "column": + # E.g. 'ALTER TABLE foo ALTER COLUMN bar + return (Column(table_refs=stmt.get_tables()),) + + elif token_v == "on": + tables = stmt.get_tables("before") + parent = (stmt.identifier and stmt.identifier.get_parent_name()) or None + if parent: + # "ON parent." + # parent can be either a schema name or table alias + filteredtables = tuple(t for t in tables if identifies(parent, t)) + sugs = [ + Column(table_refs=filteredtables, local_tables=stmt.local_tables), + Table(schema=parent), + View(schema=parent), + Function(schema=parent), + ] + if filteredtables and _allow_join_condition(stmt.parsed): + sugs.append(JoinCondition(table_refs=tables, parent=filteredtables[-1])) + return tuple(sugs) + else: + # ON + # Use table alias if there is one, otherwise the table name + aliases = tuple(t.ref for t in tables) + if _allow_join_condition(stmt.parsed): + return ( + Alias(aliases=aliases), + JoinCondition(table_refs=tables, parent=None), + ) + else: + return (Alias(aliases=aliases),) + + elif token_v in ("c", "use", "database", "template"): + # "\c ", "DROP DATABASE ", + # "CREATE DATABASE WITH TEMPLATE " + return (Database(),) + elif token_v == "schema": + # DROP SCHEMA schema_name, SET SCHEMA schema name + prev_keyword = stmt.reduce_to_prev_keyword(n_skip=2) + quoted = prev_keyword and prev_keyword.value.lower() == "set" + return (Schema(quoted),) + elif token_v.endswith(",") or token_v in ("=", "and", "or"): + prev_keyword = stmt.reduce_to_prev_keyword() + if prev_keyword: + return suggest_based_on_last_token(prev_keyword, stmt) + else: + return () + elif token_v in ("type", "::"): + # ALTER TABLE foo SET DATA TYPE bar + # SELECT foo::bar + # Note that tables are a form of composite type in postgresql, so + # they're suggested here as well + schema = stmt.get_identifier_schema() + suggestions = [Datatype(schema=schema), Table(schema=schema)] + if not schema: + suggestions.append(Schema()) + return tuple(suggestions) + elif token_v in {"alter", "create", "drop"}: + return (Keyword(token_v.upper()),) + elif token.is_keyword: + # token is a keyword we haven't implemented any special handling for + # go backwards in the query until we find one we do recognize + prev_keyword = stmt.reduce_to_prev_keyword(n_skip=1) + if prev_keyword: + return suggest_based_on_last_token(prev_keyword, stmt) + else: + return (Keyword(token_v.upper()),) + else: + return (Keyword(),) + + +def _suggest_expression(token_v, stmt): + """ + Return suggestions for an expression, taking account of any partially-typed + identifier's parent, which may be a table alias or schema name. + """ + parent = stmt.identifier.get_parent_name() if stmt.identifier else [] + tables = stmt.get_tables() + + if parent: + tables = tuple(t for t in tables if identifies(parent, t)) + return ( + Column(table_refs=tables, local_tables=stmt.local_tables), + Table(schema=parent), + View(schema=parent), + Function(schema=parent), + ) + + return ( + Column(table_refs=tables, local_tables=stmt.local_tables, qualifiable=True), + Function(schema=None), + Keyword(token_v.upper()), + ) + + +def identifies(id, ref): + """Returns true if string `id` matches TableReference `ref`""" + + return ( + id == ref.alias + or id == ref.name + or (ref.schema and (id == ref.schema + "." + ref.name)) + ) + + +def _allow_join_condition(statement): + """ + Tests if a join condition should be suggested + + We need this to avoid bad suggestions when entering e.g. + select * from tbl1 a join tbl2 b on a.id = + So check that the preceding token is a ON, AND, or OR keyword, instead of + e.g. an equals sign. + + :param statement: an sqlparse.sql.Statement + :return: boolean + """ + + if not statement or not statement.tokens: + return False + + last_tok = statement.token_prev(len(statement.tokens))[1] + return last_tok.value.lower() in ("on", "and", "or") + + +def _allow_join(statement): + """ + Tests if a join should be suggested + + We need this to avoid bad suggestions when entering e.g. + select * from tbl1 a join tbl2 b + So check that the preceding token is a JOIN keyword + + :param statement: an sqlparse.sql.Statement + :return: boolean + """ + + if not statement or not statement.tokens: + return False + + last_tok = statement.token_prev(len(statement.tokens))[1] + return last_tok.value.lower().endswith("join") and last_tok.value.lower() not in ( + "cross join", + "natural join", + ) diff --git a/pgcli/pgbuffer.py b/pgcli/pgbuffer.py new file mode 100644 index 0000000..706ed25 --- /dev/null +++ b/pgcli/pgbuffer.py @@ -0,0 +1,50 @@ +import logging + +from prompt_toolkit.enums import DEFAULT_BUFFER +from prompt_toolkit.filters import Condition +from prompt_toolkit.application import get_app +from .packages.parseutils.utils import is_open_quote + +_logger = logging.getLogger(__name__) + + +def _is_complete(sql): + # A complete command is an sql statement that ends with a semicolon, unless + # there's an open quote surrounding it, as is common when writing a + # CREATE FUNCTION command + return sql.endswith(";") and not is_open_quote(sql) + + +""" +Returns True if the buffer contents should be handled (i.e. the query/command +executed) immediately. This is necessary as we use prompt_toolkit in multiline +mode, which by default will insert new lines on Enter. +""" + + +def buffer_should_be_handled(pgcli): + @Condition + def cond(): + if not pgcli.multi_line: + _logger.debug("Not in multi-line mode. Handle the buffer.") + return True + + if pgcli.multiline_mode == "safe": + _logger.debug("Multi-line mode is set to 'safe'. Do NOT handle the buffer.") + return False + + doc = get_app().layout.get_buffer_by_name(DEFAULT_BUFFER).document + text = doc.text.strip() + + return ( + text.startswith("\\") # Special Command + or text.endswith(r"\e") # Special Command + or text.endswith(r"\G") # Ended with \e which should launch the editor + or _is_complete(text) # A complete SQL command + or (text == "exit") # Exit doesn't need semi-colon + or (text == "quit") # Quit doesn't need semi-colon + or (text == ":q") # To all the vim fans out there + or (text == "") # Just a plain enter without any text + ) + + return cond diff --git a/pgcli/pgclirc b/pgcli/pgclirc new file mode 100644 index 0000000..e97afda --- /dev/null +++ b/pgcli/pgclirc @@ -0,0 +1,195 @@ +# vi: ft=dosini +[main] + +# Enables context sensitive auto-completion. If this is disabled, all +# possible completions will be listed. +smart_completion = True + +# Display the completions in several columns. (More completions will be +# visible.) +wider_completion_menu = False + +# Multi-line mode allows breaking up the sql statements into multiple lines. If +# this is set to True, then the end of the statements must have a semi-colon. +# If this is set to False then sql statements can't be split into multiple +# lines. End of line (return) is considered as the end of the statement. +multi_line = False + +# If multi_line_mode is set to "psql", in multi-line mode, [Enter] will execute +# the current input if the input ends in a semicolon. +# If multi_line_mode is set to "safe", in multi-line mode, [Enter] will always +# insert a newline, and [Esc] [Enter] or [Alt]-[Enter] must be used to execute +# a command. +multi_line_mode = psql + +# Destructive warning mode will alert you before executing a sql statement +# that may cause harm to the database such as "drop table", "drop database" +# or "shutdown". +destructive_warning = True + +# Enables expand mode, which is similar to `\x` in psql. +expand = False + +# Enables auto expand mode, which is similar to `\x auto` in psql. +auto_expand = False + +# If set to True, table suggestions will include a table alias +generate_aliases = False + +# log_file location. +# In Unix/Linux: ~/.config/pgcli/log +# In Windows: %USERPROFILE%\AppData\Local\dbcli\pgcli\log +# %USERPROFILE% is typically C:\Users\{username} +log_file = default + +# keyword casing preference. Possible values: "lower", "upper", "auto" +keyword_casing = auto + +# casing_file location. +# In Unix/Linux: ~/.config/pgcli/casing +# In Windows: %USERPROFILE%\AppData\Local\dbcli\pgcli\casing +# %USERPROFILE% is typically C:\Users\{username} +casing_file = default + +# If generate_casing_file is set to True and there is no file in the above +# location, one will be generated based on usage in SQL/PLPGSQL functions. +generate_casing_file = False + +# Casing of column headers based on the casing_file described above +case_column_headers = True + +# history_file location. +# In Unix/Linux: ~/.config/pgcli/history +# In Windows: %USERPROFILE%\AppData\Local\dbcli\pgcli\history +# %USERPROFILE% is typically C:\Users\{username} +history_file = default + +# Default log level. Possible values: "CRITICAL", "ERROR", "WARNING", "INFO" +# and "DEBUG". "NONE" disables logging. +log_level = INFO + +# Order of columns when expanding * to column list +# Possible values: "table_order" and "alphabetic" +asterisk_column_order = table_order + +# Whether to qualify with table alias/name when suggesting columns +# Possible values: "always", "never" and "if_more_than_one_table" +qualify_columns = if_more_than_one_table + +# When no schema is entered, only suggest objects in search_path +search_path_filter = False + +# Default pager. +# By default 'PAGER' environment variable is used +# pager = less -SRXF + +# Timing of sql statements and table rendering. +timing = True + +# Show/hide the informational toolbar with function keymap at the footer. +show_bottom_toolbar = True + +# Table format. Possible values: psql, plain, simple, grid, fancy_grid, pipe, +# ascii, double, github, orgtbl, rst, mediawiki, html, latex, latex_booktabs, +# textile, moinmoin, jira, vertical, tsv, csv. +# Recommended: psql, fancy_grid and grid. +table_format = psql + +# Syntax Style. Possible values: manni, igor, xcode, vim, autumn, vs, rrt, +# native, perldoc, borland, tango, emacs, friendly, monokai, paraiso-dark, +# colorful, murphy, bw, pastie, paraiso-light, trac, default, fruity +syntax_style = default + +# Keybindings: +# When Vi mode is enabled you can use modal editing features offered by Vi in the REPL. +# When Vi mode is disabled emacs keybindings such as Ctrl-A for home and Ctrl-E +# for end are available in the REPL. +vi = False + +# Error handling +# When one of multiple SQL statements causes an error, choose to either +# continue executing the remaining statements, or stopping +# Possible values "STOP" or "RESUME" +on_error = STOP + +# Set threshold for row limit. Use 0 to disable limiting. +row_limit = 1000 + +# Skip intro on startup and goodbye on exit +less_chatty = False + +# Postgres prompt +# \t - Current date and time +# \u - Username +# \h - Short hostname of the server (up to first '.') +# \H - Hostname of the server +# \d - Database name +# \p - Database port +# \i - Postgres PID +# \# - "@" sign if logged in as superuser, '>' in other case +# \n - Newline +# \dsn_alias - name of dsn alias if -D option is used (empty otherwise) +# \x1b[...m - insert ANSI escape sequence +# eg: prompt = '\x1b[35m\u@\x1b[32m\h:\x1b[36m\d>' +prompt = '\u@\h:\d> ' + +# Number of lines to reserve for the suggestion menu +min_num_menu_lines = 4 + +# Character used to left pad multi-line queries to match the prompt size. +multiline_continuation_char = '' + +# The string used in place of a null value. +null_string = '' + +# manage pager on startup +enable_pager = True + +# Use keyring to automatically save and load password in a secure manner +keyring = True + +# Custom colors for the completion menu, toolbar, etc. +[colors] +completion-menu.completion.current = 'bg:#ffffff #000000' +completion-menu.completion = 'bg:#008888 #ffffff' +completion-menu.meta.completion.current = 'bg:#44aaaa #000000' +completion-menu.meta.completion = 'bg:#448888 #ffffff' +completion-menu.multi-column-meta = 'bg:#aaffff #000000' +scrollbar.arrow = 'bg:#003333' +scrollbar = 'bg:#00aaaa' +selected = '#ffffff bg:#6666aa' +search = '#ffffff bg:#4444aa' +search.current = '#ffffff bg:#44aa44' +bottom-toolbar = 'bg:#222222 #aaaaaa' +bottom-toolbar.off = 'bg:#222222 #888888' +bottom-toolbar.on = 'bg:#222222 #ffffff' +search-toolbar = 'noinherit bold' +search-toolbar.text = 'nobold' +system-toolbar = 'noinherit bold' +arg-toolbar = 'noinherit bold' +arg-toolbar.text = 'nobold' +bottom-toolbar.transaction.valid = 'bg:#222222 #00ff5f bold' +bottom-toolbar.transaction.failed = 'bg:#222222 #ff005f bold' +literal.string = '#ba2121' +literal.number = '#666666' +keyword = 'bold #008000' + +# style classes for colored table output +output.header = "#00ff5f bold" +output.odd-row = "" +output.even-row = "" +output.null = "#808080" + +# Named queries are queries you can execute by name. +[named queries] + +# DSN to call by -D option +[alias_dsn] +# example_dsn = postgresql://[user[:password]@][netloc][:port][/dbname] + +# Format for number representation +# for decimal "d" - 12345678, ",d" - 12,345,678 +# for float "g" - 123456.78, ",g" - 123,456.78 +[data_formats] +decimal = "" +float = "" diff --git a/pgcli/pgcompleter.py b/pgcli/pgcompleter.py new file mode 100644 index 0000000..9c95a01 --- /dev/null +++ b/pgcli/pgcompleter.py @@ -0,0 +1,1046 @@ +import logging +import re +from itertools import count, repeat, chain +import operator +from collections import namedtuple, defaultdict, OrderedDict +from cli_helpers.tabular_output import TabularOutputFormatter +from pgspecial.namedqueries import NamedQueries +from prompt_toolkit.completion import Completer, Completion, PathCompleter +from prompt_toolkit.document import Document +from .packages.sqlcompletion import ( + FromClauseItem, + suggest_type, + Special, + Database, + Schema, + Table, + TableFormat, + Function, + Column, + View, + Keyword, + NamedQuery, + Datatype, + Alias, + Path, + JoinCondition, + Join, +) +from .packages.parseutils.meta import ColumnMetadata, ForeignKey +from .packages.parseutils.utils import last_word +from .packages.parseutils.tables import TableReference +from .packages.pgliterals.main import get_literals +from .packages.prioritization import PrevalenceCounter +from .config import load_config, config_location + +_logger = logging.getLogger(__name__) + +Match = namedtuple("Match", ["completion", "priority"]) + +_SchemaObject = namedtuple("SchemaObject", "name schema meta") + + +def SchemaObject(name, schema=None, meta=None): + return _SchemaObject(name, schema, meta) + + +_Candidate = namedtuple("Candidate", "completion prio meta synonyms prio2 display") + + +def Candidate( + completion, prio=None, meta=None, synonyms=None, prio2=None, display=None +): + return _Candidate( + completion, prio, meta, synonyms or [completion], prio2, display or completion + ) + + +# Used to strip trailing '::some_type' from default-value expressions +arg_default_type_strip_regex = re.compile(r"::[\w\.]+(\[\])?$") + +normalize_ref = lambda ref: ref if ref[0] == '"' else '"' + ref.lower() + '"' + + +def generate_alias(tbl): + """Generate a table alias, consisting of all upper-case letters in + the table name, or, if there are no upper-case letters, the first letter + + all letters preceded by _ + param tbl - unescaped name of the table to alias + """ + return "".join( + [l for l in tbl if l.isupper()] + or [l for l, prev in zip(tbl, "_" + tbl) if prev == "_" and l != "_"] + ) + + +class PGCompleter(Completer): + # keywords_tree: A dict mapping keywords to well known following keywords. + # e.g. 'CREATE': ['TABLE', 'USER', ...], + keywords_tree = get_literals("keywords", type_=dict) + keywords = tuple(set(chain(keywords_tree.keys(), *keywords_tree.values()))) + functions = get_literals("functions") + datatypes = get_literals("datatypes") + reserved_words = set(get_literals("reserved")) + + def __init__(self, smart_completion=True, pgspecial=None, settings=None): + super(PGCompleter, self).__init__() + self.smart_completion = smart_completion + self.pgspecial = pgspecial + self.prioritizer = PrevalenceCounter() + settings = settings or {} + self.signature_arg_style = settings.get( + "signature_arg_style", "{arg_name} {arg_type}" + ) + self.call_arg_style = settings.get( + "call_arg_style", "{arg_name: <{max_arg_len}} := {arg_default}" + ) + self.call_arg_display_style = settings.get( + "call_arg_display_style", "{arg_name}" + ) + self.call_arg_oneliner_max = settings.get("call_arg_oneliner_max", 2) + self.search_path_filter = settings.get("search_path_filter") + self.generate_aliases = settings.get("generate_aliases") + self.casing_file = settings.get("casing_file") + self.insert_col_skip_patterns = [ + re.compile(pattern) + for pattern in settings.get( + "insert_col_skip_patterns", [r"^now\(\)$", r"^nextval\("] + ) + ] + self.generate_casing_file = settings.get("generate_casing_file") + self.qualify_columns = settings.get("qualify_columns", "if_more_than_one_table") + self.asterisk_column_order = settings.get( + "asterisk_column_order", "table_order" + ) + + keyword_casing = settings.get("keyword_casing", "upper").lower() + if keyword_casing not in ("upper", "lower", "auto"): + keyword_casing = "upper" + self.keyword_casing = keyword_casing + self.name_pattern = re.compile(r"^[_a-z][_a-z0-9\$]*$") + + self.databases = [] + self.dbmetadata = {"tables": {}, "views": {}, "functions": {}, "datatypes": {}} + self.search_path = [] + self.casing = {} + + self.all_completions = set(self.keywords + self.functions) + + def escape_name(self, name): + if name and ( + (not self.name_pattern.match(name)) + or (name.upper() in self.reserved_words) + or (name.upper() in self.functions) + ): + name = '"%s"' % name + + return name + + def escape_schema(self, name): + return "'{}'".format(self.unescape_name(name)) + + def unescape_name(self, name): + """ Unquote a string.""" + if name and name[0] == '"' and name[-1] == '"': + name = name[1:-1] + + return name + + def escaped_names(self, names): + return [self.escape_name(name) for name in names] + + def extend_database_names(self, databases): + self.databases.extend(databases) + + def extend_keywords(self, additional_keywords): + self.keywords.extend(additional_keywords) + self.all_completions.update(additional_keywords) + + def extend_schemata(self, schemata): + + # schemata is a list of schema names + schemata = self.escaped_names(schemata) + metadata = self.dbmetadata["tables"] + for schema in schemata: + metadata[schema] = {} + + # dbmetadata.values() are the 'tables' and 'functions' dicts + for metadata in self.dbmetadata.values(): + for schema in schemata: + metadata[schema] = {} + + self.all_completions.update(schemata) + + def extend_casing(self, words): + """extend casing data + + :return: + """ + # casing should be a dict {lowercasename:PreferredCasingName} + self.casing = dict((word.lower(), word) for word in words) + + def extend_relations(self, data, kind): + """extend metadata for tables or views. + + :param data: list of (schema_name, rel_name) tuples + :param kind: either 'tables' or 'views' + + :return: + + """ + + data = [self.escaped_names(d) for d in data] + + # dbmetadata['tables']['schema_name']['table_name'] should be an + # OrderedDict {column_name:ColumnMetaData}. + metadata = self.dbmetadata[kind] + for schema, relname in data: + try: + metadata[schema][relname] = OrderedDict() + except KeyError: + _logger.error( + "%r %r listed in unrecognized schema %r", kind, relname, schema + ) + self.all_completions.add(relname) + + def extend_columns(self, column_data, kind): + """extend column metadata. + + :param column_data: list of (schema_name, rel_name, column_name, + column_type, has_default, default) tuples + :param kind: either 'tables' or 'views' + + :return: + + """ + metadata = self.dbmetadata[kind] + for schema, relname, colname, datatype, has_default, default in column_data: + (schema, relname, colname) = self.escaped_names([schema, relname, colname]) + column = ColumnMetadata( + name=colname, + datatype=datatype, + has_default=has_default, + default=default, + ) + metadata[schema][relname][colname] = column + self.all_completions.add(colname) + + def extend_functions(self, func_data): + + # func_data is a list of function metadata namedtuples + + # dbmetadata['schema_name']['functions']['function_name'] should return + # the function metadata namedtuple for the corresponding function + metadata = self.dbmetadata["functions"] + + for f in func_data: + schema, func = self.escaped_names([f.schema_name, f.func_name]) + + if func in metadata[schema]: + metadata[schema][func].append(f) + else: + metadata[schema][func] = [f] + + self.all_completions.add(func) + + self._refresh_arg_list_cache() + + def _refresh_arg_list_cache(self): + # We keep a cache of {function_usage:{function_metadata: function_arg_list_string}} + # This is used when suggesting functions, to avoid the latency that would result + # if we'd recalculate the arg lists each time we suggest functions (in large DBs) + self._arg_list_cache = { + usage: { + meta: self._arg_list(meta, usage) + for sch, funcs in self.dbmetadata["functions"].items() + for func, metas in funcs.items() + for meta in metas + } + for usage in ("call", "call_display", "signature") + } + + def extend_foreignkeys(self, fk_data): + + # fk_data is a list of ForeignKey namedtuples, with fields + # parentschema, childschema, parenttable, childtable, + # parentcolumns, childcolumns + + # These are added as a list of ForeignKey namedtuples to the + # ColumnMetadata namedtuple for both the child and parent + meta = self.dbmetadata["tables"] + + for fk in fk_data: + e = self.escaped_names + parentschema, childschema = e([fk.parentschema, fk.childschema]) + parenttable, childtable = e([fk.parenttable, fk.childtable]) + childcol, parcol = e([fk.childcolumn, fk.parentcolumn]) + childcolmeta = meta[childschema][childtable][childcol] + parcolmeta = meta[parentschema][parenttable][parcol] + fk = ForeignKey( + parentschema, parenttable, parcol, childschema, childtable, childcol + ) + childcolmeta.foreignkeys.append((fk)) + parcolmeta.foreignkeys.append((fk)) + + def extend_datatypes(self, type_data): + + # dbmetadata['datatypes'][schema_name][type_name] should store type + # metadata, such as composite type field names. Currently, we're not + # storing any metadata beyond typename, so just store None + meta = self.dbmetadata["datatypes"] + + for t in type_data: + schema, type_name = self.escaped_names(t) + meta[schema][type_name] = None + self.all_completions.add(type_name) + + def extend_query_history(self, text, is_init=False): + if is_init: + # During completer initialization, only load keyword preferences, + # not names + self.prioritizer.update_keywords(text) + else: + self.prioritizer.update(text) + + def set_search_path(self, search_path): + self.search_path = self.escaped_names(search_path) + + def reset_completions(self): + self.databases = [] + self.special_commands = [] + self.search_path = [] + self.dbmetadata = {"tables": {}, "views": {}, "functions": {}, "datatypes": {}} + self.all_completions = set(self.keywords + self.functions) + + def find_matches(self, text, collection, mode="fuzzy", meta=None): + """Find completion matches for the given text. + + Given the user's input text and a collection of available + completions, find completions matching the last word of the + text. + + `collection` can be either a list of strings or a list of Candidate + namedtuples. + `mode` can be either 'fuzzy', or 'strict' + 'fuzzy': fuzzy matching, ties broken by name prevalance + `keyword`: start only matching, ties broken by keyword prevalance + + yields prompt_toolkit Completion instances for any matches found + in the collection of available completions. + + """ + if not collection: + return [] + prio_order = [ + "keyword", + "function", + "view", + "table", + "datatype", + "database", + "schema", + "column", + "table alias", + "join", + "name join", + "fk join", + "table format", + ] + type_priority = prio_order.index(meta) if meta in prio_order else -1 + text = last_word(text, include="most_punctuations").lower() + text_len = len(text) + + if text and text[0] == '"': + # text starts with double quote; user is manually escaping a name + # Match on everything that follows the double-quote. Note that + # text_len is calculated before removing the quote, so the + # Completion.position value is correct + text = text[1:] + + if mode == "fuzzy": + fuzzy = True + priority_func = self.prioritizer.name_count + else: + fuzzy = False + priority_func = self.prioritizer.keyword_count + + # Construct a `_match` function for either fuzzy or non-fuzzy matching + # The match function returns a 2-tuple used for sorting the matches, + # or None if the item doesn't match + # Note: higher priority values mean more important, so use negative + # signs to flip the direction of the tuple + if fuzzy: + regex = ".*?".join(map(re.escape, text)) + pat = re.compile("(%s)" % regex) + + def _match(item): + if item.lower()[: len(text) + 1] in (text, text + " "): + # Exact match of first word in suggestion + # This is to get exact alias matches to the top + # E.g. for input `e`, 'Entries E' should be on top + # (before e.g. `EndUsers EU`) + return float("Infinity"), -1 + r = pat.search(self.unescape_name(item.lower())) + if r: + return -len(r.group()), -r.start() + + else: + match_end_limit = len(text) + + def _match(item): + match_point = item.lower().find(text, 0, match_end_limit) + if match_point >= 0: + # Use negative infinity to force keywords to sort after all + # fuzzy matches + return -float("Infinity"), -match_point + + matches = [] + for cand in collection: + if isinstance(cand, _Candidate): + item, prio, display_meta, synonyms, prio2, display = cand + if display_meta is None: + display_meta = meta + syn_matches = (_match(x) for x in synonyms) + # Nones need to be removed to avoid max() crashing in Python 3 + syn_matches = [m for m in syn_matches if m] + sort_key = max(syn_matches) if syn_matches else None + else: + item, display_meta, prio, prio2, display = cand, meta, 0, 0, cand + sort_key = _match(cand) + + if sort_key: + if display_meta and len(display_meta) > 50: + # Truncate meta-text to 50 characters, if necessary + display_meta = display_meta[:47] + "..." + + # Lexical order of items in the collection, used for + # tiebreaking items with the same match group length and start + # position. Since we use *higher* priority to mean "more + # important," we use -ord(c) to prioritize "aa" > "ab" and end + # with 1 to prioritize shorter strings (ie "user" > "users"). + # We first do a case-insensitive sort and then a + # case-sensitive one as a tie breaker. + # We also use the unescape_name to make sure quoted names have + # the same priority as unquoted names. + lexical_priority = ( + tuple( + 0 if c in (" _") else -ord(c) + for c in self.unescape_name(item.lower()) + ) + + (1,) + + tuple(c for c in item) + ) + + item = self.case(item) + display = self.case(display) + priority = ( + sort_key, + type_priority, + prio, + priority_func(item), + prio2, + lexical_priority, + ) + matches.append( + Match( + completion=Completion( + text=item, + start_position=-text_len, + display_meta=display_meta, + display=display, + ), + priority=priority, + ) + ) + return matches + + def case(self, word): + return self.casing.get(word, word) + + def get_completions(self, document, complete_event, smart_completion=None): + word_before_cursor = document.get_word_before_cursor(WORD=True) + + if smart_completion is None: + smart_completion = self.smart_completion + + # If smart_completion is off then match any word that starts with + # 'word_before_cursor'. + if not smart_completion: + matches = self.find_matches( + word_before_cursor, self.all_completions, mode="strict" + ) + completions = [m.completion for m in matches] + return sorted(completions, key=operator.attrgetter("text")) + + matches = [] + suggestions = suggest_type(document.text, document.text_before_cursor) + + for suggestion in suggestions: + suggestion_type = type(suggestion) + _logger.debug("Suggestion type: %r", suggestion_type) + + # Map suggestion type to method + # e.g. 'table' -> self.get_table_matches + matcher = self.suggestion_matchers[suggestion_type] + matches.extend(matcher(self, suggestion, word_before_cursor)) + + # Sort matches so highest priorities are first + matches = sorted(matches, key=operator.attrgetter("priority"), reverse=True) + + return [m.completion for m in matches] + + def get_column_matches(self, suggestion, word_before_cursor): + tables = suggestion.table_refs + do_qualify = suggestion.qualifiable and { + "always": True, + "never": False, + "if_more_than_one_table": len(tables) > 1, + }[self.qualify_columns] + qualify = lambda col, tbl: ( + (tbl + "." + self.case(col)) if do_qualify else self.case(col) + ) + _logger.debug("Completion column scope: %r", tables) + scoped_cols = self.populate_scoped_cols(tables, suggestion.local_tables) + + def make_cand(name, ref): + synonyms = (name, generate_alias(self.case(name))) + return Candidate(qualify(name, ref), 0, "column", synonyms) + + def flat_cols(): + return [ + make_cand(c.name, t.ref) + for t, cols in scoped_cols.items() + for c in cols + ] + + if suggestion.require_last_table: + # require_last_table is used for 'tb11 JOIN tbl2 USING (...' which should + # suggest only columns that appear in the last table and one more + ltbl = tables[-1].ref + other_tbl_cols = set( + c.name for t, cs in scoped_cols.items() if t.ref != ltbl for c in cs + ) + scoped_cols = { + t: [col for col in cols if col.name in other_tbl_cols] + for t, cols in scoped_cols.items() + if t.ref == ltbl + } + lastword = last_word(word_before_cursor, include="most_punctuations") + if lastword == "*": + if suggestion.context == "insert": + + def filter(col): + if not col.has_default: + return True + return not any( + p.match(col.default) for p in self.insert_col_skip_patterns + ) + + scoped_cols = { + t: [col for col in cols if filter(col)] + for t, cols in scoped_cols.items() + } + if self.asterisk_column_order == "alphabetic": + for cols in scoped_cols.values(): + cols.sort(key=operator.attrgetter("name")) + if ( + lastword != word_before_cursor + and len(tables) == 1 + and word_before_cursor[-len(lastword) - 1] == "." + ): + # User typed x.*; replicate "x." for all columns except the + # first, which gets the original (as we only replace the "*"") + sep = ", " + word_before_cursor[:-1] + collist = sep.join(self.case(c.completion) for c in flat_cols()) + else: + collist = ", ".join( + qualify(c.name, t.ref) for t, cs in scoped_cols.items() for c in cs + ) + + return [ + Match( + completion=Completion( + collist, -1, display_meta="columns", display="*" + ), + priority=(1, 1, 1), + ) + ] + + return self.find_matches(word_before_cursor, flat_cols(), meta="column") + + def alias(self, tbl, tbls): + """Generate a unique table alias + tbl - name of the table to alias, quoted if it needs to be + tbls - TableReference iterable of tables already in query + """ + tbl = self.case(tbl) + tbls = set(normalize_ref(t.ref) for t in tbls) + if self.generate_aliases: + tbl = generate_alias(self.unescape_name(tbl)) + if normalize_ref(tbl) not in tbls: + return tbl + elif tbl[0] == '"': + aliases = ('"' + tbl[1:-1] + str(i) + '"' for i in count(2)) + else: + aliases = (tbl + str(i) for i in count(2)) + return next(a for a in aliases if normalize_ref(a) not in tbls) + + def get_join_matches(self, suggestion, word_before_cursor): + tbls = suggestion.table_refs + cols = self.populate_scoped_cols(tbls) + # Set up some data structures for efficient access + qualified = dict((normalize_ref(t.ref), t.schema) for t in tbls) + ref_prio = dict((normalize_ref(t.ref), n) for n, t in enumerate(tbls)) + refs = set(normalize_ref(t.ref) for t in tbls) + other_tbls = set((t.schema, t.name) for t in list(cols)[:-1]) + joins = [] + # Iterate over FKs in existing tables to find potential joins + fks = ( + (fk, rtbl, rcol) + for rtbl, rcols in cols.items() + for rcol in rcols + for fk in rcol.foreignkeys + ) + col = namedtuple("col", "schema tbl col") + for fk, rtbl, rcol in fks: + right = col(rtbl.schema, rtbl.name, rcol.name) + child = col(fk.childschema, fk.childtable, fk.childcolumn) + parent = col(fk.parentschema, fk.parenttable, fk.parentcolumn) + left = child if parent == right else parent + if suggestion.schema and left.schema != suggestion.schema: + continue + c = self.case + if self.generate_aliases or normalize_ref(left.tbl) in refs: + lref = self.alias(left.tbl, suggestion.table_refs) + join = "{0} {4} ON {4}.{1} = {2}.{3}".format( + c(left.tbl), c(left.col), rtbl.ref, c(right.col), lref + ) + else: + join = "{0} ON {0}.{1} = {2}.{3}".format( + c(left.tbl), c(left.col), rtbl.ref, c(right.col) + ) + alias = generate_alias(self.case(left.tbl)) + synonyms = [ + join, + "{0} ON {0}.{1} = {2}.{3}".format( + alias, c(left.col), rtbl.ref, c(right.col) + ), + ] + # Schema-qualify if (1) new table in same schema as old, and old + # is schema-qualified, or (2) new in other schema, except public + if not suggestion.schema and ( + qualified[normalize_ref(rtbl.ref)] + and left.schema == right.schema + or left.schema not in (right.schema, "public") + ): + join = left.schema + "." + join + prio = ref_prio[normalize_ref(rtbl.ref)] * 2 + ( + 0 if (left.schema, left.tbl) in other_tbls else 1 + ) + joins.append(Candidate(join, prio, "join", synonyms=synonyms)) + + return self.find_matches(word_before_cursor, joins, meta="join") + + def get_join_condition_matches(self, suggestion, word_before_cursor): + col = namedtuple("col", "schema tbl col") + tbls = self.populate_scoped_cols(suggestion.table_refs).items + cols = [(t, c) for t, cs in tbls() for c in cs] + try: + lref = (suggestion.parent or suggestion.table_refs[-1]).ref + ltbl, lcols = [(t, cs) for (t, cs) in tbls() if t.ref == lref][-1] + except IndexError: # The user typed an incorrect table qualifier + return [] + conds, found_conds = [], set() + + def add_cond(lcol, rcol, rref, prio, meta): + prefix = "" if suggestion.parent else ltbl.ref + "." + case = self.case + cond = prefix + case(lcol) + " = " + rref + "." + case(rcol) + if cond not in found_conds: + found_conds.add(cond) + conds.append(Candidate(cond, prio + ref_prio[rref], meta)) + + def list_dict(pairs): # Turns [(a, b), (a, c)] into {a: [b, c]} + d = defaultdict(list) + for pair in pairs: + d[pair[0]].append(pair[1]) + return d + + # Tables that are closer to the cursor get higher prio + ref_prio = dict((tbl.ref, num) for num, tbl in enumerate(suggestion.table_refs)) + # Map (schema, table, col) to tables + coldict = list_dict( + ((t.schema, t.name, c.name), t) for t, c in cols if t.ref != lref + ) + # For each fk from the left table, generate a join condition if + # the other table is also in the scope + fks = ((fk, lcol.name) for lcol in lcols for fk in lcol.foreignkeys) + for fk, lcol in fks: + left = col(ltbl.schema, ltbl.name, lcol) + child = col(fk.childschema, fk.childtable, fk.childcolumn) + par = col(fk.parentschema, fk.parenttable, fk.parentcolumn) + left, right = (child, par) if left == child else (par, child) + for rtbl in coldict[right]: + add_cond(left.col, right.col, rtbl.ref, 2000, "fk join") + # For name matching, use a {(colname, coltype): TableReference} dict + coltyp = namedtuple("coltyp", "name datatype") + col_table = list_dict((coltyp(c.name, c.datatype), t) for t, c in cols) + # Find all name-match join conditions + for c in (coltyp(c.name, c.datatype) for c in lcols): + for rtbl in (t for t in col_table[c] if t.ref != ltbl.ref): + prio = 1000 if c.datatype in ("integer", "bigint", "smallint") else 0 + add_cond(c.name, c.name, rtbl.ref, prio, "name join") + + return self.find_matches(word_before_cursor, conds, meta="join") + + def get_function_matches(self, suggestion, word_before_cursor, alias=False): + + if suggestion.usage == "from": + # Only suggest functions allowed in FROM clause + + def filt(f): + return ( + not f.is_aggregate + and not f.is_window + and not f.is_extension + and (f.is_public or f.schema_name == suggestion.schema) + ) + + else: + alias = False + + def filt(f): + return not f.is_extension and ( + f.is_public or f.schema_name == suggestion.schema + ) + + arg_mode = {"signature": "signature", "special": None}.get( + suggestion.usage, "call" + ) + + # Function overloading means we way have multiple functions of the same + # name at this point, so keep unique names only + all_functions = self.populate_functions(suggestion.schema, filt) + funcs = set( + self._make_cand(f, alias, suggestion, arg_mode) for f in all_functions + ) + + matches = self.find_matches(word_before_cursor, funcs, meta="function") + + if not suggestion.schema and not suggestion.usage: + # also suggest hardcoded functions using startswith matching + predefined_funcs = self.find_matches( + word_before_cursor, self.functions, mode="strict", meta="function" + ) + matches.extend(predefined_funcs) + + return matches + + def get_schema_matches(self, suggestion, word_before_cursor): + schema_names = self.dbmetadata["tables"].keys() + + # Unless we're sure the user really wants them, hide schema names + # starting with pg_, which are mostly temporary schemas + if not word_before_cursor.startswith("pg_"): + schema_names = [s for s in schema_names if not s.startswith("pg_")] + + if suggestion.quoted: + schema_names = [self.escape_schema(s) for s in schema_names] + + return self.find_matches(word_before_cursor, schema_names, meta="schema") + + def get_from_clause_item_matches(self, suggestion, word_before_cursor): + alias = self.generate_aliases + s = suggestion + t_sug = Table(s.schema, s.table_refs, s.local_tables) + v_sug = View(s.schema, s.table_refs) + f_sug = Function(s.schema, s.table_refs, usage="from") + return ( + self.get_table_matches(t_sug, word_before_cursor, alias) + + self.get_view_matches(v_sug, word_before_cursor, alias) + + self.get_function_matches(f_sug, word_before_cursor, alias) + ) + + def _arg_list(self, func, usage): + """Returns a an arg list string, e.g. `(_foo:=23)` for a func. + + :param func is a FunctionMetadata object + :param usage is 'call', 'call_display' or 'signature' + + """ + template = { + "call": self.call_arg_style, + "call_display": self.call_arg_display_style, + "signature": self.signature_arg_style, + }[usage] + args = func.args() + if not template: + return "()" + elif usage == "call" and len(args) < 2: + return "()" + elif usage == "call" and func.has_variadic(): + return "()" + multiline = usage == "call" and len(args) > self.call_arg_oneliner_max + max_arg_len = max(len(a.name) for a in args) if multiline else 0 + args = ( + self._format_arg(template, arg, arg_num + 1, max_arg_len) + for arg_num, arg in enumerate(args) + ) + if multiline: + return "(" + ",".join("\n " + a for a in args if a) + "\n)" + else: + return "(" + ", ".join(a for a in args if a) + ")" + + def _format_arg(self, template, arg, arg_num, max_arg_len): + if not template: + return None + if arg.has_default: + arg_default = "NULL" if arg.default is None else arg.default + # Remove trailing ::(schema.)type + arg_default = arg_default_type_strip_regex.sub("", arg_default) + else: + arg_default = "" + return template.format( + max_arg_len=max_arg_len, + arg_name=self.case(arg.name), + arg_num=arg_num, + arg_type=arg.datatype, + arg_default=arg_default, + ) + + def _make_cand(self, tbl, do_alias, suggestion, arg_mode=None): + """Returns a Candidate namedtuple. + + :param tbl is a SchemaObject + :param arg_mode determines what type of arg list to suffix for functions. + Possible values: call, signature + + """ + cased_tbl = self.case(tbl.name) + if do_alias: + alias = self.alias(cased_tbl, suggestion.table_refs) + synonyms = (cased_tbl, generate_alias(cased_tbl)) + maybe_alias = (" " + alias) if do_alias else "" + maybe_schema = (self.case(tbl.schema) + ".") if tbl.schema else "" + suffix = self._arg_list_cache[arg_mode][tbl.meta] if arg_mode else "" + if arg_mode == "call": + display_suffix = self._arg_list_cache["call_display"][tbl.meta] + elif arg_mode == "signature": + display_suffix = self._arg_list_cache["signature"][tbl.meta] + else: + display_suffix = "" + item = maybe_schema + cased_tbl + suffix + maybe_alias + display = maybe_schema + cased_tbl + display_suffix + maybe_alias + prio2 = 0 if tbl.schema else 1 + return Candidate(item, synonyms=synonyms, prio2=prio2, display=display) + + def get_table_matches(self, suggestion, word_before_cursor, alias=False): + tables = self.populate_schema_objects(suggestion.schema, "tables") + tables.extend(SchemaObject(tbl.name) for tbl in suggestion.local_tables) + + # Unless we're sure the user really wants them, don't suggest the + # pg_catalog tables that are implicitly on the search path + if not suggestion.schema and (not word_before_cursor.startswith("pg_")): + tables = [t for t in tables if not t.name.startswith("pg_")] + tables = [self._make_cand(t, alias, suggestion) for t in tables] + return self.find_matches(word_before_cursor, tables, meta="table") + + def get_table_formats(self, _, word_before_cursor): + formats = TabularOutputFormatter().supported_formats + return self.find_matches(word_before_cursor, formats, meta="table format") + + def get_view_matches(self, suggestion, word_before_cursor, alias=False): + views = self.populate_schema_objects(suggestion.schema, "views") + + if not suggestion.schema and (not word_before_cursor.startswith("pg_")): + views = [v for v in views if not v.name.startswith("pg_")] + views = [self._make_cand(v, alias, suggestion) for v in views] + return self.find_matches(word_before_cursor, views, meta="view") + + def get_alias_matches(self, suggestion, word_before_cursor): + aliases = suggestion.aliases + return self.find_matches(word_before_cursor, aliases, meta="table alias") + + def get_database_matches(self, _, word_before_cursor): + return self.find_matches(word_before_cursor, self.databases, meta="database") + + def get_keyword_matches(self, suggestion, word_before_cursor): + keywords = self.keywords_tree.keys() + # Get well known following keywords for the last token. If any, narrow + # candidates to this list. + next_keywords = self.keywords_tree.get(suggestion.last_token, []) + if next_keywords: + keywords = next_keywords + + casing = self.keyword_casing + if casing == "auto": + if word_before_cursor and word_before_cursor[-1].islower(): + casing = "lower" + else: + casing = "upper" + + if casing == "upper": + keywords = [k.upper() for k in keywords] + else: + keywords = [k.lower() for k in keywords] + + return self.find_matches( + word_before_cursor, keywords, mode="strict", meta="keyword" + ) + + def get_path_matches(self, _, word_before_cursor): + completer = PathCompleter(expanduser=True) + document = Document( + text=word_before_cursor, cursor_position=len(word_before_cursor) + ) + for c in completer.get_completions(document, None): + yield Match(completion=c, priority=(0,)) + + def get_special_matches(self, _, word_before_cursor): + if not self.pgspecial: + return [] + + commands = self.pgspecial.commands + cmds = commands.keys() + cmds = [Candidate(cmd, 0, commands[cmd].description) for cmd in cmds] + return self.find_matches(word_before_cursor, cmds, mode="strict") + + def get_datatype_matches(self, suggestion, word_before_cursor): + # suggest custom datatypes + types = self.populate_schema_objects(suggestion.schema, "datatypes") + types = [self._make_cand(t, False, suggestion) for t in types] + matches = self.find_matches(word_before_cursor, types, meta="datatype") + + if not suggestion.schema: + # Also suggest hardcoded types + matches.extend( + self.find_matches( + word_before_cursor, self.datatypes, mode="strict", meta="datatype" + ) + ) + + return matches + + def get_namedquery_matches(self, _, word_before_cursor): + return self.find_matches( + word_before_cursor, NamedQueries.instance.list(), meta="named query" + ) + + suggestion_matchers = { + FromClauseItem: get_from_clause_item_matches, + JoinCondition: get_join_condition_matches, + Join: get_join_matches, + Column: get_column_matches, + Function: get_function_matches, + Schema: get_schema_matches, + Table: get_table_matches, + TableFormat: get_table_formats, + View: get_view_matches, + Alias: get_alias_matches, + Database: get_database_matches, + Keyword: get_keyword_matches, + Special: get_special_matches, + Datatype: get_datatype_matches, + NamedQuery: get_namedquery_matches, + Path: get_path_matches, + } + + def populate_scoped_cols(self, scoped_tbls, local_tbls=()): + """Find all columns in a set of scoped_tables. + + :param scoped_tbls: list of TableReference namedtuples + :param local_tbls: tuple(TableMetadata) + :return: {TableReference:{colname:ColumnMetaData}} + + """ + ctes = dict((normalize_ref(t.name), t.columns) for t in local_tbls) + columns = OrderedDict() + meta = self.dbmetadata + + def addcols(schema, rel, alias, reltype, cols): + tbl = TableReference(schema, rel, alias, reltype == "functions") + if tbl not in columns: + columns[tbl] = [] + columns[tbl].extend(cols) + + for tbl in scoped_tbls: + # Local tables should shadow database tables + if tbl.schema is None and normalize_ref(tbl.name) in ctes: + cols = ctes[normalize_ref(tbl.name)] + addcols(None, tbl.name, "CTE", tbl.alias, cols) + continue + schemas = [tbl.schema] if tbl.schema else self.search_path + for schema in schemas: + relname = self.escape_name(tbl.name) + schema = self.escape_name(schema) + if tbl.is_function: + # Return column names from a set-returning function + # Get an array of FunctionMetadata objects + functions = meta["functions"].get(schema, {}).get(relname) + for func in functions or []: + # func is a FunctionMetadata object + cols = func.fields() + addcols(schema, relname, tbl.alias, "functions", cols) + else: + for reltype in ("tables", "views"): + cols = meta[reltype].get(schema, {}).get(relname) + if cols: + cols = cols.values() + addcols(schema, relname, tbl.alias, reltype, cols) + break + + return columns + + def _get_schemas(self, obj_typ, schema): + """Returns a list of schemas from which to suggest objects. + + :param schema is the schema qualification input by the user (if any) + + """ + metadata = self.dbmetadata[obj_typ] + if schema: + schema = self.escape_name(schema) + return [schema] if schema in metadata else [] + return self.search_path if self.search_path_filter else metadata.keys() + + def _maybe_schema(self, schema, parent): + return None if parent or schema in self.search_path else schema + + def populate_schema_objects(self, schema, obj_type): + """Returns a list of SchemaObjects representing tables or views. + + :param schema is the schema qualification input by the user (if any) + + """ + + return [ + SchemaObject( + name=obj, schema=(self._maybe_schema(schema=sch, parent=schema)) + ) + for sch in self._get_schemas(obj_type, schema) + for obj in self.dbmetadata[obj_type][sch].keys() + ] + + def populate_functions(self, schema, filter_func): + """Returns a list of function SchemaObjects. + + :param filter_func is a function that accepts a FunctionMetadata + namedtuple and returns a boolean indicating whether that + function should be kept or discarded + + """ + + # Because of multiple dispatch, we can have multiple functions + # with the same name, which is why `for meta in metas` is necessary + # in the comprehensions below + return [ + SchemaObject( + name=func, + schema=(self._maybe_schema(schema=sch, parent=schema)), + meta=meta, + ) + for sch in self._get_schemas("functions", schema) + for (func, metas) in self.dbmetadata["functions"][sch].items() + for meta in metas + if filter_func(meta) + ] diff --git a/pgcli/pgexecute.py b/pgcli/pgexecute.py new file mode 100644 index 0000000..d34bf26 --- /dev/null +++ b/pgcli/pgexecute.py @@ -0,0 +1,857 @@ +import traceback +import logging +import psycopg2 +import psycopg2.extras +import psycopg2.errorcodes +import psycopg2.extensions as ext +import sqlparse +import pgspecial as special +import select +from psycopg2.extensions import POLL_OK, POLL_READ, POLL_WRITE, make_dsn +from .packages.parseutils.meta import FunctionMetadata, ForeignKey + +_logger = logging.getLogger(__name__) + +# Cast all database input to unicode automatically. +# See http://initd.org/psycopg/docs/usage.html#unicode-handling for more info. +ext.register_type(ext.UNICODE) +ext.register_type(ext.UNICODEARRAY) +ext.register_type(ext.new_type((705,), "UNKNOWN", ext.UNICODE)) +# See https://github.com/dbcli/pgcli/issues/426 for more details. +# This registers a unicode type caster for datatype 'RECORD'. +ext.register_type(ext.new_type((2249,), "RECORD", ext.UNICODE)) + +# Cast bytea fields to text. By default, this will render as hex strings with +# Postgres 9+ and as escaped binary in earlier versions. +ext.register_type(ext.new_type((17,), "BYTEA_TEXT", psycopg2.STRING)) + +# TODO: Get default timeout from pgclirc? +_WAIT_SELECT_TIMEOUT = 1 + + +def _wait_select(conn): + """ + copy-pasted from psycopg2.extras.wait_select + the default implementation doesn't define a timeout in the select calls + """ + while 1: + try: + state = conn.poll() + if state == POLL_OK: + break + elif state == POLL_READ: + select.select([conn.fileno()], [], [], _WAIT_SELECT_TIMEOUT) + elif state == POLL_WRITE: + select.select([], [conn.fileno()], [], _WAIT_SELECT_TIMEOUT) + else: + raise conn.OperationalError("bad state from poll: %s" % state) + except KeyboardInterrupt: + conn.cancel() + # the loop will be broken by a server error + continue + except select.error as e: + errno = e.args[0] + if errno != 4: + raise + + +# When running a query, make pressing CTRL+C raise a KeyboardInterrupt +# See http://initd.org/psycopg/articles/2014/07/20/cancelling-postgresql-statements-python/ +# See also https://github.com/psycopg/psycopg2/issues/468 +ext.set_wait_callback(_wait_select) + + +def register_date_typecasters(connection): + """ + Casts date and timestamp values to string, resolves issues with out of + range dates (e.g. BC) which psycopg2 can't handle + """ + + def cast_date(value, cursor): + return value + + cursor = connection.cursor() + cursor.execute("SELECT NULL::date") + date_oid = cursor.description[0][1] + cursor.execute("SELECT NULL::timestamp") + timestamp_oid = cursor.description[0][1] + cursor.execute("SELECT NULL::timestamp with time zone") + timestamptz_oid = cursor.description[0][1] + oids = (date_oid, timestamp_oid, timestamptz_oid) + new_type = psycopg2.extensions.new_type(oids, "DATE", cast_date) + psycopg2.extensions.register_type(new_type) + + +def register_json_typecasters(conn, loads_fn): + """Set the function for converting JSON data for a connection. + + Use the supplied function to decode JSON data returned from the database + via the given connection. The function should accept a single argument of + the data as a string encoded in the database's character encoding. + psycopg2's default handler for JSON data is json.loads. + http://initd.org/psycopg/docs/extras.html#json-adaptation + + This function attempts to register the typecaster for both JSON and JSONB + types. + + Returns a set that is a subset of {'json', 'jsonb'} indicating which types + (if any) were successfully registered. + """ + available = set() + + for name in ["json", "jsonb"]: + try: + psycopg2.extras.register_json(conn, loads=loads_fn, name=name) + available.add(name) + except psycopg2.ProgrammingError: + pass + + return available + + +def register_hstore_typecaster(conn): + """ + Instead of using register_hstore() which converts hstore into a python + dict, we query the 'oid' of hstore which will be different for each + database and register a type caster that converts it to unicode. + http://initd.org/psycopg/docs/extras.html#psycopg2.extras.register_hstore + """ + with conn.cursor() as cur: + try: + cur.execute( + "select t.oid FROM pg_type t WHERE t.typname = 'hstore' and t.typisdefined" + ) + oid = cur.fetchone()[0] + ext.register_type(ext.new_type((oid,), "HSTORE", ext.UNICODE)) + except Exception: + pass + + +class PGExecute(object): + + # The boolean argument to the current_schemas function indicates whether + # implicit schemas, e.g. pg_catalog + search_path_query = """ + SELECT * FROM unnest(current_schemas(true))""" + + schemata_query = """ + SELECT nspname + FROM pg_catalog.pg_namespace + ORDER BY 1 """ + + tables_query = """ + SELECT n.nspname schema_name, + c.relname table_name + FROM pg_catalog.pg_class c + LEFT JOIN pg_catalog.pg_namespace n + ON n.oid = c.relnamespace + WHERE c.relkind = ANY(%s) + ORDER BY 1,2;""" + + databases_query = """ + SELECT d.datname + FROM pg_catalog.pg_database d + ORDER BY 1""" + + full_databases_query = """ + SELECT d.datname as "Name", + pg_catalog.pg_get_userbyid(d.datdba) as "Owner", + pg_catalog.pg_encoding_to_char(d.encoding) as "Encoding", + d.datcollate as "Collate", + d.datctype as "Ctype", + pg_catalog.array_to_string(d.datacl, E'\n') AS "Access privileges" + FROM pg_catalog.pg_database d + ORDER BY 1""" + + socket_directory_query = """ + SELECT setting + FROM pg_settings + WHERE name = 'unix_socket_directories' + """ + + view_definition_query = """ + WITH v AS (SELECT %s::pg_catalog.regclass::pg_catalog.oid AS v_oid) + SELECT nspname, relname, relkind, + pg_catalog.pg_get_viewdef(c.oid, true), + array_remove(array_remove(c.reloptions,'check_option=local'), + 'check_option=cascaded') AS reloptions, + CASE + WHEN 'check_option=local' = ANY (c.reloptions) THEN 'LOCAL'::text + WHEN 'check_option=cascaded' = ANY (c.reloptions) THEN 'CASCADED'::text + ELSE NULL + END AS checkoption + FROM pg_catalog.pg_class c + LEFT JOIN pg_catalog.pg_namespace n ON (c.relnamespace = n.oid) + JOIN v ON (c.oid = v.v_oid)""" + + function_definition_query = """ + WITH f AS + (SELECT %s::pg_catalog.regproc::pg_catalog.oid AS f_oid) + SELECT pg_catalog.pg_get_functiondef(f.f_oid) + FROM f""" + + version_query = "SELECT version();" + + def __init__( + self, + database=None, + user=None, + password=None, + host=None, + port=None, + dsn=None, + **kwargs, + ): + self._conn_params = {} + self.conn = None + self.dbname = None + self.user = None + self.password = None + self.host = None + self.port = None + self.server_version = None + self.extra_args = None + self.connect(database, user, password, host, port, dsn, **kwargs) + self.reset_expanded = None + + def copy(self): + """Returns a clone of the current executor.""" + return self.__class__(**self._conn_params) + + def connect( + self, + database=None, + user=None, + password=None, + host=None, + port=None, + dsn=None, + **kwargs, + ): + + conn_params = self._conn_params.copy() + + new_params = { + "database": database, + "user": user, + "password": password, + "host": host, + "port": port, + "dsn": dsn, + } + new_params.update(kwargs) + + if new_params["dsn"]: + new_params = {"dsn": new_params["dsn"], "password": new_params["password"]} + + if new_params["password"]: + new_params["dsn"] = make_dsn( + new_params["dsn"], password=new_params.pop("password") + ) + + conn_params.update({k: v for k, v in new_params.items() if v}) + + conn = psycopg2.connect(**conn_params) + cursor = conn.cursor() + conn.set_client_encoding("utf8") + + self._conn_params = conn_params + if self.conn: + self.conn.close() + self.conn = conn + self.conn.autocommit = True + + # When we connect using a DSN, we don't really know what db, + # user, etc. we connected to. Let's read it. + # Note: moved this after setting autocommit because of #664. + libpq_version = psycopg2.__libpq_version__ + dsn_parameters = {} + if libpq_version >= 93000: + # use actual connection info from psycopg2.extensions.Connection.info + # as libpq_version > 9.3 is available and required dependency + dsn_parameters = conn.info.dsn_parameters + else: + try: + dsn_parameters = conn.get_dsn_parameters() + except Exception as x: + # https://github.com/dbcli/pgcli/issues/1110 + # PQconninfo not available in libpq < 9.3 + _logger.info("Exception in get_dsn_parameters: %r", x) + + if dsn_parameters: + self.dbname = dsn_parameters.get("dbname") + self.user = dsn_parameters.get("user") + self.host = dsn_parameters.get("host") + self.port = dsn_parameters.get("port") + else: + self.dbname = conn_params.get("database") + self.user = conn_params.get("user") + self.host = conn_params.get("host") + self.port = conn_params.get("port") + + self.password = password + self.extra_args = kwargs + + if not self.host: + self.host = self.get_socket_directory() + + pid = self._select_one(cursor, "select pg_backend_pid()")[0] + self.pid = pid + self.superuser = conn.get_parameter_status("is_superuser") in ("on", "1") + self.server_version = conn.get_parameter_status("server_version") + + register_date_typecasters(conn) + register_json_typecasters(self.conn, self._json_typecaster) + register_hstore_typecaster(self.conn) + + @property + def short_host(self): + if "," in self.host: + host, _, _ = self.host.partition(",") + else: + host = self.host + short_host, _, _ = host.partition(".") + return short_host + + def _select_one(self, cur, sql): + """ + Helper method to run a select and retrieve a single field value + :param cur: cursor + :param sql: string + :return: string + """ + cur.execute(sql) + return cur.fetchone() + + def _json_typecaster(self, json_data): + """Interpret incoming JSON data as a string. + + The raw data is decoded using the connection's encoding, which defaults + to the database's encoding. + + See http://initd.org/psycopg/docs/connection.html#connection.encoding + """ + + return json_data + + def failed_transaction(self): + status = self.conn.get_transaction_status() + return status == ext.TRANSACTION_STATUS_INERROR + + def valid_transaction(self): + status = self.conn.get_transaction_status() + return ( + status == ext.TRANSACTION_STATUS_ACTIVE + or status == ext.TRANSACTION_STATUS_INTRANS + ) + + def run( + self, statement, pgspecial=None, exception_formatter=None, on_error_resume=False + ): + """Execute the sql in the database and return the results. + + :param statement: A string containing one or more sql statements + :param pgspecial: PGSpecial object + :param exception_formatter: A callable that accepts an Exception and + returns a formatted (title, rows, headers, status) tuple that can + act as a query result. If an exception_formatter is not supplied, + psycopg2 exceptions are always raised. + :param on_error_resume: Bool. If true, queries following an exception + (assuming exception_formatter has been supplied) continue to + execute. + + :return: Generator yielding tuples containing + (title, rows, headers, status, query, success, is_special) + """ + + # Remove spaces and EOL + statement = statement.strip() + if not statement: # Empty string + yield (None, None, None, None, statement, False, False) + + # Split the sql into separate queries and run each one. + for sql in sqlparse.split(statement): + # Remove spaces, eol and semi-colons. + sql = sql.rstrip(";") + sql = sqlparse.format(sql, strip_comments=True).strip() + if not sql: + continue + try: + if pgspecial: + # \G is treated specially since we have to set the expanded output. + if sql.endswith("\\G"): + if not pgspecial.expanded_output: + pgspecial.expanded_output = True + self.reset_expanded = True + sql = sql[:-2].strip() + + # First try to run each query as special + _logger.debug("Trying a pgspecial command. sql: %r", sql) + try: + cur = self.conn.cursor() + except psycopg2.InterfaceError: + # edge case when connection is already closed, but we + # don't need cursor for special_cmd.arg_type == NO_QUERY. + # See https://github.com/dbcli/pgcli/issues/1014. + cur = None + try: + for result in pgspecial.execute(cur, sql): + # e.g. execute_from_file already appends these + if len(result) < 7: + yield result + (sql, True, True) + else: + yield result + continue + except special.CommandNotFound: + pass + + # Not a special command, so execute as normal sql + yield self.execute_normal_sql(sql) + (sql, True, False) + except psycopg2.DatabaseError as e: + _logger.error("sql: %r, error: %r", sql, e) + _logger.error("traceback: %r", traceback.format_exc()) + + if self._must_raise(e) or not exception_formatter: + raise + + yield None, None, None, exception_formatter(e), sql, False, False + + if not on_error_resume: + break + finally: + if self.reset_expanded: + pgspecial.expanded_output = False + self.reset_expanded = None + + def _must_raise(self, e): + """Return true if e is an error that should not be caught in ``run``. + + An uncaught error will prompt the user to reconnect; as long as we + detect that the connection is stil open, we catch the error, as + reconnecting won't solve that problem. + + :param e: DatabaseError. An exception raised while executing a query. + + :return: Bool. True if ``run`` must raise this exception. + + """ + return self.conn.closed != 0 + + def execute_normal_sql(self, split_sql): + """Returns tuple (title, rows, headers, status)""" + _logger.debug("Regular sql statement. sql: %r", split_sql) + cur = self.conn.cursor() + cur.execute(split_sql) + + # conn.notices persist between queies, we use pop to clear out the list + title = "" + while len(self.conn.notices) > 0: + title = self.conn.notices.pop() + title + + # cur.description will be None for operations that do not return + # rows. + if cur.description: + headers = [x[0] for x in cur.description] + return title, cur, headers, cur.statusmessage + else: + _logger.debug("No rows in result.") + return title, None, None, cur.statusmessage + + def search_path(self): + """Returns the current search path as a list of schema names""" + + try: + with self.conn.cursor() as cur: + _logger.debug("Search path query. sql: %r", self.search_path_query) + cur.execute(self.search_path_query) + return [x[0] for x in cur.fetchall()] + except psycopg2.ProgrammingError: + fallback = "SELECT * FROM current_schemas(true)" + with self.conn.cursor() as cur: + _logger.debug("Search path query. sql: %r", fallback) + cur.execute(fallback) + return cur.fetchone()[0] + + def view_definition(self, spec): + """Returns the SQL defining views described by `spec`""" + + template = "CREATE OR REPLACE {6} VIEW {0}.{1} AS \n{3}" + # 2: relkind, v or m (materialized) + # 4: reloptions, null + # 5: checkoption: local or cascaded + with self.conn.cursor() as cur: + sql = self.view_definition_query + _logger.debug("View Definition Query. sql: %r\nspec: %r", sql, spec) + try: + cur.execute(sql, (spec,)) + except psycopg2.ProgrammingError: + raise RuntimeError("View {} does not exist.".format(spec)) + result = cur.fetchone() + view_type = "MATERIALIZED" if result[2] == "m" else "" + return template.format(*result + (view_type,)) + + def function_definition(self, spec): + """Returns the SQL defining functions described by `spec`""" + + with self.conn.cursor() as cur: + sql = self.function_definition_query + _logger.debug("Function Definition Query. sql: %r\nspec: %r", sql, spec) + try: + cur.execute(sql, (spec,)) + result = cur.fetchone() + return result[0] + except psycopg2.ProgrammingError: + raise RuntimeError("Function {} does not exist.".format(spec)) + + def schemata(self): + """Returns a list of schema names in the database""" + + with self.conn.cursor() as cur: + _logger.debug("Schemata Query. sql: %r", self.schemata_query) + cur.execute(self.schemata_query) + return [x[0] for x in cur.fetchall()] + + def _relations(self, kinds=("r", "p", "f", "v", "m")): + """Get table or view name metadata + + :param kinds: list of postgres relkind filters: + 'r' - table + 'p' - partitioned table + 'f' - foreign table + 'v' - view + 'm' - materialized view + :return: (schema_name, rel_name) tuples + """ + + with self.conn.cursor() as cur: + sql = cur.mogrify(self.tables_query, [kinds]) + _logger.debug("Tables Query. sql: %r", sql) + cur.execute(sql) + for row in cur: + yield row + + def tables(self): + """Yields (schema_name, table_name) tuples""" + for row in self._relations(kinds=["r", "p", "f"]): + yield row + + def views(self): + """Yields (schema_name, view_name) tuples. + + Includes both views and and materialized views + """ + for row in self._relations(kinds=["v", "m"]): + yield row + + def _columns(self, kinds=("r", "p", "f", "v", "m")): + """Get column metadata for tables and views + + :param kinds: kinds: list of postgres relkind filters: + 'r' - table + 'p' - partitioned table + 'f' - foreign table + 'v' - view + 'm' - materialized view + :return: list of (schema_name, relation_name, column_name, column_type) tuples + """ + + if self.conn.server_version >= 80400: + columns_query = """ + SELECT nsp.nspname schema_name, + cls.relname table_name, + att.attname column_name, + att.atttypid::regtype::text type_name, + att.atthasdef AS has_default, + pg_catalog.pg_get_expr(def.adbin, def.adrelid, true) as default + FROM pg_catalog.pg_attribute att + INNER JOIN pg_catalog.pg_class cls + ON att.attrelid = cls.oid + INNER JOIN pg_catalog.pg_namespace nsp + ON cls.relnamespace = nsp.oid + LEFT OUTER JOIN pg_attrdef def + ON def.adrelid = att.attrelid + AND def.adnum = att.attnum + WHERE cls.relkind = ANY(%s) + AND NOT att.attisdropped + AND att.attnum > 0 + ORDER BY 1, 2, att.attnum""" + else: + columns_query = """ + SELECT nsp.nspname schema_name, + cls.relname table_name, + att.attname column_name, + typ.typname type_name, + NULL AS has_default, + NULL AS default + FROM pg_catalog.pg_attribute att + INNER JOIN pg_catalog.pg_class cls + ON att.attrelid = cls.oid + INNER JOIN pg_catalog.pg_namespace nsp + ON cls.relnamespace = nsp.oid + INNER JOIN pg_catalog.pg_type typ + ON typ.oid = att.atttypid + WHERE cls.relkind = ANY(%s) + AND NOT att.attisdropped + AND att.attnum > 0 + ORDER BY 1, 2, att.attnum""" + + with self.conn.cursor() as cur: + sql = cur.mogrify(columns_query, [kinds]) + _logger.debug("Columns Query. sql: %r", sql) + cur.execute(sql) + for row in cur: + yield row + + def table_columns(self): + for row in self._columns(kinds=["r", "p", "f"]): + yield row + + def view_columns(self): + for row in self._columns(kinds=["v", "m"]): + yield row + + def databases(self): + with self.conn.cursor() as cur: + _logger.debug("Databases Query. sql: %r", self.databases_query) + cur.execute(self.databases_query) + return [x[0] for x in cur.fetchall()] + + def full_databases(self): + with self.conn.cursor() as cur: + _logger.debug("Databases Query. sql: %r", self.full_databases_query) + cur.execute(self.full_databases_query) + headers = [x[0] for x in cur.description] + return cur.fetchall(), headers, cur.statusmessage + + def get_socket_directory(self): + with self.conn.cursor() as cur: + _logger.debug( + "Socket directory Query. sql: %r", self.socket_directory_query + ) + cur.execute(self.socket_directory_query) + result = cur.fetchone() + return result[0] if result else "" + + def foreignkeys(self): + """Yields ForeignKey named tuples""" + + if self.conn.server_version < 90000: + return + + with self.conn.cursor() as cur: + query = """ + SELECT s_p.nspname AS parentschema, + t_p.relname AS parenttable, + unnest(( + select + array_agg(attname ORDER BY i) + from + (select unnest(confkey) as attnum, generate_subscripts(confkey, 1) as i) x + JOIN pg_catalog.pg_attribute c USING(attnum) + WHERE c.attrelid = fk.confrelid + )) AS parentcolumn, + s_c.nspname AS childschema, + t_c.relname AS childtable, + unnest(( + select + array_agg(attname ORDER BY i) + from + (select unnest(conkey) as attnum, generate_subscripts(conkey, 1) as i) x + JOIN pg_catalog.pg_attribute c USING(attnum) + WHERE c.attrelid = fk.conrelid + )) AS childcolumn + FROM pg_catalog.pg_constraint fk + JOIN pg_catalog.pg_class t_p ON t_p.oid = fk.confrelid + JOIN pg_catalog.pg_namespace s_p ON s_p.oid = t_p.relnamespace + JOIN pg_catalog.pg_class t_c ON t_c.oid = fk.conrelid + JOIN pg_catalog.pg_namespace s_c ON s_c.oid = t_c.relnamespace + WHERE fk.contype = 'f'; + """ + _logger.debug("Functions Query. sql: %r", query) + cur.execute(query) + for row in cur: + yield ForeignKey(*row) + + def functions(self): + """Yields FunctionMetadata named tuples""" + + if self.conn.server_version >= 110000: + query = """ + SELECT n.nspname schema_name, + p.proname func_name, + p.proargnames, + COALESCE(proallargtypes::regtype[], proargtypes::regtype[])::text[], + p.proargmodes, + prorettype::regtype::text return_type, + p.prokind = 'a' is_aggregate, + p.prokind = 'w' is_window, + p.proretset is_set_returning, + d.deptype = 'e' is_extension, + pg_get_expr(proargdefaults, 0) AS arg_defaults + FROM pg_catalog.pg_proc p + INNER JOIN pg_catalog.pg_namespace n + ON n.oid = p.pronamespace + LEFT JOIN pg_depend d ON d.objid = p.oid and d.deptype = 'e' + WHERE p.prorettype::regtype != 'trigger'::regtype + ORDER BY 1, 2 + """ + elif self.conn.server_version > 90000: + query = """ + SELECT n.nspname schema_name, + p.proname func_name, + p.proargnames, + COALESCE(proallargtypes::regtype[], proargtypes::regtype[])::text[], + p.proargmodes, + prorettype::regtype::text return_type, + p.proisagg is_aggregate, + p.proiswindow is_window, + p.proretset is_set_returning, + d.deptype = 'e' is_extension, + pg_get_expr(proargdefaults, 0) AS arg_defaults + FROM pg_catalog.pg_proc p + INNER JOIN pg_catalog.pg_namespace n + ON n.oid = p.pronamespace + LEFT JOIN pg_depend d ON d.objid = p.oid and d.deptype = 'e' + WHERE p.prorettype::regtype != 'trigger'::regtype + ORDER BY 1, 2 + """ + elif self.conn.server_version >= 80400: + query = """ + SELECT n.nspname schema_name, + p.proname func_name, + p.proargnames, + COALESCE(proallargtypes::regtype[], proargtypes::regtype[])::text[], + p.proargmodes, + prorettype::regtype::text, + p.proisagg is_aggregate, + false is_window, + p.proretset is_set_returning, + d.deptype = 'e' is_extension, + NULL AS arg_defaults + FROM pg_catalog.pg_proc p + INNER JOIN pg_catalog.pg_namespace n + ON n.oid = p.pronamespace + LEFT JOIN pg_depend d ON d.objid = p.oid and d.deptype = 'e' + WHERE p.prorettype::regtype != 'trigger'::regtype + ORDER BY 1, 2 + """ + else: + query = """ + SELECT n.nspname schema_name, + p.proname func_name, + p.proargnames, + NULL arg_types, + NULL arg_modes, + '' ret_type, + p.proisagg is_aggregate, + false is_window, + p.proretset is_set_returning, + d.deptype = 'e' is_extension, + NULL AS arg_defaults + FROM pg_catalog.pg_proc p + INNER JOIN pg_catalog.pg_namespace n + ON n.oid = p.pronamespace + LEFT JOIN pg_depend d ON d.objid = p.oid and d.deptype = 'e' + WHERE p.prorettype::regtype != 'trigger'::regtype + ORDER BY 1, 2 + """ + + with self.conn.cursor() as cur: + _logger.debug("Functions Query. sql: %r", query) + cur.execute(query) + for row in cur: + yield FunctionMetadata(*row) + + def datatypes(self): + """Yields tuples of (schema_name, type_name)""" + + with self.conn.cursor() as cur: + if self.conn.server_version > 90000: + query = """ + SELECT n.nspname schema_name, + t.typname type_name + FROM pg_catalog.pg_type t + INNER JOIN pg_catalog.pg_namespace n + ON n.oid = t.typnamespace + WHERE ( t.typrelid = 0 -- non-composite types + OR ( -- composite type, but not a table + SELECT c.relkind = 'c' + FROM pg_catalog.pg_class c + WHERE c.oid = t.typrelid + ) + ) + AND NOT EXISTS( -- ignore array types + SELECT 1 + FROM pg_catalog.pg_type el + WHERE el.oid = t.typelem AND el.typarray = t.oid + ) + AND n.nspname <> 'pg_catalog' + AND n.nspname <> 'information_schema' + ORDER BY 1, 2; + """ + else: + query = """ + SELECT n.nspname schema_name, + pg_catalog.format_type(t.oid, NULL) type_name + FROM pg_catalog.pg_type t + LEFT JOIN pg_catalog.pg_namespace n ON n.oid = t.typnamespace + WHERE (t.typrelid = 0 OR (SELECT c.relkind = 'c' FROM pg_catalog.pg_class c WHERE c.oid = t.typrelid)) + AND t.typname !~ '^_' + AND n.nspname <> 'pg_catalog' + AND n.nspname <> 'information_schema' + AND pg_catalog.pg_type_is_visible(t.oid) + ORDER BY 1, 2; + """ + _logger.debug("Datatypes Query. sql: %r", query) + cur.execute(query) + for row in cur: + yield row + + def casing(self): + """Yields the most common casing for names used in db functions""" + with self.conn.cursor() as cur: + query = r""" + WITH Words AS ( + SELECT regexp_split_to_table(prosrc, '\W+') AS Word, COUNT(1) + FROM pg_catalog.pg_proc P + JOIN pg_catalog.pg_namespace N ON N.oid = P.pronamespace + JOIN pg_catalog.pg_language L ON L.oid = P.prolang + WHERE L.lanname IN ('sql', 'plpgsql') + AND N.nspname NOT IN ('pg_catalog', 'information_schema') + GROUP BY Word + ), + OrderWords AS ( + SELECT Word, + ROW_NUMBER() OVER(PARTITION BY LOWER(Word) ORDER BY Count DESC) + FROM Words + WHERE Word ~* '.*[a-z].*' + ), + Names AS ( + --Column names + SELECT attname AS Name + FROM pg_catalog.pg_attribute + UNION -- Table/view names + SELECT relname + FROM pg_catalog.pg_class + UNION -- Function names + SELECT proname + FROM pg_catalog.pg_proc + UNION -- Type names + SELECT typname + FROM pg_catalog.pg_type + UNION -- Schema names + SELECT nspname + FROM pg_catalog.pg_namespace + UNION -- Parameter names + SELECT unnest(proargnames) + FROM pg_proc + ) + SELECT Word + FROM OrderWords + WHERE LOWER(Word) IN (SELECT Name FROM Names) + AND Row_Number = 1; + """ + _logger.debug("Casing Query. sql: %r", query) + cur.execute(query) + for row in cur: + yield row[0] diff --git a/pgcli/pgstyle.py b/pgcli/pgstyle.py new file mode 100644 index 0000000..8229037 --- /dev/null +++ b/pgcli/pgstyle.py @@ -0,0 +1,116 @@ +import logging + +import pygments.styles +from pygments.token import string_to_tokentype, Token +from pygments.style import Style as PygmentsStyle +from pygments.util import ClassNotFound +from prompt_toolkit.styles.pygments import style_from_pygments_cls +from prompt_toolkit.styles import merge_styles, Style + +logger = logging.getLogger(__name__) + +# map Pygments tokens (ptk 1.0) to class names (ptk 2.0). +TOKEN_TO_PROMPT_STYLE = { + Token.Menu.Completions.Completion.Current: "completion-menu.completion.current", + Token.Menu.Completions.Completion: "completion-menu.completion", + Token.Menu.Completions.Meta.Current: "completion-menu.meta.completion.current", + Token.Menu.Completions.Meta: "completion-menu.meta.completion", + Token.Menu.Completions.MultiColumnMeta: "completion-menu.multi-column-meta", + Token.Menu.Completions.ProgressButton: "scrollbar.arrow", # best guess + Token.Menu.Completions.ProgressBar: "scrollbar", # best guess + Token.SelectedText: "selected", + Token.SearchMatch: "search", + Token.SearchMatch.Current: "search.current", + Token.Toolbar: "bottom-toolbar", + Token.Toolbar.Off: "bottom-toolbar.off", + Token.Toolbar.On: "bottom-toolbar.on", + Token.Toolbar.Search: "search-toolbar", + Token.Toolbar.Search.Text: "search-toolbar.text", + Token.Toolbar.System: "system-toolbar", + Token.Toolbar.Arg: "arg-toolbar", + Token.Toolbar.Arg.Text: "arg-toolbar.text", + Token.Toolbar.Transaction.Valid: "bottom-toolbar.transaction.valid", + Token.Toolbar.Transaction.Failed: "bottom-toolbar.transaction.failed", + Token.Output.Header: "output.header", + Token.Output.OddRow: "output.odd-row", + Token.Output.EvenRow: "output.even-row", + Token.Output.Null: "output.null", + Token.Literal.String: "literal.string", + Token.Literal.Number: "literal.number", + Token.Keyword: "keyword", + Token.Prompt: "prompt", + Token.Continuation: "continuation", +} + +# reverse dict for cli_helpers, because they still expect Pygments tokens. +PROMPT_STYLE_TO_TOKEN = {v: k for k, v in TOKEN_TO_PROMPT_STYLE.items()} + + +def parse_pygments_style(token_name, style_object, style_dict): + """Parse token type and style string. + + :param token_name: str name of Pygments token. Example: "Token.String" + :param style_object: pygments.style.Style instance to use as base + :param style_dict: dict of token names and their styles, customized to this cli + + """ + token_type = string_to_tokentype(token_name) + try: + other_token_type = string_to_tokentype(style_dict[token_name]) + return token_type, style_object.styles[other_token_type] + except AttributeError: + return token_type, style_dict[token_name] + + +def style_factory(name, cli_style): + try: + style = pygments.styles.get_style_by_name(name) + except ClassNotFound: + style = pygments.styles.get_style_by_name("native") + + prompt_styles = [] + # prompt-toolkit used pygments tokens for styling before, switched to style + # names in 2.0. Convert old token types to new style names, for backwards compatibility. + for token in cli_style: + if token.startswith("Token."): + # treat as pygments token (1.0) + token_type, style_value = parse_pygments_style(token, style, cli_style) + if token_type in TOKEN_TO_PROMPT_STYLE: + prompt_style = TOKEN_TO_PROMPT_STYLE[token_type] + prompt_styles.append((prompt_style, style_value)) + else: + # we don't want to support tokens anymore + logger.error("Unhandled style / class name: %s", token) + else: + # treat as prompt style name (2.0). See default style names here: + # https://github.com/jonathanslenders/python-prompt-toolkit/blob/master/prompt_toolkit/styles/defaults.py + prompt_styles.append((token, cli_style[token])) + + override_style = Style([("bottom-toolbar", "noreverse")]) + return merge_styles( + [style_from_pygments_cls(style), override_style, Style(prompt_styles)] + ) + + +def style_factory_output(name, cli_style): + try: + style = pygments.styles.get_style_by_name(name).styles + except ClassNotFound: + style = pygments.styles.get_style_by_name("native").styles + + for token in cli_style: + if token.startswith("Token."): + token_type, style_value = parse_pygments_style(token, style, cli_style) + style.update({token_type: style_value}) + elif token in PROMPT_STYLE_TO_TOKEN: + token_type = PROMPT_STYLE_TO_TOKEN[token] + style.update({token_type: cli_style[token]}) + else: + # TODO: cli helpers will have to switch to ptk.Style + logger.error("Unhandled style / class name: %s", token) + + class OutputStyle(PygmentsStyle): + default_style = "" + styles = style + + return OutputStyle diff --git a/pgcli/pgtoolbar.py b/pgcli/pgtoolbar.py new file mode 100644 index 0000000..f4289a1 --- /dev/null +++ b/pgcli/pgtoolbar.py @@ -0,0 +1,62 @@ +from prompt_toolkit.key_binding.vi_state import InputMode +from prompt_toolkit.application import get_app + + +def _get_vi_mode(): + return { + InputMode.INSERT: "I", + InputMode.NAVIGATION: "N", + InputMode.REPLACE: "R", + InputMode.REPLACE_SINGLE: "R", + InputMode.INSERT_MULTIPLE: "M", + }[get_app().vi_state.input_mode] + + +def create_toolbar_tokens_func(pgcli): + """Return a function that generates the toolbar tokens.""" + + def get_toolbar_tokens(): + result = [] + result.append(("class:bottom-toolbar", " ")) + + if pgcli.completer.smart_completion: + result.append(("class:bottom-toolbar.on", "[F2] Smart Completion: ON ")) + else: + result.append(("class:bottom-toolbar.off", "[F2] Smart Completion: OFF ")) + + if pgcli.multi_line: + result.append(("class:bottom-toolbar.on", "[F3] Multiline: ON ")) + else: + result.append(("class:bottom-toolbar.off", "[F3] Multiline: OFF ")) + + if pgcli.multi_line: + if pgcli.multiline_mode == "safe": + result.append(("class:bottom-toolbar", " ([Esc] [Enter] to execute]) ")) + else: + result.append( + ("class:bottom-toolbar", " (Semi-colon [;] will end the line) ") + ) + + if pgcli.vi_mode: + result.append( + ("class:bottom-toolbar", "[F4] Vi-mode (" + _get_vi_mode() + ")") + ) + else: + result.append(("class:bottom-toolbar", "[F4] Emacs-mode")) + + if pgcli.pgexecute.failed_transaction(): + result.append( + ("class:bottom-toolbar.transaction.failed", " Failed transaction") + ) + + if pgcli.pgexecute.valid_transaction(): + result.append( + ("class:bottom-toolbar.transaction.valid", " Transaction") + ) + + if pgcli.completion_refresher.is_refreshing(): + result.append(("class:bottom-toolbar", " Refreshing completions...")) + + return result + + return get_toolbar_tokens diff --git a/post-install b/post-install new file mode 100644 index 0000000..d516a3f --- /dev/null +++ b/post-install @@ -0,0 +1,4 @@ +#!/bin/bash + +echo "Setting up symlink to pgcli" +ln -sf /usr/share/pgcli/bin/pgcli /usr/local/bin/pgcli diff --git a/post-remove b/post-remove new file mode 100644 index 0000000..1013eb4 --- /dev/null +++ b/post-remove @@ -0,0 +1,4 @@ +#!/bin/bash + +echo "Removing symlink to pgcli" +rm /usr/local/bin/pgcli diff --git a/pylintrc b/pylintrc new file mode 100644 index 0000000..4aa448d --- /dev/null +++ b/pylintrc @@ -0,0 +1,2 @@ +[MESSAGES CONTROL] +disable=missing-docstring,invalid-name \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..c9bf518 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,22 @@ +[tool.black] +line-length = 88 +target-version = ['py36'] +include = '\.pyi?$' +exclude = ''' +/( + \.eggs + | \.git + | \.hg + | \.mypy_cache + | \.tox + | \.venv + | \.cache + | \.pytest_cache + | _build + | buck-out + | build + | dist + | tests/data +)/ +''' + diff --git a/release.py b/release.py new file mode 100644 index 0000000..e83d239 --- /dev/null +++ b/release.py @@ -0,0 +1,135 @@ +#!/usr/bin/env python +"""A script to publish a release of pgcli to PyPI.""" + +import io +from optparse import OptionParser +import re +import subprocess +import sys + +import click + +DEBUG = False +CONFIRM_STEPS = False +DRY_RUN = False + + +def skip_step(): + """ + Asks for user's response whether to run a step. Default is yes. + :return: boolean + """ + global CONFIRM_STEPS + + if CONFIRM_STEPS: + return not click.confirm("--- Run this step?", default=True) + return False + + +def run_step(*args): + """ + Prints out the command and asks if it should be run. + If yes (default), runs it. + :param args: list of strings (command and args) + """ + global DRY_RUN + + cmd = args + print(" ".join(cmd)) + if skip_step(): + print("--- Skipping...") + elif DRY_RUN: + print("--- Pretending to run...") + else: + subprocess.check_output(cmd) + + +def version(version_file): + _version_re = re.compile( + r'__version__\s+=\s+(?P[\'"])(?P.*)(?P=quote)' + ) + + with io.open(version_file, encoding="utf-8") as f: + ver = _version_re.search(f.read()).group("version") + + return ver + + +def commit_for_release(version_file, ver): + run_step("git", "reset") + run_step("git", "add", version_file) + run_step("git", "commit", "--message", "Releasing version {}".format(ver)) + + +def create_git_tag(tag_name): + run_step("git", "tag", tag_name) + + +def create_distribution_files(): + run_step("python", "setup.py", "clean", "--all", "sdist", "bdist_wheel") + + +def upload_distribution_files(): + run_step("twine", "upload", "dist/*") + + +def push_to_github(): + run_step("git", "push", "origin", "master") + + +def push_tags_to_github(): + run_step("git", "push", "--tags", "origin") + + +def checklist(questions): + for question in questions: + if not click.confirm("--- {}".format(question), default=False): + sys.exit(1) + + +if __name__ == "__main__": + if DEBUG: + subprocess.check_output = lambda x: x + + checks = [ + "Have you updated the AUTHORS file?", + "Have you updated the `Usage` section of the README?", + ] + checklist(checks) + + ver = version("pgcli/__init__.py") + print("Releasing Version:", ver) + + parser = OptionParser() + parser.add_option( + "-c", + "--confirm-steps", + action="store_true", + dest="confirm_steps", + default=False, + help=( + "Confirm every step. If the step is not " "confirmed, it will be skipped." + ), + ) + parser.add_option( + "-d", + "--dry-run", + action="store_true", + dest="dry_run", + default=False, + help="Print out, but not actually run any steps.", + ) + + popts, pargs = parser.parse_args() + CONFIRM_STEPS = popts.confirm_steps + DRY_RUN = popts.dry_run + + if not click.confirm("Are you sure?", default=False): + sys.exit(1) + + commit_for_release("pgcli/__init__.py", ver) + create_git_tag("v{}".format(ver)) + create_distribution_files() + push_to_github() + push_tags_to_github() + upload_distribution_files() diff --git a/release_procedure.txt b/release_procedure.txt new file mode 100644 index 0000000..9f3bff0 --- /dev/null +++ b/release_procedure.txt @@ -0,0 +1,13 @@ +# vi: ft=vimwiki + +* Bump the version number in pgcli/__init__.py +* Commit with message: 'Releasing version X.X.X.' +* Create a tag: git tag vX.X.X +* Fix the image url in PyPI to point to github raw content. https://raw.githubusercontent.com/dbcli/pgcli/master/screenshots/image01.png +* Create source dist tar ball: python setup.py sdist +* Test this by installing it in a fresh new virtualenv. Run SanityChecks [./sanity_checks.txt]. +* Upload the source dist to PyPI: https://pypi.python.org/pypi/pgcli +* pip install pgcli +* Run SanityChecks. +* Push the version back to github: git push --tags origin master +* Done! diff --git a/requirements-dev.txt b/requirements-dev.txt new file mode 100644 index 0000000..80e8f43 --- /dev/null +++ b/requirements-dev.txt @@ -0,0 +1,14 @@ +pytest>=2.7.0 +mock>=1.0.1 +tox>=1.9.2 +behave>=1.2.4 +pexpect==3.3 +pre-commit>=1.16.0 +coverage==5.0.4 +codecov>=1.5.1 +docutils>=0.13.1 +autopep8==1.3.3 +click==6.7 +twine==1.11.0 +wheel==0.33.6 +prompt_toolkit==3.0.5 diff --git a/sanity_checks.txt b/sanity_checks.txt new file mode 100644 index 0000000..d8a4898 --- /dev/null +++ b/sanity_checks.txt @@ -0,0 +1,37 @@ +# vi: ft=vimwiki + +* Launch pgcli with different inputs. + * pgcli test_db + * pgcli postgres://localhost/test_db + * pgcli postgres://localhost:5432/test_db + * pgcli postgres://amjith@localhost:5432/test_db + * pgcli postgres://amjith:password@localhost:5432/test_db + * pgcli non-existent-db + +* Test special command + * \d + * \d table_name + * \dt + * \l + * \c amjith + * \q + +* Simple execution: + 1 Execute a simple 'select * from users;' test that will pass. + 2 Execute a syntax error: 'insert into users ( ;' + 3 Execute a simple test from step 1 again to see if it still passes. + * Change the database and try steps 1 - 3. + +* Test smart-completion + * Sele - Must auto-complete to SELECT + * SELECT * FROM - Must list the table names. + * INSERT INTO - Must list table names. + * \d - Must list table names. + * \c - Database names. + * SELECT * FROM table_name WHERE - column names (all of it). + +* Test naive-completion - turn off smart completion (using F2 key after launch) + * Sele - autocomplete to select. + * SELECT * FROM - autocomplete list should have everything. + + diff --git a/screenshots/image01.png b/screenshots/image01.png new file mode 100644 index 0000000000000000000000000000000000000000..58520c5faababaa7f64adb1e1fdec719e70a3b15 GIT binary patch literal 82111 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 literal 0 HcmV?d00001 diff --git a/screenshots/image02.png b/screenshots/image02.png new file mode 100644 index 0000000000000000000000000000000000000000..c321c861b8247e7b09f754e46c7ccca397850052 GIT binary patch literal 11767 zcmb_?1x(!0vo2a3in}{Sixr3B4#kR>;tq?uwRmwU?rd=_t}V8>ySvNcx^HRzxi2rd z$-BvW$yv$nk#FYAoSFH~FG5X476X+86$S{c~Li^aI67 zPS*tn1`YT11q+j&NdyDKRw6GYq3H>Gl7(V`EBn~*kqCprPZLa*kDLz(XJXM~W68d@ zI?1c(ZE!4WxIVeQSzB!EII)dwygewJ)jL_Tsj#oOt*~lW#gxXwhn05NL6%iXcpkHm z45TBKK>P~Mz{;r>61p6|^tHTM01aD&=D!t(L4da75Ey$(i$4l)VS;WLx;Ud3Xsk&b z`dNS8gyYrAQZvE7_7U$wMGh|)4@)WKwC+CSuyBX$fF|D?o3~yHrI7ydN}@&m^flg25r$qt!fBp{ zq>qr$egWkR_Ty1hqL@O5iO2(%m&s1CDurOP-RboGQMv3pD?k`R`aPOrtpQF0nK8Nc zooo(DmA=Ak%3ND8?cYZ!nQNyaR?jD)@w$o~ep44DZ<6ghOjlH5hYwqr2t8Y}O3ug2 ziHBqgX92R(Uz?`}Z4Zx-e)Ys=(q})ME#gFClCrTVQ~Mjtzf(V35MaJ$fxWnsK*6N` zi(%X)PZGANWIp(5k%=o+T4T&&Bj$&wehF|k_$mHO-oWo_(}!NL`5xQvR14^6<>*#( zfo&2GOQB5BEQ27lInBt~Gq>{Y!+0Yts%)CQw0QmYMwizSngiQvw+N4LF6oxnQIdVi zN4<*TpWgTfE4s)Yxt97vj>XsH`_^$9=`>=}DBGVVo~uw$QnRW`m;cU3=@}X?_v*{3 zl+|4Wj}aH+n|yE@Ml|BMZ#mx=&mku!t_#JvT>zMmTD5*3JMa8{pv|!<$N?ZzD0%#F zEoACV6Vt@RQD7tN6YfjNxQSZql~wcj+t$j6qgMh9RrMc`;&Q%C_qORD0(>1R?EO<2 z-=jDvkA3@AXz3tfBeilTIoy;7Ganb^yH(H>e+xek?tVf%APgcNM! z{x?tryn%J3`*}K#_hF){=zY($`kP)+Lyz)uy-Mw&BK#TpRFBxp!$#49&w5UWCaOAq zBxB5BRoaFkO+8-A{fJsRViZfsQIIb~UI*8meXa5`tn+HR$(J{@E*|Fib-ja;{=W8^ z5-)#pZV0pn!k@&9B4{PCLp=)s5oC(qEp+7quDW^LM;5E`z=u}S?Q;-69Vgjx=#%z^ z@8uHZnin82uPKErso1Kl&aE=#q$zPr21{i$Y?uMFg{vtHK<@6>m_^4h7SflSsFt2j z9iY6$d%YdZuw^OTu8?!_0P8*+=3DDUyg3dkNw=D08r=S+Jc}cQhrK<5ji)T?>A^&o(ovyHN8>OM1qEoGeA2VBsNTnj92q*jCRiXxI4!LC4dceY_ z2rx6=rhZasbcqBnGDFbiRIGz}5+|VTWT&~R<6%+E*pEKK0K?atzGUSg82&F^Iqhda zZGw6&ud-gB+YfDhw@8s_T=E zdIJn9XKV(qjR|D}oFjV@g}zl8h>B$O1t?#YH&^TFl&h%P(=YXJ#E;Q)KimC&(gq(3 zKQt>d0-A-kH}ihS_VW13zt<|#jrLMiu&|1V%|8F}(`7cZpHcEAb84>HG2|96!4f8d z>#4VMROQ@>Jy{B%!^p-}xEIOK-kA2uLM3|2g(62*HDT?;^ZeMb=aV`mj~_BGM>5dg z_)Z{8T=ee5mHm3Jb>3*Gp)m~Hd$~f`c2e3*@WWF0;k*ITmpKyCMqw!5QIP^HU;oN) zr2amg3^1lQ9_I6&8TEyx@$eUi?&Sh$4$$uqjdXC8S6M`qk`iF7_*)N*r+feOCE@n^ zS|GY1+BbDmulB%_+tg{Y10nr3wzMI0kSTwKwu7UBwIku?@1v^oVCHJu?jz;xT3q5q zN`mxSz~#ssWxyrdk;oY((auMWqfXz4Uzu6dt~dvs*BalW{1^R`Z^C_!jNyNsqV-EF zuuSr^v9LGVEne(EZZ}!nWlV144Z!k!N0y^xD-p%9PvD)0Qazllxpk+z!JR3WKl?=a zK-(PRqRPG+QV9?%F-EVM-Hn^?Xu{T8WV_Sb)E%%Ad-8pe5a7AvZxvkfK z?eozQGmAMJ8=$b=ho`-8;i9(uL*fr-KoRINuvKO?Zr0}4^51k@M&9oS>6iMh8n7+4 zi=h+gq^a|I9Y;a3s@yxsS8F2Zsh>x-wJ;ut^GPx94*5k(k$R^K$%TjMg_FF$lewat z{v{mKiS<1x&GX%Y_&zutC@pn5zl2^6j;xWj6`xE8Y-YfU76yng^wg|ZiKV1!=}yZ^ zaq)|0^Mx#z5(}mH-o1$Vuc-G~66(YZ6kLSve)YVM3*(kHHiB%_4XGp~(I01>?rxoq z=W@L0W5aHFxb3224c;B!rdq_D&D23$&YpOjhtC7=_CENLOYuy7h#c*^#yC+r%?w8K zmR&#Hkm3Ch>G4u$b6V(e=CfJBFLeJfo<8$VbmtpqeUnBiRpeI@pSrGTvbCnfD3o(& z2Bet!Q|q2-vL*lUsN2iSbxhVIMVz092Q<#q7lZl3xWxD|Efmxvn%P5TMKTE{R@~s` zPj{mF)H(Tjy@~H?XuKwH9pzOVUYb&+mIDkXkB1mvJa=S8%~^YV5Y#LoI%NyuiJfki8_lOd>Td0vt zV$w3T@v{;8mrjQkxho1WnsOn5F2CZg%}wR z2N7bV3_|rzHs?0Z^!m|gH;IK2l*p43OtvOjD zNXv&8w^?0evTq^A*jWC(^R$T$9n?Zz9R8a$pe{mkKP1$G9Guq28qBK)*k`x+&vL8o zl-ID2{7?3#0wBk_7_Cia^t4>nwe*0BsYF%qMb&40!CP0<90szxe5vD6Ol;%DuGGxU zvbC{PhZ2i#Q=Ni$&r2L_HCB&%^4LkoPx%3kdr^C3vd}0Ee48U`=k0Qmql!ry;4*T! zEv$EZK$Cw{q1#<#S0U=_PCQ8OXpM-O(Q3C)+o@{O$q2sSDs=SDg~Y7#p!Uy-=PnGN zWPqU6OAn=-0cHNfT;onBsdum$4{LPgh&>?P4QWF(})$Udl_^A?49?X zXxCPihPqZ_3=h?3hr)OF#+eK)V|_P{5}0N7PyOaCwDsfKMciYt*glXTvNkz&ozYY9tz4+LQFk(P| zP+}%j1^V~`v^p_RZQjLqz>|lA*(TQbO;D{>M!7VaNFZ5lX64O4w^z=0%M{_P_r^r- z3!M|UA{y0VWljbGj~ljQ>d!bbF=Pvx#6tkqbKc4Rj|u=#u;DH@_?%1G4uEw)tE53w z{EWjdDg(uBQG~BA=_e~k$rfvO z=SB_$j?YjPV>zrV-WGP+!=2wX{y5DZ{N)2m)pR!mHIF4c>E%B>qxo~Z>-_UfR7-yl z{{}(tWA0J?_x?JxCQ3cqo#bsTojPNEeHv72hY%Ry7khErBi4uJ@doxcUds#mkJdDIViAlBj*Rn^4bQ$T8K!yWT%hSWZOCc zyAM)5)p$I@`h`$}=wO2d9D8M!q;H6Kq~pegAzilUSIYpMl}(yiJ1kdtSnyv#Fj_gm zNf6>^sxOuZW-PS`aSmccQK5tcg~GLbyg|FI`OReVRlR$;X8kX0CWT2T86OB`1^_ zWqG%8(Uqo#ImFvkfX8b@qAHJEerIej4P>Ky(d^H*UGul6q~v#ZH>)OO8ye=IwXOGV z6>=d(M2}MNWagXhmq<%9HqZtd3oXjC9!zipK3m2o#0&G-XlAyGO9~-22~C*o^&TE( zxUpba&asdFC=je4pZE*1KlY==fnTm7NFJ~Pox@JRnmL6NjFJfFriO98+ncPsTu3cK zl|^!GK5oQz58ttJA`d42?oJ1b6g$r7?QK)!#c?cbII5-*rlNp4&GvNm_FU;37VSGv z+o2k=P%ugi#NpS5?AGFS>n&0&5%;)s9pETC$dvKhz1xIA+tW)2e6(ca3>KEP4c3Bw zsNr^^y2Em-sdBR*yUo=Ta--ux7#VI>Qmyg3@WCMelmziB6xhUeJB{}Tjr;&Hc@U2 zHMqlqvsZ?p=&V4I?3lqu2D>tlpTBo!ROV~?AaL-o-GVFQ)gTwpJtQrrCV4~oOJ~qE zmAR-z`tRvBv08yuNYp6O8(&veZk^Koq~PEWJ90fi1R$6De_hl?-!Xj)=K=* zJUQ=A7Qgd**xfIsND`Vys|E&)^*N%+;5ehgdE~(KU7U$($khYj@XdaH$BB-jEFLT) zrNmc|@nj0p|Ap4BtS8A6dqBvqXdptvkQ9Bc))LdK>yqB$f+2bT=E_@BEm1!yL(fta zi+Gw^H69}5V$ zYcO$H<8L-Ct)9~SqPPCHyF=sQv9##E%mq=NOSC5@G)sUPG0lN~s@5znEI+9QepCTx z2Y1}o8zwnTFU32}&o0g~Fm#DMNwM2%#!OJhP`{J_D>9rMue-NYLxa0n=VP;rZ?L#N zZtewId4ml4J-KujSi#2sG~1BLONRHc&u^-WaE)gqi>x(~a$is=p(G@Dki_BePNk6A zaR}uLJ?|fddQTVUp?GR~LQb#SoI{8bvYdya>(0rP?is zjpc)$fiQ44A7LogjBH5UUxz^lExvxd4uZW$OlhA-6@s3k;$0mmUv_6Zj@Du-Vv%Dq z2}xI_Sqtm+td&9FcG)V|m^cT9?fJN~i0=R( zT`T$Cm@(&rsH^jV+HeHvjb(91bp&B4Q8v2G24NPnR#|(jM`#S_Fc2S9# z`f@tsv(pSTkqq~rP)0IU?{hrfaAgVccoAA_J|J~~68JrlK1SLM318u4vKpu*h2^eV zmcbu|X=eVUt+rx7G%DWJg50&Guj414fyvOt#7)xHkowckUmClgouC%!@Nklw{yma| zmXAz)!Iw_5IFSlK)Z=J4GKR@*J-Aa$?D>1a0=XSrBoeGYG%!8cIrYk*Ob@X*Sp9S` z>JT{?T25*B5jy#h#`Ye}T~Ng_qPaK6)jW(rkFTlcYOU_AYdRQ8TMNX$ck#IJ;db_P zgu=5kO~YWmphOVNgc0@+(}Bgaa)hhb_TKka{uEnDDaz@4k~VaWb4D#;1nQ4Hn8ol2 zN5Lwo7>|iQq+~C483oqSin+~k7jikbIt_JwH|2{tjzr zg&69YLR(?Ww+ulejJMv%GO8%2g0`MIvv4w%8Lj461>XHKHIUy>S{gDu?siDL1N6c2 zfA1{iDrHdb@W>*q>AyQ+VF&hihOwZPu8P1#Tg=pml&Q++uVmp}mj;s% zFJ$vY<5#UVSuV{A2FaAzHJDU9^yz2JJvO&`sU;a`C04Cv-Sp{`&Y7r|wd%Z!o8CpJ zM$*opm4Ve(3@k~_sThCmQUKDhnIT;yvnc=mQ~leXdquI-R;h91Pqy|5(MC@TN_aI= zikr^cdRsBLo!|KQ@160zC~;vC?2=$)-0bSVnrI)PBoSJUEM(3+_zu9r-J-*m*E)@9 z;^QGgy*5aQPTXH190smI{!3rSbAvTec}p3BKdH!Jc8 z8l`Z!pjL5lZ2#9XKSkbR&nibfM(_i^p{SFN}^q4`J6R;Xqi`KOtX z5%i?QwUYfzdi6H<&5QQfD;^S?{L4@eTk>lQK!B2Dk?``!c>H%EBGu(hfZTLLoVW4# z%im6WT!Z8SzGx8{1j{6%;6RX7%O+6=>*HWnNA0MY=<0akcJ{E$?H4tje=q*F4NvqK zSSPdyCu6S2=d|yweMcJm=}+8%mkTYh&)MR#H!&zRnzwBJ%FN_>+=P~}V{2h1e&o-u zN(PAgW@65O$v`67n^6+Bb%GVMsx+)j(VaP_yj^s9l44alK;B^)d-=0*Ut~okJO!}; zM1asS%NLiJhgQ+}f@_DDZ8!d9S(?3%{@Bq3Mda>^S-7_JL#OIC(OlAw7~Zkimax-q z4^}ok+3;d76Bz4h4=G;i&v?Qmgp3F~^PuwI4{L|0x!&bIhY?4~q-!dN#rf`I}o?SluF*zFs zJ@1Gy2FN$swMyjdADH?*^zksHkgr@G)<Cs!)3vEc@iQ z5>{u7`1Calpv@K8mLv^oH!_$!+YqI*a9X)f6g!UgX%jmT%D@tSaEh=U!lg90ZJ196 zxN`{IuW7K{qsvBkajnP*yMl+jy!vZt8LAZ#c8-mt-Mkj78i9y@M3Kq(JboM104q)#st*fHBLnR&GBM0(+mz1JK8>kmO*^#vub z^EEQ~g9(#moxAgn4KGF;$<_K+pV!l)sPFeCpM+V^RBD28byuDQ#Y-bK^bFnA;$GvQJ;Un=MY>>lceAgkd!w?w9V_F$-1xCVb5rX?_68Lt&Om zQpqcrHM|f76FNAFH>gMBt@buamDGPw7#n4)(jd}M8w*>;={rG!0?sHel{QMM5=ZTX zWQ9ATjG3D8-V##sff=FXMx}RqDljeG#Wq#rl>0u^wTWgq7|BakgL&wstV9XZ ze_N5g#@vrbm}53$ab_A-Pm1qx25HFGsx}rTc;;=_aK(%!BAzt|+@^|u;jen*#uecJ zyOrtY(64@`G8*a2+p*fSDO&O_F(+Q;&3|({6S@34Ji+zz=TEZImbLg=S~j{{j|U%9 zv)d^OW7_XOPoxP;&nSa&MsN5#w0SEB?U>X8x`0?dx^BgEw5@_-kJ~y`_&K?!K0c++ zySsbnCj`9DDAwNZ)8h8IwHtlVqH++NXa>?gRQU|70*BP6O4<)id+KUJYRvUYPv0$?G&yJ|w5V(mf8 zT5fMo#A?qj*ID7RxC)T1>903EzO>z$L<}2y+GozM^6L|{1c^Wc< zZww*qZSL{t7#qhrm!OSbJ4-EBuDe@(MfAoAB=6(>HVqx_Q*2DXlKqB-^#=V7x|}7f z4>Ai!2!iG~jS1;8^tgh8+}H1a%%*+VjOrBiu<+T8=HXCxcVC9h82Y}x7a8(%#pmjV*7wO zQpg*eeE1hC`u96x z=dxe0gxEJG#JFDmo`q~}oSw96%||@fXU(H~Z1_QThNGnFU`KSTVQ(9if?l`?JJ*)`Bs$`zx@a1N= zIscpYavGELu|#VYzbz+IZ=hO120?Z-3ge81;FDfOeX-g7Y5`*g2dS{Y&5P_uL3|_8 z3wIeV`7y@5m-G39_EtBXs3aTn_EPtg4tNkClm=cZBrN7=Lvog}taKP6c;Dld} zJW8TmaKYU)GH-45jmDtgzVaw$|95o5#Aim=Ncz6TzG+P1`=1 z*i8IId9Ad|$y3dQiEi5IaNj$0KNva};rdRbEW)TB!fqvyw=o zf%YyTSa)pcA35O;tTxI(kj?8L zRaHK#@vEk*fP`h2MNqOaY!%+P9()`uYLR;Rz?Rt)kvT9Pv-th$nfSgL$65+&EkHTo zYIh_bUYEENUz4AiER1U023ns~$b_OdhzF`$@OY|n)WFw-f0(CIwTx6xnK?Xhq0Vlk zPps7|`n0~iy-gr71tgkH)lc}#G)8cMb`GG<&cgUgT^88Va-|e89OQ$}8A*ot?r;p( z=RXC}!7*gO3G#S3W&9z1WLE+vXz8_B)1Qw!Fk%!xvaB8mABpbh;L*r3?jJas#yeS; zz7b4PS6SUC5JY3D2*FY&9Qfa5lfmusmv%XEEyB#_(x#(7-4c#UF?*r54(9{$*p9D+ zNAhtS3vSEJPuvYjZ6bzzz>ot|N$zC6V$|didIAQL^&ESgm^ z{fIuxDT$|OF-n{+I10l;H}=^DwDs=J1dp=2;q>I-$3y$1%|RVh*On%*x}iR@d+{Ri)96k=&|Au;b z_FK?#BlbWG#hw!9TuNzg2+dcdgGLTbV6g?DDza9PDp33gZU4aIKOQ99m}LCdXFl(CEj0X_I_KN2-^@IXNx-rGq~`5Dg%n zB*IkM3GcySEHYq7LWtPDM?E}HpJMPR@!pGlCY--F<-7Y&sVG@V65Kk!u(lAd#Ehpo z>!dLls`$YQAGRZT`q<{T*~I2ZZ$*A+50z6DZtnMEs*X=ZCECA~?U_p|S-5`8^m1N< z>+sp0VUtQS5^)rum1R_=_P_vTtW5ZupjqrL ziq92W->3P+bK4s7KQdHq_Sr5@WG@FtS+6`uyJDRwzX42x%ZIGj3U?Yz@0m~$n4-4| zu;XSFxTh)Y6a>pN~ zYir4IAKWnL~%UaVZB!rEDS~33=FNg)WtjZl0uIK+?1)2FV4Qs zyF{)Ou#6X#vYuJ}77afPQy5!Y5d|}rt2$n^`?ub) zp}FAXr5QpR{$_>g^5j4#jY&Rlm}p^sh8%iVbc9l*J4Tq=%Y$%KkdZ zKTZ<|Y6JSE%JcuOe^OozFF(Zg^(N?xB2@qOC444mul+tlqlaSzX63(2A2ukB(pa?k zjqP8*2&hBQ3pTxZMcN<*B~JfqA!#^2sTTWew&HH-{}Jr}i)cars3;eT5AgpoNBY3E z6*&^qLPh7(|ILa1s^(~@w4|`z z%>VSj3e`hKg&xsAF=U_<{0Lmfd+lYH0+nw>n+^Tns-~}5TgP00h{l-C2fGY?;?R5=Vn#YlOi`__txoQ2%L+u=$4lD&067R0g)dZ*>1e#eEeu zpTq>=b&h%{SM85RMUC>G3851Gk|Bb9-5541Xn_wWC--lg`6Qsb^j}LUB0~d4irPn# z{||d)r2^ygg?BHA`#;*g8>#p&)zLz6%ZNLnzQW;Cgd(sgBMALxPp{cME!Brt_766L z9=efrT4;a&J)#^A)jjsx%zy0t2o6*W3#H#p{t5nSXs_A*D-mz;dRG6JB`^evR7Xdw jll@OOXfdq|0eS@?X& literal 0 HcmV?d00001 diff --git a/screenshots/pgcli.gif b/screenshots/pgcli.gif new file mode 100644 index 0000000000000000000000000000000000000000..9c8e66d75a53c5e70bb40da395b48e9ee526c69d GIT binary patch literal 238421 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 mp6Tk;f~<5?4Mbtpzp(WWZa3C+qc3fu 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%0D#8UzDV96NE@MTeHJJj-;(FaiB33SH_V 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℘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ⅆ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 z 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.11.8", + "click >= 4.1", + "Pygments >= 2.0", # Pygments has to be Capitalcased. WTF? + # We still need to use pt-2 unless pt-3 released on Fedora32 + # see: https://github.com/dbcli/pgcli/pull/1197 + "prompt_toolkit>=2.0.6,<4.0.0", + "psycopg2 >= 2.8", + "sqlparse >=0.3.0,<0.5", + "configobj >= 5.0.6", + "pendulum>=2.1.0", + "cli_helpers[styles] >= 2.0.0", +] + + +# setproctitle is used to mask the password when running `ps` in command line. +# But this is not necessary in Windows since the password is never shown in the +# task manager. Also setproctitle is a hard dependency to install in Windows, +# so we'll only install it if we're not in Windows. +if platform.system() != "Windows" and not platform.system().startswith("CYGWIN"): + install_requirements.append("setproctitle >= 1.1.9") + +setup( + name="pgcli", + author="Pgcli Core Team", + author_email="pgcli-dev@googlegroups.com", + version=__version__, + license="BSD", + url="http://pgcli.com", + packages=find_packages(), + package_data={"pgcli": ["pgclirc", "packages/pgliterals/pgliterals.json"]}, + description=description, + long_description=open("README.rst").read(), + install_requires=install_requirements, + extras_require={"keyring": ["keyring >= 12.2.0"]}, + python_requires=">=3.6", + entry_points=""" + [console_scripts] + pgcli=pgcli.main:cli + """, + classifiers=[ + "Intended Audience :: Developers", + "License :: OSI Approved :: BSD License", + "Operating System :: Unix", + "Programming Language :: Python", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.6", + "Programming Language :: Python :: 3.7", + "Programming Language :: Python :: 3.8", + "Programming Language :: Python :: 3.9", + "Programming Language :: SQL", + "Topic :: Database", + "Topic :: Database :: Front-Ends", + "Topic :: Software Development", + "Topic :: Software Development :: Libraries :: Python Modules", + ], +) diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..2a715b1 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,52 @@ +import os +import pytest +from utils import ( + POSTGRES_HOST, + POSTGRES_PORT, + POSTGRES_USER, + POSTGRES_PASSWORD, + create_db, + db_connection, + drop_tables, +) +import pgcli.pgexecute + + +@pytest.yield_fixture(scope="function") +def connection(): + create_db("_test_db") + connection = db_connection("_test_db") + yield connection + + drop_tables(connection) + connection.close() + + +@pytest.fixture +def cursor(connection): + with connection.cursor() as cur: + return cur + + +@pytest.fixture +def executor(connection): + return pgcli.pgexecute.PGExecute( + database="_test_db", + user=POSTGRES_USER, + host=POSTGRES_HOST, + password=POSTGRES_PASSWORD, + port=POSTGRES_PORT, + dsn=None, + ) + + +@pytest.fixture +def exception_formatter(): + return lambda e: str(e) + + +@pytest.fixture(scope="session", autouse=True) +def temp_config(tmpdir_factory): + # this function runs on start of test session. + # use temporary directory for config home so user config will not be used + os.environ["XDG_CONFIG_HOME"] = str(tmpdir_factory.mktemp("data")) diff --git a/tests/features/__init__.py b/tests/features/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/features/auto_vertical.feature b/tests/features/auto_vertical.feature new file mode 100644 index 0000000..aa95718 --- /dev/null +++ b/tests/features/auto_vertical.feature @@ -0,0 +1,12 @@ +Feature: auto_vertical mode: + on, off + + Scenario: auto_vertical on with small query + When we run dbcli with --auto-vertical-output + and we execute a small query + then we see small results in horizontal format + + Scenario: auto_vertical on with large query + When we run dbcli with --auto-vertical-output + and we execute a large query + then we see large results in vertical format diff --git a/tests/features/basic_commands.feature b/tests/features/basic_commands.feature new file mode 100644 index 0000000..99f893e --- /dev/null +++ b/tests/features/basic_commands.feature @@ -0,0 +1,58 @@ +Feature: run the cli, + call the help command, + exit the cli + + Scenario: run "\?" command + When we send "\?" command + then we see help output + + Scenario: run source command + When we send source command + then we see help output + + Scenario: run partial select command + When we send partial select command + then we see error message + then we see dbcli prompt + + Scenario: check our application_name + When we run query to check application_name + then we see found + + Scenario: run the cli and exit + When we send "ctrl + d" + then dbcli exits + + Scenario: list databases + When we list databases + then we see list of databases + + Scenario: run the cli with --username + When we launch dbcli using --username + and we send "\?" command + then we see help output + + Scenario: run the cli with --user + When we launch dbcli using --user + and we send "\?" command + then we see help output + + Scenario: run the cli with --port + When we launch dbcli using --port + and we send "\?" command + then we see help output + + Scenario: run the cli with --password + When we launch dbcli using --password + then we send password + and we see dbcli prompt + when we send "\?" command + then we see help output + + @wip + Scenario: run the cli with dsn and password + When we launch dbcli using dsn_password + then we send password + and we see dbcli prompt + when we send "\?" command + then we see help output diff --git a/tests/features/crud_database.feature b/tests/features/crud_database.feature new file mode 100644 index 0000000..ed13bbe --- /dev/null +++ b/tests/features/crud_database.feature @@ -0,0 +1,17 @@ +Feature: manipulate databases: + create, drop, connect, disconnect + + Scenario: create and drop temporary database + When we create database + then we see database created + when we drop database + then we confirm the destructive warning + then we see database dropped + when we connect to dbserver + then we see database connected + + Scenario: connect and disconnect from test database + When we connect to test database + then we see database connected + when we connect to dbserver + then we see database connected diff --git a/tests/features/crud_table.feature b/tests/features/crud_table.feature new file mode 100644 index 0000000..1f9db4a --- /dev/null +++ b/tests/features/crud_table.feature @@ -0,0 +1,22 @@ +Feature: manipulate tables: + create, insert, update, select, delete from, drop + + Scenario: create, insert, select from, update, drop table + When we connect to test database + then we see database connected + when we create table + then we see table created + when we insert into table + then we see record inserted + when we update table + then we see record updated + when we select from table + then we see data selected + when we delete from table + then we confirm the destructive warning + then we see record deleted + when we drop table + then we confirm the destructive warning + then we see table dropped + when we connect to dbserver + then we see database connected diff --git a/tests/features/db_utils.py b/tests/features/db_utils.py new file mode 100644 index 0000000..f57bc3b --- /dev/null +++ b/tests/features/db_utils.py @@ -0,0 +1,78 @@ +from psycopg2 import connect +from psycopg2.extensions import AsIs + + +def create_db( + hostname="localhost", username=None, password=None, dbname=None, port=None +): + """Create test database. + + :param hostname: string + :param username: string + :param password: string + :param dbname: string + :param port: int + :return: + + """ + cn = create_cn(hostname, password, username, "postgres", port) + + # ISOLATION_LEVEL_AUTOCOMMIT = 0 + # Needed for DB creation. + cn.set_isolation_level(0) + + with cn.cursor() as cr: + cr.execute("drop database if exists %s", (AsIs(dbname),)) + cr.execute("create database %s", (AsIs(dbname),)) + + cn.close() + + cn = create_cn(hostname, password, username, dbname, port) + return cn + + +def create_cn(hostname, password, username, dbname, port): + """ + Open connection to database. + :param hostname: + :param password: + :param username: + :param dbname: string + :return: psycopg2.connection + """ + cn = connect( + host=hostname, user=username, database=dbname, password=password, port=port + ) + + print("Created connection: {0}.".format(cn.dsn)) + return cn + + +def drop_db(hostname="localhost", username=None, password=None, dbname=None, port=None): + """ + Drop database. + :param hostname: string + :param username: string + :param password: string + :param dbname: string + """ + cn = create_cn(hostname, password, username, "postgres", port) + + # ISOLATION_LEVEL_AUTOCOMMIT = 0 + # Needed for DB drop. + cn.set_isolation_level(0) + + with cn.cursor() as cr: + cr.execute("drop database if exists %s", (AsIs(dbname),)) + + close_cn(cn) + + +def close_cn(cn=None): + """ + Close connection. + :param connection: psycopg2.connection + """ + if cn: + cn.close() + print("Closed connection: {0}.".format(cn.dsn)) diff --git a/tests/features/environment.py b/tests/features/environment.py new file mode 100644 index 0000000..049c2f2 --- /dev/null +++ b/tests/features/environment.py @@ -0,0 +1,192 @@ +import copy +import os +import sys +import db_utils as dbutils +import fixture_utils as fixutils +import pexpect +import tempfile +import shutil +import signal + + +from steps import wrappers + + +def before_all(context): + """Set env parameters.""" + env_old = copy.deepcopy(dict(os.environ)) + os.environ["LINES"] = "100" + os.environ["COLUMNS"] = "100" + os.environ["PAGER"] = "cat" + os.environ["EDITOR"] = "ex" + os.environ["VISUAL"] = "ex" + os.environ["PROMPT_TOOLKIT_NO_CPR"] = "1" + + context.package_root = os.path.abspath( + os.path.dirname(os.path.dirname(os.path.dirname(__file__))) + ) + fixture_dir = os.path.join(context.package_root, "tests/features/fixture_data") + + print("package root:", context.package_root) + print("fixture dir:", fixture_dir) + + os.environ["COVERAGE_PROCESS_START"] = os.path.join( + context.package_root, ".coveragerc" + ) + + context.exit_sent = False + + vi = "_".join([str(x) for x in sys.version_info[:3]]) + db_name = context.config.userdata.get("pg_test_db", "pgcli_behave_tests") + db_name_full = "{0}_{1}".format(db_name, vi) + + # Store get params from config. + context.conf = { + "host": context.config.userdata.get( + "pg_test_host", os.getenv("PGHOST", "localhost") + ), + "user": context.config.userdata.get( + "pg_test_user", os.getenv("PGUSER", "postgres") + ), + "pass": context.config.userdata.get( + "pg_test_pass", os.getenv("PGPASSWORD", None) + ), + "port": context.config.userdata.get( + "pg_test_port", os.getenv("PGPORT", "5432") + ), + "cli_command": ( + context.config.userdata.get("pg_cli_command", None) + or '{python} -c "{startup}"'.format( + python=sys.executable, + startup="; ".join( + [ + "import coverage", + "coverage.process_startup()", + "import pgcli.main", + "pgcli.main.cli()", + ] + ), + ) + ), + "dbname": db_name_full, + "dbname_tmp": db_name_full + "_tmp", + "vi": vi, + "pager_boundary": "---boundary---", + } + os.environ["PAGER"] = "{0} {1} {2}".format( + sys.executable, + os.path.join(context.package_root, "tests/features/wrappager.py"), + context.conf["pager_boundary"], + ) + + # Store old env vars. + context.pgenv = { + "PGDATABASE": os.environ.get("PGDATABASE", None), + "PGUSER": os.environ.get("PGUSER", None), + "PGHOST": os.environ.get("PGHOST", None), + "PGPASSWORD": os.environ.get("PGPASSWORD", None), + "PGPORT": os.environ.get("PGPORT", None), + "XDG_CONFIG_HOME": os.environ.get("XDG_CONFIG_HOME", None), + "PGSERVICEFILE": os.environ.get("PGSERVICEFILE", None), + } + + # Set new env vars. + os.environ["PGDATABASE"] = context.conf["dbname"] + os.environ["PGUSER"] = context.conf["user"] + os.environ["PGHOST"] = context.conf["host"] + os.environ["PGPORT"] = context.conf["port"] + os.environ["PGSERVICEFILE"] = os.path.join(fixture_dir, "mock_pg_service.conf") + + if context.conf["pass"]: + os.environ["PGPASSWORD"] = context.conf["pass"] + else: + if "PGPASSWORD" in os.environ: + del os.environ["PGPASSWORD"] + + context.cn = dbutils.create_db( + context.conf["host"], + context.conf["user"], + context.conf["pass"], + context.conf["dbname"], + context.conf["port"], + ) + + context.fixture_data = fixutils.read_fixture_files() + + # use temporary directory as config home + context.env_config_home = tempfile.mkdtemp(prefix="pgcli_home_") + os.environ["XDG_CONFIG_HOME"] = context.env_config_home + show_env_changes(env_old, dict(os.environ)) + + +def show_env_changes(env_old, env_new): + """Print out all test-specific env values.""" + print("--- os.environ changed values: ---") + all_keys = set(list(env_old.keys()) + list(env_new.keys())) + for k in sorted(all_keys): + old_value = env_old.get(k, "") + new_value = env_new.get(k, "") + if new_value and old_value != new_value: + print('{}="{}"'.format(k, new_value)) + print("-" * 20) + + +def after_all(context): + """ + Unset env parameters. + """ + dbutils.close_cn(context.cn) + dbutils.drop_db( + context.conf["host"], + context.conf["user"], + context.conf["pass"], + context.conf["dbname"], + context.conf["port"], + ) + + # Remove temp config direcotry + shutil.rmtree(context.env_config_home) + + # Restore env vars. + for k, v in context.pgenv.items(): + if k in os.environ and v is None: + del os.environ[k] + elif v: + os.environ[k] = v + + +def before_step(context, _): + context.atprompt = False + + +def before_scenario(context, scenario): + if scenario.name == "list databases": + # not using the cli for that + return + wrappers.run_cli(context) + wrappers.wait_prompt(context) + + +def after_scenario(context, scenario): + """Cleans up after each scenario completes.""" + if hasattr(context, "cli") and context.cli and not context.exit_sent: + # Quit nicely. + if not context.atprompt: + dbname = context.currentdb + context.cli.expect_exact("{0}> ".format(dbname), timeout=15) + context.cli.sendcontrol("c") + context.cli.sendcontrol("d") + try: + context.cli.expect_exact(pexpect.EOF, timeout=15) + except pexpect.TIMEOUT: + print("--- after_scenario {}: kill cli".format(scenario.name)) + context.cli.kill(signal.SIGKILL) + if hasattr(context, "tmpfile_sql_help") and context.tmpfile_sql_help: + context.tmpfile_sql_help.close() + context.tmpfile_sql_help = None + + +# # TODO: uncomment to debug a failure +# def after_step(context, step): +# if step.status == "failed": +# import pdb; pdb.set_trace() diff --git a/tests/features/expanded.feature b/tests/features/expanded.feature new file mode 100644 index 0000000..4f381f8 --- /dev/null +++ b/tests/features/expanded.feature @@ -0,0 +1,29 @@ +Feature: expanded mode: + on, off, auto + + Scenario: expanded on + When we prepare the test data + and we set expanded on + and we select from table + then we see expanded data selected + when we drop table + then we confirm the destructive warning + then we see table dropped + + Scenario: expanded off + When we prepare the test data + and we set expanded off + and we select from table + then we see nonexpanded data selected + when we drop table + then we confirm the destructive warning + then we see table dropped + + Scenario: expanded auto + When we prepare the test data + and we set expanded auto + and we select from table + then we see auto data selected + when we drop table + then we confirm the destructive warning + then we see table dropped diff --git a/tests/features/fixture_data/help.txt b/tests/features/fixture_data/help.txt new file mode 100644 index 0000000..bebb976 --- /dev/null +++ b/tests/features/fixture_data/help.txt @@ -0,0 +1,25 @@ ++--------------------------+------------------------------------------------+ +| Command | Description | +|--------------------------+------------------------------------------------| +| \# | Refresh auto-completions. | +| \? | Show Help. | +| \T [format] | Change the table format used to output results | +| \c[onnect] database_name | Change to a new database. | +| \d [pattern] | List or describe tables, views and sequences. | +| \dT[S+] [pattern] | List data types | +| \df[+] [pattern] | List functions. | +| \di[+] [pattern] | List indexes. | +| \dn[+] [pattern] | List schemas. | +| \ds[+] [pattern] | List sequences. | +| \dt[+] [pattern] | List tables. | +| \du[+] [pattern] | List roles. | +| \dv[+] [pattern] | List views. | +| \e [file] | Edit the query with external editor. | +| \l | List databases. | +| \n[+] [name] | List or execute named queries. | +| \nd [name [query]] | Delete a named query. | +| \ns name query | Save a named query. | +| \refresh | Refresh auto-completions. | +| \timing | Toggle timing of commands. | +| \x | Toggle expanded output. | ++--------------------------+------------------------------------------------+ diff --git a/tests/features/fixture_data/help_commands.txt b/tests/features/fixture_data/help_commands.txt new file mode 100644 index 0000000..e076661 --- /dev/null +++ b/tests/features/fixture_data/help_commands.txt @@ -0,0 +1,64 @@ +Command +Description +\# +Refresh auto-completions. +\? +Show Commands. +\T [format] +Change the table format used to output results +\c[onnect] database_name +Change to a new database. +\copy [tablename] to/from [filename] +Copy data between a file and a table. +\d[+] [pattern] +List or describe tables, views and sequences. +\dT[S+] [pattern] +List data types +\db[+] [pattern] +List tablespaces. +\df[+] [pattern] +List functions. +\di[+] [pattern] +List indexes. +\dm[+] [pattern] +List materialized views. +\dn[+] [pattern] +List schemas. +\ds[+] [pattern] +List sequences. +\dt[+] [pattern] +List tables. +\du[+] [pattern] +List roles. +\dv[+] [pattern] +List views. +\dx[+] [pattern] +List extensions. +\e [file] +Edit the query with external editor. +\h +Show SQL syntax and help. +\i filename +Execute commands from file. +\l +List databases. +\n[+] [name] [param1 param2 ...] +List or execute named queries. +\nd [name] +Delete a named query. +\ns name query +Save a named query. +\o [filename] +Send all query results to file. +\pager [command] +Set PAGER. Print the query results via PAGER. +\pset [key] [value] +A limited version of traditional \pset +\refresh +Refresh auto-completions. +\sf[+] FUNCNAME +Show a function's definition. +\timing +Toggle timing of commands. +\x +Toggle expanded output. diff --git a/tests/features/fixture_data/mock_pg_service.conf b/tests/features/fixture_data/mock_pg_service.conf new file mode 100644 index 0000000..15f9811 --- /dev/null +++ b/tests/features/fixture_data/mock_pg_service.conf @@ -0,0 +1,4 @@ +[mock_postgres] +dbname=postgres +host=localhost +user=postgres diff --git a/tests/features/fixture_utils.py b/tests/features/fixture_utils.py new file mode 100644 index 0000000..16f123a --- /dev/null +++ b/tests/features/fixture_utils.py @@ -0,0 +1,28 @@ +import os +import codecs + + +def read_fixture_lines(filename): + """ + Read lines of text from file. + :param filename: string name + :return: list of strings + """ + lines = [] + for line in codecs.open(filename, "rb", encoding="utf-8"): + lines.append(line.strip()) + return lines + + +def read_fixture_files(): + """Read all files inside fixture_data directory.""" + current_dir = os.path.dirname(__file__) + fixture_dir = os.path.join(current_dir, "fixture_data/") + print("reading fixture data: {}".format(fixture_dir)) + fixture_dict = {} + for filename in os.listdir(fixture_dir): + if filename not in [".", ".."]: + fullname = os.path.join(fixture_dir, filename) + fixture_dict[filename] = read_fixture_lines(fullname) + + return fixture_dict diff --git a/tests/features/iocommands.feature b/tests/features/iocommands.feature new file mode 100644 index 0000000..dad7d10 --- /dev/null +++ b/tests/features/iocommands.feature @@ -0,0 +1,17 @@ +Feature: I/O commands + + Scenario: edit sql in file with external editor + When we start external editor providing a file name + and we type sql in the editor + and we exit the editor + then we see dbcli prompt + and we see the sql in prompt + + Scenario: tee output from query + When we tee output + and we wait for prompt + and we query "select 123456" + and we wait for prompt + and we stop teeing output + and we wait for prompt + then we see 123456 in tee output diff --git a/tests/features/named_queries.feature b/tests/features/named_queries.feature new file mode 100644 index 0000000..74201b9 --- /dev/null +++ b/tests/features/named_queries.feature @@ -0,0 +1,10 @@ +Feature: named queries: + save, use and delete named queries + + Scenario: save, use and delete named queries + When we connect to test database + then we see database connected + when we save a named query + then we see the named query saved + when we delete a named query + then we see the named query deleted diff --git a/tests/features/specials.feature b/tests/features/specials.feature new file mode 100644 index 0000000..63c5cdc --- /dev/null +++ b/tests/features/specials.feature @@ -0,0 +1,6 @@ +Feature: Special commands + + Scenario: run refresh command + When we refresh completions + and we wait for prompt + then we see completions refresh started diff --git a/tests/features/steps/__init__.py b/tests/features/steps/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/features/steps/auto_vertical.py b/tests/features/steps/auto_vertical.py new file mode 100644 index 0000000..1643ea5 --- /dev/null +++ b/tests/features/steps/auto_vertical.py @@ -0,0 +1,99 @@ +from textwrap import dedent +from behave import then, when +import wrappers + + +@when("we run dbcli with {arg}") +def step_run_cli_with_arg(context, arg): + wrappers.run_cli(context, run_args=arg.split("=")) + + +@when("we execute a small query") +def step_execute_small_query(context): + context.cli.sendline("select 1") + + +@when("we execute a large query") +def step_execute_large_query(context): + context.cli.sendline("select {}".format(",".join([str(n) for n in range(1, 50)]))) + + +@then("we see small results in horizontal format") +def step_see_small_results(context): + wrappers.expect_pager( + context, + dedent( + """\ + +------------+\r + | ?column? |\r + |------------|\r + | 1 |\r + +------------+\r + SELECT 1\r + """ + ), + timeout=5, + ) + + +@then("we see large results in vertical format") +def step_see_large_results(context): + wrappers.expect_pager( + context, + dedent( + """\ + -[ RECORD 1 ]-------------------------\r + ?column? | 1\r + ?column? | 2\r + ?column? | 3\r + ?column? | 4\r + ?column? | 5\r + ?column? | 6\r + ?column? | 7\r + ?column? | 8\r + ?column? | 9\r + ?column? | 10\r + ?column? | 11\r + ?column? | 12\r + ?column? | 13\r + ?column? | 14\r + ?column? | 15\r + ?column? | 16\r + ?column? | 17\r + ?column? | 18\r + ?column? | 19\r + ?column? | 20\r + ?column? | 21\r + ?column? | 22\r + ?column? | 23\r + ?column? | 24\r + ?column? | 25\r + ?column? | 26\r + ?column? | 27\r + ?column? | 28\r + ?column? | 29\r + ?column? | 30\r + ?column? | 31\r + ?column? | 32\r + ?column? | 33\r + ?column? | 34\r + ?column? | 35\r + ?column? | 36\r + ?column? | 37\r + ?column? | 38\r + ?column? | 39\r + ?column? | 40\r + ?column? | 41\r + ?column? | 42\r + ?column? | 43\r + ?column? | 44\r + ?column? | 45\r + ?column? | 46\r + ?column? | 47\r + ?column? | 48\r + ?column? | 49\r + SELECT 1\r + """ + ), + timeout=5, + ) diff --git a/tests/features/steps/basic_commands.py b/tests/features/steps/basic_commands.py new file mode 100644 index 0000000..1069662 --- /dev/null +++ b/tests/features/steps/basic_commands.py @@ -0,0 +1,147 @@ +""" +Steps for behavioral style tests are defined in this module. +Each step is defined by the string decorating it. +This string is used to call the step in "*.feature" file. +""" + +import pexpect +import subprocess +import tempfile + +from behave import when, then +from textwrap import dedent +import wrappers + + +@when("we list databases") +def step_list_databases(context): + cmd = ["pgcli", "--list"] + context.cmd_output = subprocess.check_output(cmd, cwd=context.package_root) + + +@then("we see list of databases") +def step_see_list_databases(context): + assert b"List of databases" in context.cmd_output + assert b"postgres" in context.cmd_output + context.cmd_output = None + + +@when("we run dbcli") +def step_run_cli(context): + wrappers.run_cli(context) + + +@when("we launch dbcli using {arg}") +def step_run_cli_using_arg(context, arg): + prompt_check = False + currentdb = None + if arg == "--username": + arg = "--username={}".format(context.conf["user"]) + if arg == "--user": + arg = "--user={}".format(context.conf["user"]) + if arg == "--port": + arg = "--port={}".format(context.conf["port"]) + if arg == "--password": + arg = "--password" + prompt_check = False + # This uses the mock_pg_service.conf file in fixtures folder. + if arg == "dsn_password": + arg = "service=mock_postgres --password" + prompt_check = False + currentdb = "postgres" + wrappers.run_cli( + context, run_args=[arg], prompt_check=prompt_check, currentdb=currentdb + ) + + +@when("we wait for prompt") +def step_wait_prompt(context): + wrappers.wait_prompt(context) + + +@when('we send "ctrl + d"') +def step_ctrl_d(context): + """ + Send Ctrl + D to hopefully exit. + """ + # turn off pager before exiting + context.cli.sendline("\pset pager off") + wrappers.wait_prompt(context) + context.cli.sendcontrol("d") + context.cli.expect(pexpect.EOF, timeout=15) + context.exit_sent = True + + +@when('we send "\?" command') +def step_send_help(context): + """ + Send \? to see help. + """ + context.cli.sendline("\?") + + +@when("we send partial select command") +def step_send_partial_select_command(context): + """ + Send `SELECT a` to see completion. + """ + context.cli.sendline("SELECT a") + + +@then("we see error message") +def step_see_error_message(context): + wrappers.expect_exact(context, 'column "a" does not exist', timeout=2) + + +@when("we send source command") +def step_send_source_command(context): + context.tmpfile_sql_help = tempfile.NamedTemporaryFile(prefix="pgcli_") + context.tmpfile_sql_help.write(b"\?") + context.tmpfile_sql_help.flush() + context.cli.sendline("\i {0}".format(context.tmpfile_sql_help.name)) + wrappers.expect_exact(context, context.conf["pager_boundary"] + "\r\n", timeout=5) + + +@when("we run query to check application_name") +def step_check_application_name(context): + context.cli.sendline( + "SELECT 'found' FROM pg_stat_activity WHERE application_name = 'pgcli' HAVING COUNT(*) > 0;" + ) + + +@then("we see found") +def step_see_found(context): + wrappers.expect_exact( + context, + context.conf["pager_boundary"] + + "\r" + + dedent( + """ + +------------+\r + | ?column? |\r + |------------|\r + | found |\r + +------------+\r + SELECT 1\r + """ + ) + + context.conf["pager_boundary"], + timeout=5, + ) + + +@then("we confirm the destructive warning") +def step_confirm_destructive_command(context): + """Confirm destructive command.""" + wrappers.expect_exact( + context, + "You're about to run a destructive command.\r\nDo you want to proceed? (y/n):", + timeout=2, + ) + context.cli.sendline("y") + + +@then("we send password") +def step_send_password(context): + wrappers.expect_exact(context, "Password for", timeout=5) + context.cli.sendline(context.conf["pass"] or "DOES NOT MATTER") diff --git a/tests/features/steps/crud_database.py b/tests/features/steps/crud_database.py new file mode 100644 index 0000000..3fd8b7a --- /dev/null +++ b/tests/features/steps/crud_database.py @@ -0,0 +1,93 @@ +""" +Steps for behavioral style tests are defined in this module. +Each step is defined by the string decorating it. +This string is used to call the step in "*.feature" file. +""" +import pexpect + +from behave import when, then +import wrappers + + +@when("we create database") +def step_db_create(context): + """ + Send create database. + """ + context.cli.sendline("create database {0};".format(context.conf["dbname_tmp"])) + + context.response = {"database_name": context.conf["dbname_tmp"]} + + +@when("we drop database") +def step_db_drop(context): + """ + Send drop database. + """ + context.cli.sendline("drop database {0};".format(context.conf["dbname_tmp"])) + + +@when("we connect to test database") +def step_db_connect_test(context): + """ + Send connect to database. + """ + db_name = context.conf["dbname"] + context.cli.sendline("\\connect {0}".format(db_name)) + + +@when("we connect to dbserver") +def step_db_connect_dbserver(context): + """ + Send connect to database. + """ + context.cli.sendline("\\connect postgres") + context.currentdb = "postgres" + + +@then("dbcli exits") +def step_wait_exit(context): + """ + Make sure the cli exits. + """ + wrappers.expect_exact(context, pexpect.EOF, timeout=5) + + +@then("we see dbcli prompt") +def step_see_prompt(context): + """ + Wait to see the prompt. + """ + db_name = getattr(context, "currentdb", context.conf["dbname"]) + wrappers.expect_exact(context, "{0}> ".format(db_name), timeout=5) + context.atprompt = True + + +@then("we see help output") +def step_see_help(context): + for expected_line in context.fixture_data["help_commands.txt"]: + wrappers.expect_exact(context, expected_line, timeout=2) + + +@then("we see database created") +def step_see_db_created(context): + """ + Wait to see create database output. + """ + wrappers.expect_pager(context, "CREATE DATABASE\r\n", timeout=5) + + +@then("we see database dropped") +def step_see_db_dropped(context): + """ + Wait to see drop database output. + """ + wrappers.expect_pager(context, "DROP DATABASE\r\n", timeout=2) + + +@then("we see database connected") +def step_see_db_connected(context): + """ + Wait to see drop database output. + """ + wrappers.expect_exact(context, "You are now connected to database", timeout=2) diff --git a/tests/features/steps/crud_table.py b/tests/features/steps/crud_table.py new file mode 100644 index 0000000..0375883 --- /dev/null +++ b/tests/features/steps/crud_table.py @@ -0,0 +1,118 @@ +""" +Steps for behavioral style tests are defined in this module. +Each step is defined by the string decorating it. +This string is used to call the step in "*.feature" file. +""" + +from behave import when, then +from textwrap import dedent +import wrappers + + +@when("we create table") +def step_create_table(context): + """ + Send create table. + """ + context.cli.sendline("create table a(x text);") + + +@when("we insert into table") +def step_insert_into_table(context): + """ + Send insert into table. + """ + context.cli.sendline("""insert into a(x) values('xxx');""") + + +@when("we update table") +def step_update_table(context): + """ + Send insert into table. + """ + context.cli.sendline("""update a set x = 'yyy' where x = 'xxx';""") + + +@when("we select from table") +def step_select_from_table(context): + """ + Send select from table. + """ + context.cli.sendline("select * from a;") + + +@when("we delete from table") +def step_delete_from_table(context): + """ + Send deete from table. + """ + context.cli.sendline("""delete from a where x = 'yyy';""") + + +@when("we drop table") +def step_drop_table(context): + """ + Send drop table. + """ + context.cli.sendline("drop table a;") + + +@then("we see table created") +def step_see_table_created(context): + """ + Wait to see create table output. + """ + wrappers.expect_pager(context, "CREATE TABLE\r\n", timeout=2) + + +@then("we see record inserted") +def step_see_record_inserted(context): + """ + Wait to see insert output. + """ + wrappers.expect_pager(context, "INSERT 0 1\r\n", timeout=2) + + +@then("we see record updated") +def step_see_record_updated(context): + """ + Wait to see update output. + """ + wrappers.expect_pager(context, "UPDATE 1\r\n", timeout=2) + + +@then("we see data selected") +def step_see_data_selected(context): + """ + Wait to see select output. + """ + wrappers.expect_pager( + context, + dedent( + """\ + +-----+\r + | x |\r + |-----|\r + | yyy |\r + +-----+\r + SELECT 1\r + """ + ), + timeout=1, + ) + + +@then("we see record deleted") +def step_see_data_deleted(context): + """ + Wait to see delete output. + """ + wrappers.expect_pager(context, "DELETE 1\r\n", timeout=2) + + +@then("we see table dropped") +def step_see_table_dropped(context): + """ + Wait to see drop output. + """ + wrappers.expect_pager(context, "DROP TABLE\r\n", timeout=2) diff --git a/tests/features/steps/expanded.py b/tests/features/steps/expanded.py new file mode 100644 index 0000000..f34fcf0 --- /dev/null +++ b/tests/features/steps/expanded.py @@ -0,0 +1,70 @@ +"""Steps for behavioral style tests are defined in this module. + +Each step is defined by the string decorating it. This string is used +to call the step in "*.feature" file. + +""" + +from behave import when, then +from textwrap import dedent +import wrappers + + +@when("we prepare the test data") +def step_prepare_data(context): + """Create table, insert a record.""" + context.cli.sendline("drop table if exists a;") + wrappers.expect_exact( + context, + "You're about to run a destructive command.\r\nDo you want to proceed? (y/n):", + timeout=2, + ) + context.cli.sendline("y") + + wrappers.wait_prompt(context) + context.cli.sendline("create table a(x integer, y real, z numeric(10, 4));") + wrappers.expect_pager(context, "CREATE TABLE\r\n", timeout=2) + context.cli.sendline("""insert into a(x, y, z) values(1, 1.0, 1.0);""") + wrappers.expect_pager(context, "INSERT 0 1\r\n", timeout=2) + + +@when("we set expanded {mode}") +def step_set_expanded(context, mode): + """Set expanded to mode.""" + context.cli.sendline("\\" + "x {}".format(mode)) + wrappers.expect_exact(context, "Expanded display is", timeout=2) + wrappers.wait_prompt(context) + + +@then("we see {which} data selected") +def step_see_data(context, which): + """Select data from expanded test table.""" + if which == "expanded": + wrappers.expect_pager( + context, + dedent( + """\ + -[ RECORD 1 ]-------------------------\r + x | 1\r + y | 1.0\r + z | 1.0000\r + SELECT 1\r + """ + ), + timeout=1, + ) + else: + wrappers.expect_pager( + context, + dedent( + """\ + +-----+-----+--------+\r + | x | y | z |\r + |-----+-----+--------|\r + | 1 | 1.0 | 1.0000 |\r + +-----+-----+--------+\r + SELECT 1\r + """ + ), + timeout=1, + ) diff --git a/tests/features/steps/iocommands.py b/tests/features/steps/iocommands.py new file mode 100644 index 0000000..613aeb2 --- /dev/null +++ b/tests/features/steps/iocommands.py @@ -0,0 +1,80 @@ +import os +import os.path + +from behave import when, then +import wrappers + + +@when("we start external editor providing a file name") +def step_edit_file(context): + """Edit file with external editor.""" + context.editor_file_name = os.path.join( + context.package_root, "test_file_{0}.sql".format(context.conf["vi"]) + ) + if os.path.exists(context.editor_file_name): + os.remove(context.editor_file_name) + context.cli.sendline("\e {0}".format(os.path.basename(context.editor_file_name))) + wrappers.expect_exact( + context, 'Entering Ex mode. Type "visual" to go to Normal mode.', timeout=2 + ) + wrappers.expect_exact(context, ":", timeout=2) + + +@when("we type sql in the editor") +def step_edit_type_sql(context): + context.cli.sendline("i") + context.cli.sendline("select * from abc") + context.cli.sendline(".") + wrappers.expect_exact(context, ":", timeout=2) + + +@when("we exit the editor") +def step_edit_quit(context): + context.cli.sendline("x") + wrappers.expect_exact(context, "written", timeout=2) + + +@then("we see the sql in prompt") +def step_edit_done_sql(context): + for match in "select * from abc".split(" "): + wrappers.expect_exact(context, match, timeout=1) + # Cleanup the command line. + context.cli.sendcontrol("c") + # Cleanup the edited file. + if context.editor_file_name and os.path.exists(context.editor_file_name): + os.remove(context.editor_file_name) + context.atprompt = True + + +@when("we tee output") +def step_tee_ouptut(context): + context.tee_file_name = os.path.join( + context.package_root, "tee_file_{0}.sql".format(context.conf["vi"]) + ) + if os.path.exists(context.tee_file_name): + os.remove(context.tee_file_name) + context.cli.sendline("\o {0}".format(os.path.basename(context.tee_file_name))) + wrappers.expect_exact(context, context.conf["pager_boundary"] + "\r\n", timeout=5) + wrappers.expect_exact(context, "Writing to file", timeout=5) + wrappers.expect_exact(context, context.conf["pager_boundary"] + "\r\n", timeout=5) + wrappers.expect_exact(context, "Time", timeout=5) + + +@when('we query "select 123456"') +def step_query_select_123456(context): + context.cli.sendline("select 123456") + + +@when("we stop teeing output") +def step_notee_output(context): + context.cli.sendline("\o") + wrappers.expect_exact(context, "Time", timeout=5) + + +@then("we see 123456 in tee output") +def step_see_123456_in_ouput(context): + with open(context.tee_file_name) as f: + assert "123456" in f.read() + if os.path.exists(context.tee_file_name): + os.remove(context.tee_file_name) + context.atprompt = True diff --git a/tests/features/steps/named_queries.py b/tests/features/steps/named_queries.py new file mode 100644 index 0000000..3f52859 --- /dev/null +++ b/tests/features/steps/named_queries.py @@ -0,0 +1,57 @@ +""" +Steps for behavioral style tests are defined in this module. +Each step is defined by the string decorating it. +This string is used to call the step in "*.feature" file. +""" + +from behave import when, then +import wrappers + + +@when("we save a named query") +def step_save_named_query(context): + """ + Send \ns command + """ + context.cli.sendline("\\ns foo SELECT 12345") + + +@when("we use a named query") +def step_use_named_query(context): + """ + Send \n command + """ + context.cli.sendline("\\n foo") + + +@when("we delete a named query") +def step_delete_named_query(context): + """ + Send \nd command + """ + context.cli.sendline("\\nd foo") + + +@then("we see the named query saved") +def step_see_named_query_saved(context): + """ + Wait to see query saved. + """ + wrappers.expect_exact(context, "Saved.", timeout=2) + + +@then("we see the named query executed") +def step_see_named_query_executed(context): + """ + Wait to see select output. + """ + wrappers.expect_exact(context, "12345", timeout=1) + wrappers.expect_exact(context, "SELECT 1", timeout=1) + + +@then("we see the named query deleted") +def step_see_named_query_deleted(context): + """ + Wait to see query deleted. + """ + wrappers.expect_pager(context, "foo: Deleted\r\n", timeout=1) diff --git a/tests/features/steps/specials.py b/tests/features/steps/specials.py new file mode 100644 index 0000000..813292c --- /dev/null +++ b/tests/features/steps/specials.py @@ -0,0 +1,26 @@ +""" +Steps for behavioral style tests are defined in this module. +Each step is defined by the string decorating it. +This string is used to call the step in "*.feature" file. +""" + +from behave import when, then +import wrappers + + +@when("we refresh completions") +def step_refresh_completions(context): + """ + Send refresh command. + """ + context.cli.sendline("\\refresh") + + +@then("we see completions refresh started") +def step_see_refresh_started(context): + """ + Wait to see refresh output. + """ + wrappers.expect_pager( + context, "Auto-completion refresh started in the background.\r\n", timeout=2 + ) diff --git a/tests/features/steps/wrappers.py b/tests/features/steps/wrappers.py new file mode 100644 index 0000000..e0f5a20 --- /dev/null +++ b/tests/features/steps/wrappers.py @@ -0,0 +1,67 @@ +import re +import pexpect +from pgcli.main import COLOR_CODE_REGEX +import textwrap + +try: + from StringIO import StringIO +except ImportError: + from io import StringIO + + +def expect_exact(context, expected, timeout): + timedout = False + try: + context.cli.expect_exact(expected, timeout=timeout) + except pexpect.TIMEOUT: + timedout = True + if timedout: + # Strip color codes out of the output. + actual = re.sub(r"\x1b\[([0-9A-Za-z;?])+[m|K]?", "", context.cli.before) + raise Exception( + textwrap.dedent( + """\ + Expected: + --- + {0!r} + --- + Actual: + --- + {1!r} + --- + Full log: + --- + {2!r} + --- + """ + ).format(expected, actual, context.logfile.getvalue()) + ) + + +def expect_pager(context, expected, timeout): + expect_exact( + context, + "{0}\r\n{1}{0}\r\n".format(context.conf["pager_boundary"], expected), + timeout=timeout, + ) + + +def run_cli(context, run_args=None, prompt_check=True, currentdb=None): + """Run the process using pexpect.""" + run_args = run_args or [] + cli_cmd = context.conf.get("cli_command") + cmd_parts = [cli_cmd] + run_args + cmd = " ".join(cmd_parts) + context.cli = pexpect.spawnu(cmd, cwd=context.package_root) + context.logfile = StringIO() + context.cli.logfile = context.logfile + context.exit_sent = False + context.currentdb = currentdb or context.conf["dbname"] + context.cli.sendline("\pset pager always") + if prompt_check: + wait_prompt(context) + + +def wait_prompt(context): + """Make sure prompt is displayed.""" + expect_exact(context, "{0}> ".format(context.conf["dbname"]), timeout=5) diff --git a/tests/features/wrappager.py b/tests/features/wrappager.py new file mode 100755 index 0000000..51d4909 --- /dev/null +++ b/tests/features/wrappager.py @@ -0,0 +1,16 @@ +#!/usr/bin/env python +import sys + + +def wrappager(boundary): + print(boundary) + while 1: + buf = sys.stdin.read(2048) + if not buf: + break + sys.stdout.write(buf) + print(boundary) + + +if __name__ == "__main__": + wrappager(sys.argv[1]) diff --git a/tests/metadata.py b/tests/metadata.py new file mode 100644 index 0000000..2f89ea2 --- /dev/null +++ b/tests/metadata.py @@ -0,0 +1,255 @@ +from functools import partial +from itertools import product +from pgcli.packages.parseutils.meta import FunctionMetadata, ForeignKey +from prompt_toolkit.completion import Completion +from prompt_toolkit.document import Document +from mock import Mock +import pytest + +parametrize = pytest.mark.parametrize + +qual = ["if_more_than_one_table", "always"] +no_qual = ["if_more_than_one_table", "never"] + + +def escape(name): + if not name.islower() or name in ("select", "localtimestamp"): + return '"' + name + '"' + return name + + +def completion(display_meta, text, pos=0): + return Completion(text, start_position=pos, display_meta=display_meta) + + +def function(text, pos=0, display=None): + return Completion( + text, display=display or text, start_position=pos, display_meta="function" + ) + + +def get_result(completer, text, position=None): + position = len(text) if position is None else position + return completer.get_completions( + Document(text=text, cursor_position=position), Mock() + ) + + +def result_set(completer, text, position=None): + return set(get_result(completer, text, position)) + + +# The code below is quivalent to +# def schema(text, pos=0): +# return completion('schema', text, pos) +# and so on +schema = partial(completion, "schema") +table = partial(completion, "table") +view = partial(completion, "view") +column = partial(completion, "column") +keyword = partial(completion, "keyword") +datatype = partial(completion, "datatype") +alias = partial(completion, "table alias") +name_join = partial(completion, "name join") +fk_join = partial(completion, "fk join") +join = partial(completion, "join") + + +def wildcard_expansion(cols, pos=-1): + return Completion(cols, start_position=pos, display_meta="columns", display="*") + + +class MetaData(object): + def __init__(self, metadata): + self.metadata = metadata + + def builtin_functions(self, pos=0): + return [function(f, pos) for f in self.completer.functions] + + def builtin_datatypes(self, pos=0): + return [datatype(dt, pos) for dt in self.completer.datatypes] + + def keywords(self, pos=0): + return [keyword(kw, pos) for kw in self.completer.keywords_tree.keys()] + + def specials(self, pos=0): + return [ + Completion(text=k, start_position=pos, display_meta=v.description) + for k, v in self.completer.pgspecial.commands.items() + ] + + def columns(self, tbl, parent="public", typ="tables", pos=0): + if typ == "functions": + fun = [x for x in self.metadata[typ][parent] if x[0] == tbl][0] + cols = fun[1] + else: + cols = self.metadata[typ][parent][tbl] + return [column(escape(col), pos) for col in cols] + + def datatypes(self, parent="public", pos=0): + return [ + datatype(escape(x), pos) + for x in self.metadata.get("datatypes", {}).get(parent, []) + ] + + def tables(self, parent="public", pos=0): + return [ + table(escape(x), pos) + for x in self.metadata.get("tables", {}).get(parent, []) + ] + + def views(self, parent="public", pos=0): + return [ + view(escape(x), pos) for x in self.metadata.get("views", {}).get(parent, []) + ] + + def functions(self, parent="public", pos=0): + return [ + function( + escape(x[0]) + + "(" + + ", ".join( + arg_name + " := " + for (arg_name, arg_mode) in zip(x[1], x[3]) + if arg_mode in ("b", "i") + ) + + ")", + pos, + escape(x[0]) + + "(" + + ", ".join( + arg_name + for (arg_name, arg_mode) in zip(x[1], x[3]) + if arg_mode in ("b", "i") + ) + + ")", + ) + for x in self.metadata.get("functions", {}).get(parent, []) + ] + + def schemas(self, pos=0): + schemas = set(sch for schs in self.metadata.values() for sch in schs) + return [schema(escape(s), pos=pos) for s in schemas] + + def functions_and_keywords(self, parent="public", pos=0): + return ( + self.functions(parent, pos) + + self.builtin_functions(pos) + + self.keywords(pos) + ) + + # Note that the filtering parameters here only apply to the columns + def columns_functions_and_keywords(self, tbl, parent="public", typ="tables", pos=0): + return self.functions_and_keywords(pos=pos) + self.columns( + tbl, parent, typ, pos + ) + + def from_clause_items(self, parent="public", pos=0): + return ( + self.functions(parent, pos) + + self.views(parent, pos) + + self.tables(parent, pos) + ) + + def schemas_and_from_clause_items(self, parent="public", pos=0): + return self.from_clause_items(parent, pos) + self.schemas(pos) + + def types(self, parent="public", pos=0): + return self.datatypes(parent, pos) + self.tables(parent, pos) + + @property + def completer(self): + return self.get_completer() + + def get_completers(self, casing): + """ + Returns a function taking three bools `casing`, `filtr`, `aliasing` and + the list `qualify`, all defaulting to None. + Returns a list of completers. + These parameters specify the allowed values for the corresponding + completer parameters, `None` meaning any, i.e. (None, None, None, None) + results in all 24 possible completers, whereas e.g. + (True, False, True, ['never']) results in the one completer with + casing, without `search_path` filtering of objects, with table + aliasing, and without column qualification. + """ + + def _cfg(_casing, filtr, aliasing, qualify): + cfg = {"settings": {}} + if _casing: + cfg["casing"] = casing + cfg["settings"]["search_path_filter"] = filtr + cfg["settings"]["generate_aliases"] = aliasing + cfg["settings"]["qualify_columns"] = qualify + return cfg + + def _cfgs(casing, filtr, aliasing, qualify): + casings = [True, False] if casing is None else [casing] + filtrs = [True, False] if filtr is None else [filtr] + aliases = [True, False] if aliasing is None else [aliasing] + qualifys = qualify or ["always", "if_more_than_one_table", "never"] + return [_cfg(*p) for p in product(casings, filtrs, aliases, qualifys)] + + def completers(casing=None, filtr=None, aliasing=None, qualify=None): + get_comp = self.get_completer + return [get_comp(**c) for c in _cfgs(casing, filtr, aliasing, qualify)] + + return completers + + def _make_col(self, sch, tbl, col): + defaults = self.metadata.get("defaults", {}).get(sch, {}) + return (sch, tbl, col, "text", (tbl, col) in defaults, defaults.get((tbl, col))) + + def get_completer(self, settings=None, casing=None): + metadata = self.metadata + from pgcli.pgcompleter import PGCompleter + from pgspecial import PGSpecial + + comp = PGCompleter( + smart_completion=True, settings=settings, pgspecial=PGSpecial() + ) + + schemata, tables, tbl_cols, views, view_cols = [], [], [], [], [] + + for sch, tbls in metadata["tables"].items(): + schemata.append(sch) + + for tbl, cols in tbls.items(): + tables.append((sch, tbl)) + # Let all columns be text columns + tbl_cols.extend([self._make_col(sch, tbl, col) for col in cols]) + + for sch, tbls in metadata.get("views", {}).items(): + for tbl, cols in tbls.items(): + views.append((sch, tbl)) + # Let all columns be text columns + view_cols.extend([self._make_col(sch, tbl, col) for col in cols]) + + functions = [ + FunctionMetadata(sch, *func_meta, arg_defaults=None) + for sch, funcs in metadata["functions"].items() + for func_meta in funcs + ] + + datatypes = [ + (sch, typ) + for sch, datatypes in metadata["datatypes"].items() + for typ in datatypes + ] + + foreignkeys = [ + ForeignKey(*fk) for fks in metadata["foreignkeys"].values() for fk in fks + ] + + comp.extend_schemata(schemata) + comp.extend_relations(tables, kind="tables") + comp.extend_relations(views, kind="views") + comp.extend_columns(tbl_cols, kind="tables") + comp.extend_columns(view_cols, kind="views") + comp.extend_functions(functions) + comp.extend_datatypes(datatypes) + comp.extend_foreignkeys(foreignkeys) + comp.set_search_path(["public"]) + comp.extend_casing(casing or []) + + return comp diff --git a/tests/parseutils/test_ctes.py b/tests/parseutils/test_ctes.py new file mode 100644 index 0000000..3e89cca --- /dev/null +++ b/tests/parseutils/test_ctes.py @@ -0,0 +1,137 @@ +import pytest +from sqlparse import parse +from pgcli.packages.parseutils.ctes import ( + token_start_pos, + extract_ctes, + extract_column_names as _extract_column_names, +) + + +def extract_column_names(sql): + p = parse(sql)[0] + return _extract_column_names(p) + + +def test_token_str_pos(): + sql = "SELECT * FROM xxx" + p = parse(sql)[0] + idx = p.token_index(p.tokens[-1]) + assert token_start_pos(p.tokens, idx) == len("SELECT * FROM ") + + sql = "SELECT * FROM \nxxx" + p = parse(sql)[0] + idx = p.token_index(p.tokens[-1]) + assert token_start_pos(p.tokens, idx) == len("SELECT * FROM \n") + + +def test_single_column_name_extraction(): + sql = "SELECT abc FROM xxx" + assert extract_column_names(sql) == ("abc",) + + +def test_aliased_single_column_name_extraction(): + sql = "SELECT abc def FROM xxx" + assert extract_column_names(sql) == ("def",) + + +def test_aliased_expression_name_extraction(): + sql = "SELECT 99 abc FROM xxx" + assert extract_column_names(sql) == ("abc",) + + +def test_multiple_column_name_extraction(): + sql = "SELECT abc, def FROM xxx" + assert extract_column_names(sql) == ("abc", "def") + + +def test_missing_column_name_handled_gracefully(): + sql = "SELECT abc, 99 FROM xxx" + assert extract_column_names(sql) == ("abc",) + + sql = "SELECT abc, 99, def FROM xxx" + assert extract_column_names(sql) == ("abc", "def") + + +def test_aliased_multiple_column_name_extraction(): + sql = "SELECT abc def, ghi jkl FROM xxx" + assert extract_column_names(sql) == ("def", "jkl") + + +def test_table_qualified_column_name_extraction(): + sql = "SELECT abc.def, ghi.jkl FROM xxx" + assert extract_column_names(sql) == ("def", "jkl") + + +@pytest.mark.parametrize( + "sql", + [ + "INSERT INTO foo (x, y, z) VALUES (5, 6, 7) RETURNING x, y", + "DELETE FROM foo WHERE x > y RETURNING x, y", + "UPDATE foo SET x = 9 RETURNING x, y", + ], +) +def test_extract_column_names_from_returning_clause(sql): + assert extract_column_names(sql) == ("x", "y") + + +def test_simple_cte_extraction(): + sql = "WITH a AS (SELECT abc FROM xxx) SELECT * FROM a" + start_pos = len("WITH a AS ") + stop_pos = len("WITH a AS (SELECT abc FROM xxx)") + ctes, remainder = extract_ctes(sql) + + assert tuple(ctes) == (("a", ("abc",), start_pos, stop_pos),) + assert remainder.strip() == "SELECT * FROM a" + + +def test_cte_extraction_around_comments(): + sql = """--blah blah blah + WITH a AS (SELECT abc def FROM x) + SELECT * FROM a""" + start_pos = len( + """--blah blah blah + WITH a AS """ + ) + stop_pos = len( + """--blah blah blah + WITH a AS (SELECT abc def FROM x)""" + ) + + ctes, remainder = extract_ctes(sql) + assert tuple(ctes) == (("a", ("def",), start_pos, stop_pos),) + assert remainder.strip() == "SELECT * FROM a" + + +def test_multiple_cte_extraction(): + sql = """WITH + x AS (SELECT abc, def FROM x), + y AS (SELECT ghi, jkl FROM y) + SELECT * FROM a, b""" + + start1 = len( + """WITH + x AS """ + ) + + stop1 = len( + """WITH + x AS (SELECT abc, def FROM x)""" + ) + + start2 = len( + """WITH + x AS (SELECT abc, def FROM x), + y AS """ + ) + + stop2 = len( + """WITH + x AS (SELECT abc, def FROM x), + y AS (SELECT ghi, jkl FROM y)""" + ) + + ctes, remainder = extract_ctes(sql) + assert tuple(ctes) == ( + ("x", ("abc", "def"), start1, stop1), + ("y", ("ghi", "jkl"), start2, stop2), + ) diff --git a/tests/parseutils/test_function_metadata.py b/tests/parseutils/test_function_metadata.py new file mode 100644 index 0000000..0350e2a --- /dev/null +++ b/tests/parseutils/test_function_metadata.py @@ -0,0 +1,19 @@ +from pgcli.packages.parseutils.meta import FunctionMetadata + + +def test_function_metadata_eq(): + f1 = FunctionMetadata( + "s", "f", ["x"], ["integer"], [], "int", False, False, False, False, None + ) + f2 = FunctionMetadata( + "s", "f", ["x"], ["integer"], [], "int", False, False, False, False, None + ) + f3 = FunctionMetadata( + "s", "g", ["x"], ["integer"], [], "int", False, False, False, False, None + ) + assert f1 == f2 + assert f1 != f3 + assert not (f1 != f2) + assert not (f1 == f3) + assert hash(f1) == hash(f2) + assert hash(f1) != hash(f3) diff --git a/tests/parseutils/test_parseutils.py b/tests/parseutils/test_parseutils.py new file mode 100644 index 0000000..50bc889 --- /dev/null +++ b/tests/parseutils/test_parseutils.py @@ -0,0 +1,269 @@ +import pytest +from pgcli.packages.parseutils.tables import extract_tables +from pgcli.packages.parseutils.utils import find_prev_keyword, is_open_quote + + +def test_empty_string(): + tables = extract_tables("") + assert tables == () + + +def test_simple_select_single_table(): + tables = extract_tables("select * from abc") + assert tables == ((None, "abc", None, False),) + + +@pytest.mark.parametrize( + "sql", ['select * from "abc"."def"', 'select * from abc."def"'] +) +def test_simple_select_single_table_schema_qualified_quoted_table(sql): + tables = extract_tables(sql) + assert tables == (("abc", "def", '"def"', False),) + + +@pytest.mark.parametrize("sql", ["select * from abc.def", 'select * from "abc".def']) +def test_simple_select_single_table_schema_qualified(sql): + tables = extract_tables(sql) + assert tables == (("abc", "def", None, False),) + + +def test_simple_select_single_table_double_quoted(): + tables = extract_tables('select * from "Abc"') + assert tables == ((None, "Abc", None, False),) + + +def test_simple_select_multiple_tables(): + tables = extract_tables("select * from abc, def") + assert set(tables) == set([(None, "abc", None, False), (None, "def", None, False)]) + + +def test_simple_select_multiple_tables_double_quoted(): + tables = extract_tables('select * from "Abc", "Def"') + assert set(tables) == set([(None, "Abc", None, False), (None, "Def", None, False)]) + + +def test_simple_select_single_table_deouble_quoted_aliased(): + tables = extract_tables('select * from "Abc" a') + assert tables == ((None, "Abc", "a", False),) + + +def test_simple_select_multiple_tables_deouble_quoted_aliased(): + tables = extract_tables('select * from "Abc" a, "Def" d') + assert set(tables) == set([(None, "Abc", "a", False), (None, "Def", "d", False)]) + + +def test_simple_select_multiple_tables_schema_qualified(): + tables = extract_tables("select * from abc.def, ghi.jkl") + assert set(tables) == set( + [("abc", "def", None, False), ("ghi", "jkl", None, False)] + ) + + +def test_simple_select_with_cols_single_table(): + tables = extract_tables("select a,b from abc") + assert tables == ((None, "abc", None, False),) + + +def test_simple_select_with_cols_single_table_schema_qualified(): + tables = extract_tables("select a,b from abc.def") + assert tables == (("abc", "def", None, False),) + + +def test_simple_select_with_cols_multiple_tables(): + tables = extract_tables("select a,b from abc, def") + assert set(tables) == set([(None, "abc", None, False), (None, "def", None, False)]) + + +def test_simple_select_with_cols_multiple_qualified_tables(): + tables = extract_tables("select a,b from abc.def, def.ghi") + assert set(tables) == set( + [("abc", "def", None, False), ("def", "ghi", None, False)] + ) + + +def test_select_with_hanging_comma_single_table(): + tables = extract_tables("select a, from abc") + assert tables == ((None, "abc", None, False),) + + +def test_select_with_hanging_comma_multiple_tables(): + tables = extract_tables("select a, from abc, def") + assert set(tables) == set([(None, "abc", None, False), (None, "def", None, False)]) + + +def test_select_with_hanging_period_multiple_tables(): + tables = extract_tables("SELECT t1. FROM tabl1 t1, tabl2 t2") + assert set(tables) == set( + [(None, "tabl1", "t1", False), (None, "tabl2", "t2", False)] + ) + + +def test_simple_insert_single_table(): + tables = extract_tables('insert into abc (id, name) values (1, "def")') + + # sqlparse mistakenly assigns an alias to the table + # AND mistakenly identifies the field list as + # assert tables == ((None, 'abc', 'abc', False),) + + assert tables == ((None, "abc", "abc", False),) + + +@pytest.mark.xfail +def test_simple_insert_single_table_schema_qualified(): + tables = extract_tables('insert into abc.def (id, name) values (1, "def")') + assert tables == (("abc", "def", None, False),) + + +def test_simple_update_table_no_schema(): + tables = extract_tables("update abc set id = 1") + assert tables == ((None, "abc", None, False),) + + +def test_simple_update_table_with_schema(): + tables = extract_tables("update abc.def set id = 1") + assert tables == (("abc", "def", None, False),) + + +@pytest.mark.parametrize("join_type", ["", "INNER", "LEFT", "RIGHT OUTER"]) +def test_join_table(join_type): + sql = "SELECT * FROM abc a {0} JOIN def d ON a.id = d.num".format(join_type) + tables = extract_tables(sql) + assert set(tables) == set([(None, "abc", "a", False), (None, "def", "d", False)]) + + +def test_join_table_schema_qualified(): + tables = extract_tables("SELECT * FROM abc.def x JOIN ghi.jkl y ON x.id = y.num") + assert set(tables) == set([("abc", "def", "x", False), ("ghi", "jkl", "y", False)]) + + +def test_incomplete_join_clause(): + sql = """select a.x, b.y + from abc a join bcd b + on a.id = """ + tables = extract_tables(sql) + assert tables == ((None, "abc", "a", False), (None, "bcd", "b", False)) + + +def test_join_as_table(): + tables = extract_tables("SELECT * FROM my_table AS m WHERE m.a > 5") + assert tables == ((None, "my_table", "m", False),) + + +def test_multiple_joins(): + sql = """select * from t1 + inner join t2 ON + t1.id = t2.t1_id + inner join t3 ON + t2.id = t3.""" + tables = extract_tables(sql) + assert tables == ( + (None, "t1", None, False), + (None, "t2", None, False), + (None, "t3", None, False), + ) + + +def test_subselect_tables(): + sql = "SELECT * FROM (SELECT FROM abc" + tables = extract_tables(sql) + assert tables == ((None, "abc", None, False),) + + +@pytest.mark.parametrize("text", ["SELECT * FROM foo.", "SELECT 123 AS foo"]) +def test_extract_no_tables(text): + tables = extract_tables(text) + assert tables == tuple() + + +@pytest.mark.parametrize("arg_list", ["", "arg1", "arg1, arg2, arg3"]) +def test_simple_function_as_table(arg_list): + tables = extract_tables("SELECT * FROM foo({0})".format(arg_list)) + assert tables == ((None, "foo", None, True),) + + +@pytest.mark.parametrize("arg_list", ["", "arg1", "arg1, arg2, arg3"]) +def test_simple_schema_qualified_function_as_table(arg_list): + tables = extract_tables("SELECT * FROM foo.bar({0})".format(arg_list)) + assert tables == (("foo", "bar", None, True),) + + +@pytest.mark.parametrize("arg_list", ["", "arg1", "arg1, arg2, arg3"]) +def test_simple_aliased_function_as_table(arg_list): + tables = extract_tables("SELECT * FROM foo({0}) bar".format(arg_list)) + assert tables == ((None, "foo", "bar", True),) + + +def test_simple_table_and_function(): + tables = extract_tables("SELECT * FROM foo JOIN bar()") + assert set(tables) == set([(None, "foo", None, False), (None, "bar", None, True)]) + + +def test_complex_table_and_function(): + tables = extract_tables( + """SELECT * FROM foo.bar baz + JOIN bar.qux(x, y, z) quux""" + ) + assert set(tables) == set( + [("foo", "bar", "baz", False), ("bar", "qux", "quux", True)] + ) + + +def test_find_prev_keyword_using(): + q = "select * from tbl1 inner join tbl2 using (col1, " + kw, q2 = find_prev_keyword(q) + assert kw.value == "(" and q2 == "select * from tbl1 inner join tbl2 using (" + + +@pytest.mark.parametrize( + "sql", + [ + "select * from foo where bar", + "select * from foo where bar = 1 and baz or ", + "select * from foo where bar = 1 and baz between qux and ", + ], +) +def test_find_prev_keyword_where(sql): + kw, stripped = find_prev_keyword(sql) + assert kw.value == "where" and stripped == "select * from foo where" + + +@pytest.mark.parametrize( + "sql", ["create table foo (bar int, baz ", "select * from foo() as bar (baz "] +) +def test_find_prev_keyword_open_parens(sql): + kw, _ = find_prev_keyword(sql) + assert kw.value == "(" + + +@pytest.mark.parametrize( + "sql", + [ + "", + "$$ foo $$", + "$$ 'foo' $$", + '$$ "foo" $$', + "$$ $a$ $$", + "$a$ $$ $a$", + "foo bar $$ baz $$", + ], +) +def test_is_open_quote__closed(sql): + assert not is_open_quote(sql) + + +@pytest.mark.parametrize( + "sql", + [ + "$$", + ";;;$$", + "foo $$ bar $$; foo $$", + "$$ foo $a$", + "foo 'bar baz", + "$a$ foo ", + '$$ "foo" ', + "$$ $a$ ", + "foo bar $$ baz", + ], +) +def test_is_open_quote__open(sql): + assert is_open_quote(sql) diff --git a/tests/pytest.ini b/tests/pytest.ini new file mode 100644 index 0000000..f787740 --- /dev/null +++ b/tests/pytest.ini @@ -0,0 +1,2 @@ +[pytest] +addopts=--capture=sys --showlocals \ No newline at end of file diff --git a/tests/test_completion_refresher.py b/tests/test_completion_refresher.py new file mode 100644 index 0000000..6a916a8 --- /dev/null +++ b/tests/test_completion_refresher.py @@ -0,0 +1,97 @@ +import time +import pytest +from mock import Mock, patch + + +@pytest.fixture +def refresher(): + from pgcli.completion_refresher import CompletionRefresher + + return CompletionRefresher() + + +def test_ctor(refresher): + """ + Refresher object should contain a few handlers + :param refresher: + :return: + """ + assert len(refresher.refreshers) > 0 + actual_handlers = list(refresher.refreshers.keys()) + expected_handlers = [ + "schemata", + "tables", + "views", + "types", + "databases", + "casing", + "functions", + ] + assert expected_handlers == actual_handlers + + +def test_refresh_called_once(refresher): + """ + + :param refresher: + :return: + """ + callbacks = Mock() + pgexecute = Mock() + special = Mock() + + with patch.object(refresher, "_bg_refresh") as bg_refresh: + actual = refresher.refresh(pgexecute, special, callbacks) + time.sleep(1) # Wait for the thread to work. + assert len(actual) == 1 + assert len(actual[0]) == 4 + assert actual[0][3] == "Auto-completion refresh started in the background." + bg_refresh.assert_called_with(pgexecute, special, callbacks, None, None) + + +def test_refresh_called_twice(refresher): + """ + If refresh is called a second time, it should be restarted + :param refresher: + :return: + """ + callbacks = Mock() + + pgexecute = Mock() + special = Mock() + + def dummy_bg_refresh(*args): + time.sleep(3) # seconds + + refresher._bg_refresh = dummy_bg_refresh + + actual1 = refresher.refresh(pgexecute, special, callbacks) + time.sleep(1) # Wait for the thread to work. + assert len(actual1) == 1 + assert len(actual1[0]) == 4 + assert actual1[0][3] == "Auto-completion refresh started in the background." + + actual2 = refresher.refresh(pgexecute, special, callbacks) + time.sleep(1) # Wait for the thread to work. + assert len(actual2) == 1 + assert len(actual2[0]) == 4 + assert actual2[0][3] == "Auto-completion refresh restarted." + + +def test_refresh_with_callbacks(refresher): + """ + Callbacks must be called + :param refresher: + """ + callbacks = [Mock()] + pgexecute_class = Mock() + pgexecute = Mock() + pgexecute.extra_args = {} + special = Mock() + + with patch("pgcli.completion_refresher.PGExecute", pgexecute_class): + # Set refreshers to 0: we're not testing refresh logic here + refresher.refreshers = {} + refresher.refresh(pgexecute, special, callbacks) + time.sleep(1) # Wait for the thread to work. + assert callbacks[0].call_count == 1 diff --git a/tests/test_config.py b/tests/test_config.py new file mode 100644 index 0000000..1c023e0 --- /dev/null +++ b/tests/test_config.py @@ -0,0 +1,30 @@ +import os +import stat + +import pytest + +from pgcli.config import ensure_dir_exists + + +def test_ensure_file_parent(tmpdir): + subdir = tmpdir.join("subdir") + rcfile = subdir.join("rcfile") + ensure_dir_exists(str(rcfile)) + + +def test_ensure_existing_dir(tmpdir): + rcfile = str(tmpdir.mkdir("subdir").join("rcfile")) + + # should just not raise + ensure_dir_exists(rcfile) + + +def test_ensure_other_create_error(tmpdir): + subdir = tmpdir.join("subdir") + rcfile = subdir.join("rcfile") + + # trigger an oserror that isn't "directory already exists" + os.chmod(str(tmpdir), stat.S_IREAD) + + with pytest.raises(OSError): + ensure_dir_exists(str(rcfile)) diff --git a/tests/test_exceptionals.py b/tests/test_exceptionals.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_fuzzy_completion.py b/tests/test_fuzzy_completion.py new file mode 100644 index 0000000..8f8f2cd --- /dev/null +++ b/tests/test_fuzzy_completion.py @@ -0,0 +1,87 @@ +import pytest + + +@pytest.fixture +def completer(): + import pgcli.pgcompleter as pgcompleter + + return pgcompleter.PGCompleter() + + +def test_ranking_ignores_identifier_quotes(completer): + """When calculating result rank, identifier quotes should be ignored. + + The result ranking algorithm ignores identifier quotes. Without this + correction, the match "user", which Postgres requires to be quoted + since it is also a reserved word, would incorrectly fall below the + match user_action because the literal quotation marks in "user" + alter the position of the match. + + This test checks that the fuzzy ranking algorithm correctly ignores + quotation marks when computing match ranks. + + """ + + text = "user" + collection = ["user_action", '"user"'] + matches = completer.find_matches(text, collection) + assert len(matches) == 2 + + +def test_ranking_based_on_shortest_match(completer): + """Fuzzy result rank should be based on shortest match. + + Result ranking in fuzzy searching is partially based on the length + of matches: shorter matches are considered more relevant than + longer ones. When searching for the text 'user', the length + component of the match 'user_group' could be either 4 ('user') or + 7 ('user_gr'). + + This test checks that the fuzzy ranking algorithm uses the shorter + match when calculating result rank. + + """ + + text = "user" + collection = ["api_user", "user_group"] + matches = completer.find_matches(text, collection) + + assert matches[1].priority > matches[0].priority + + +@pytest.mark.parametrize( + "collection", + [["user_action", "user"], ["user_group", "user"], ["user_group", "user_action"]], +) +def test_should_break_ties_using_lexical_order(completer, collection): + """Fuzzy result rank should use lexical order to break ties. + + When fuzzy matching, if multiple matches have the same match length and + start position, present them in lexical (rather than arbitrary) order. For + example, if we have tables 'user', 'user_action', and 'user_group', a + search for the text 'user' should present these tables in this order. + + The input collections to this test are out of order; each run checks that + the search text 'user' results in the input tables being reordered + lexically. + + """ + + text = "user" + matches = completer.find_matches(text, collection) + + assert matches[1].priority > matches[0].priority + + +def test_matching_should_be_case_insensitive(completer): + """Fuzzy matching should keep matches even if letter casing doesn't match. + + This test checks that variations of the text which have different casing + are still matched. + """ + + text = "foo" + collection = ["Foo", "FOO", "fOO"] + matches = completer.find_matches(text, collection) + + assert len(matches) == 3 diff --git a/tests/test_main.py b/tests/test_main.py new file mode 100644 index 0000000..9b85a34 --- /dev/null +++ b/tests/test_main.py @@ -0,0 +1,383 @@ +import os +import platform +import mock + +import pytest + +try: + import setproctitle +except ImportError: + setproctitle = None + +from pgcli.main import ( + obfuscate_process_password, + format_output, + PGCli, + OutputSettings, + COLOR_CODE_REGEX, +) +from pgcli.pgexecute import PGExecute +from pgspecial.main import PAGER_OFF, PAGER_LONG_OUTPUT, PAGER_ALWAYS +from utils import dbtest, run +from collections import namedtuple + + +@pytest.mark.skipif(platform.system() == "Windows", reason="Not applicable in windows") +@pytest.mark.skipif(not setproctitle, reason="setproctitle not available") +def test_obfuscate_process_password(): + original_title = setproctitle.getproctitle() + + setproctitle.setproctitle("pgcli user=root password=secret host=localhost") + obfuscate_process_password() + title = setproctitle.getproctitle() + expected = "pgcli user=root password=xxxx host=localhost" + assert title == expected + + setproctitle.setproctitle("pgcli user=root password=top secret host=localhost") + obfuscate_process_password() + title = setproctitle.getproctitle() + expected = "pgcli user=root password=xxxx host=localhost" + assert title == expected + + setproctitle.setproctitle("pgcli user=root password=top secret") + obfuscate_process_password() + title = setproctitle.getproctitle() + expected = "pgcli user=root password=xxxx" + assert title == expected + + setproctitle.setproctitle("pgcli postgres://root:secret@localhost/db") + obfuscate_process_password() + title = setproctitle.getproctitle() + expected = "pgcli postgres://root:xxxx@localhost/db" + assert title == expected + + setproctitle.setproctitle(original_title) + + +def test_format_output(): + settings = OutputSettings(table_format="psql", dcmlfmt="d", floatfmt="g") + results = format_output( + "Title", [("abc", "def")], ["head1", "head2"], "test status", settings + ) + expected = [ + "Title", + "+---------+---------+", + "| head1 | head2 |", + "|---------+---------|", + "| abc | def |", + "+---------+---------+", + "test status", + ] + assert list(results) == expected + + +@dbtest +def test_format_array_output(executor): + statement = """ + SELECT + array[1, 2, 3]::bigint[] as bigint_array, + '{{1,2},{3,4}}'::numeric[] as nested_numeric_array, + '{å,魚,текст}'::text[] as 配列 + UNION ALL + SELECT '{}', NULL, array[NULL] + """ + results = run(executor, statement) + expected = [ + "+----------------+------------------------+--------------+", + "| bigint_array | nested_numeric_array | 配列 |", + "|----------------+------------------------+--------------|", + "| {1,2,3} | {{1,2},{3,4}} | {å,魚,текст} |", + "| {} | | {} |", + "+----------------+------------------------+--------------+", + "SELECT 2", + ] + assert list(results) == expected + + +@dbtest +def test_format_array_output_expanded(executor): + statement = """ + SELECT + array[1, 2, 3]::bigint[] as bigint_array, + '{{1,2},{3,4}}'::numeric[] as nested_numeric_array, + '{å,魚,текст}'::text[] as 配列 + UNION ALL + SELECT '{}', NULL, array[NULL] + """ + results = run(executor, statement, expanded=True) + expected = [ + "-[ RECORD 1 ]-------------------------", + "bigint_array | {1,2,3}", + "nested_numeric_array | {{1,2},{3,4}}", + "配列 | {å,魚,текст}", + "-[ RECORD 2 ]-------------------------", + "bigint_array | {}", + "nested_numeric_array | ", + "配列 | {}", + "SELECT 2", + ] + assert "\n".join(results) == "\n".join(expected) + + +def test_format_output_auto_expand(): + settings = OutputSettings( + table_format="psql", dcmlfmt="d", floatfmt="g", max_width=100 + ) + table_results = format_output( + "Title", [("abc", "def")], ["head1", "head2"], "test status", settings + ) + table = [ + "Title", + "+---------+---------+", + "| head1 | head2 |", + "|---------+---------|", + "| abc | def |", + "+---------+---------+", + "test status", + ] + assert list(table_results) == table + expanded_results = format_output( + "Title", + [("abc", "def")], + ["head1", "head2"], + "test status", + settings._replace(max_width=1), + ) + expanded = [ + "Title", + "-[ RECORD 1 ]-------------------------", + "head1 | abc", + "head2 | def", + "test status", + ] + assert "\n".join(expanded_results) == "\n".join(expanded) + + +termsize = namedtuple("termsize", ["rows", "columns"]) +test_line = "-" * 10 +test_data = [ + (10, 10, "\n".join([test_line] * 7)), + (10, 10, "\n".join([test_line] * 6)), + (10, 10, "\n".join([test_line] * 5)), + (10, 10, "-" * 11), + (10, 10, "-" * 10), + (10, 10, "-" * 9), +] + +# 4 lines are reserved at the bottom of the terminal for pgcli's prompt +use_pager_when_on = [True, True, False, True, False, False] + +# Can be replaced with pytest.param once we can upgrade pytest after Python 3.4 goes EOL +test_ids = [ + "Output longer than terminal height", + "Output equal to terminal height", + "Output shorter than terminal height", + "Output longer than terminal width", + "Output equal to terminal width", + "Output shorter than terminal width", +] + + +@pytest.fixture +def pset_pager_mocks(): + cli = PGCli() + cli.watch_command = None + with mock.patch("pgcli.main.click.echo") as mock_echo, mock.patch( + "pgcli.main.click.echo_via_pager" + ) as mock_echo_via_pager, mock.patch.object(cli, "prompt_app") as mock_app: + + yield cli, mock_echo, mock_echo_via_pager, mock_app + + +@pytest.mark.parametrize("term_height,term_width,text", test_data, ids=test_ids) +def test_pset_pager_off(term_height, term_width, text, pset_pager_mocks): + cli, mock_echo, mock_echo_via_pager, mock_cli = pset_pager_mocks + mock_cli.output.get_size.return_value = termsize( + rows=term_height, columns=term_width + ) + + with mock.patch.object(cli.pgspecial, "pager_config", PAGER_OFF): + cli.echo_via_pager(text) + + mock_echo.assert_called() + mock_echo_via_pager.assert_not_called() + + +@pytest.mark.parametrize("term_height,term_width,text", test_data, ids=test_ids) +def test_pset_pager_always(term_height, term_width, text, pset_pager_mocks): + cli, mock_echo, mock_echo_via_pager, mock_cli = pset_pager_mocks + mock_cli.output.get_size.return_value = termsize( + rows=term_height, columns=term_width + ) + + with mock.patch.object(cli.pgspecial, "pager_config", PAGER_ALWAYS): + cli.echo_via_pager(text) + + mock_echo.assert_not_called() + mock_echo_via_pager.assert_called() + + +pager_on_test_data = [l + (r,) for l, r in zip(test_data, use_pager_when_on)] + + +@pytest.mark.parametrize( + "term_height,term_width,text,use_pager", pager_on_test_data, ids=test_ids +) +def test_pset_pager_on(term_height, term_width, text, use_pager, pset_pager_mocks): + cli, mock_echo, mock_echo_via_pager, mock_cli = pset_pager_mocks + mock_cli.output.get_size.return_value = termsize( + rows=term_height, columns=term_width + ) + + with mock.patch.object(cli.pgspecial, "pager_config", PAGER_LONG_OUTPUT): + cli.echo_via_pager(text) + + if use_pager: + mock_echo.assert_not_called() + mock_echo_via_pager.assert_called() + else: + mock_echo_via_pager.assert_not_called() + mock_echo.assert_called() + + +@pytest.mark.parametrize( + "text,expected_length", + [ + ( + "22200K .......\u001b[0m\u001b[91m... .......... ...\u001b[0m\u001b[91m.\u001b[0m\u001b[91m...... .........\u001b[0m\u001b[91m.\u001b[0m\u001b[91m \u001b[0m\u001b[91m.\u001b[0m\u001b[91m.\u001b[0m\u001b[91m.\u001b[0m\u001b[91m.\u001b[0m\u001b[91m...... 50% 28.6K 12m55s", + 78, + ), + ("=\u001b[m=", 2), + ("-\u001b]23\u0007-", 2), + ], +) +def test_color_pattern(text, expected_length, pset_pager_mocks): + cli = pset_pager_mocks[0] + assert len(COLOR_CODE_REGEX.sub("", text)) == expected_length + + +@dbtest +def test_i_works(tmpdir, executor): + sqlfile = tmpdir.join("test.sql") + sqlfile.write("SELECT NOW()") + rcfile = str(tmpdir.join("rcfile")) + cli = PGCli(pgexecute=executor, pgclirc_file=rcfile) + statement = r"\i {0}".format(sqlfile) + run(executor, statement, pgspecial=cli.pgspecial) + + +def test_missing_rc_dir(tmpdir): + rcfile = str(tmpdir.join("subdir").join("rcfile")) + + PGCli(pgclirc_file=rcfile) + assert os.path.exists(rcfile) + + +def test_quoted_db_uri(tmpdir): + with mock.patch.object(PGCli, "connect") as mock_connect: + cli = PGCli(pgclirc_file=str(tmpdir.join("rcfile"))) + cli.connect_uri("postgres://bar%5E:%5Dfoo@baz.com/testdb%5B") + mock_connect.assert_called_with( + database="testdb[", host="baz.com", user="bar^", passwd="]foo" + ) + + +def test_pg_service_file(tmpdir): + + with mock.patch.object(PGCli, "connect") as mock_connect: + cli = PGCli(pgclirc_file=str(tmpdir.join("rcfile"))) + with open(tmpdir.join(".pg_service.conf").strpath, "w") as service_conf: + service_conf.write( + """[myservice] + host=a_host + user=a_user + port=5433 + password=much_secure + dbname=a_dbname + + [my_other_service] + host=b_host + user=b_user + port=5435 + dbname=b_dbname + """ + ) + os.environ["PGSERVICEFILE"] = tmpdir.join(".pg_service.conf").strpath + cli.connect_service("myservice", "another_user") + mock_connect.assert_called_with( + database="a_dbname", + host="a_host", + user="another_user", + port="5433", + passwd="much_secure", + ) + + with mock.patch.object(PGExecute, "__init__") as mock_pgexecute: + mock_pgexecute.return_value = None + cli = PGCli(pgclirc_file=str(tmpdir.join("rcfile"))) + os.environ["PGPASSWORD"] = "very_secure" + cli.connect_service("my_other_service", None) + mock_pgexecute.assert_called_with( + "b_dbname", + "b_user", + "very_secure", + "b_host", + "5435", + "", + application_name="pgcli", + ) + del os.environ["PGPASSWORD"] + del os.environ["PGSERVICEFILE"] + + +def test_ssl_db_uri(tmpdir): + with mock.patch.object(PGCli, "connect") as mock_connect: + cli = PGCli(pgclirc_file=str(tmpdir.join("rcfile"))) + cli.connect_uri( + "postgres://bar%5E:%5Dfoo@baz.com/testdb%5B?" + "sslmode=verify-full&sslcert=m%79.pem&sslkey=my-key.pem&sslrootcert=c%61.pem" + ) + mock_connect.assert_called_with( + database="testdb[", + host="baz.com", + user="bar^", + passwd="]foo", + sslmode="verify-full", + sslcert="my.pem", + sslkey="my-key.pem", + sslrootcert="ca.pem", + ) + + +def test_port_db_uri(tmpdir): + with mock.patch.object(PGCli, "connect") as mock_connect: + cli = PGCli(pgclirc_file=str(tmpdir.join("rcfile"))) + cli.connect_uri("postgres://bar:foo@baz.com:2543/testdb") + mock_connect.assert_called_with( + database="testdb", host="baz.com", user="bar", passwd="foo", port="2543" + ) + + +def test_multihost_db_uri(tmpdir): + with mock.patch.object(PGCli, "connect") as mock_connect: + cli = PGCli(pgclirc_file=str(tmpdir.join("rcfile"))) + cli.connect_uri( + "postgres://bar:foo@baz1.com:2543,baz2.com:2543,baz3.com:2543/testdb" + ) + mock_connect.assert_called_with( + database="testdb", + host="baz1.com,baz2.com,baz3.com", + user="bar", + passwd="foo", + port="2543,2543,2543", + ) + + +def test_application_name_db_uri(tmpdir): + with mock.patch.object(PGExecute, "__init__") as mock_pgexecute: + mock_pgexecute.return_value = None + cli = PGCli(pgclirc_file=str(tmpdir.join("rcfile"))) + cli.connect_uri("postgres://bar@baz.com/?application_name=cow") + mock_pgexecute.assert_called_with( + "bar", "bar", "", "baz.com", "", "", application_name="cow" + ) diff --git a/tests/test_naive_completion.py b/tests/test_naive_completion.py new file mode 100644 index 0000000..a6c80a7 --- /dev/null +++ b/tests/test_naive_completion.py @@ -0,0 +1,133 @@ +import pytest +from prompt_toolkit.completion import Completion +from prompt_toolkit.document import Document +from utils import completions_to_set + + +@pytest.fixture +def completer(): + import pgcli.pgcompleter as pgcompleter + + return pgcompleter.PGCompleter(smart_completion=False) + + +@pytest.fixture +def complete_event(): + from mock import Mock + + return Mock() + + +def test_empty_string_completion(completer, complete_event): + text = "" + position = 0 + result = completions_to_set( + completer.get_completions( + Document(text=text, cursor_position=position), complete_event + ) + ) + assert result == completions_to_set(map(Completion, completer.all_completions)) + + +def test_select_keyword_completion(completer, complete_event): + text = "SEL" + position = len("SEL") + result = completions_to_set( + completer.get_completions( + Document(text=text, cursor_position=position), complete_event + ) + ) + assert result == completions_to_set([Completion(text="SELECT", start_position=-3)]) + + +def test_function_name_completion(completer, complete_event): + text = "SELECT MA" + position = len("SELECT MA") + result = completions_to_set( + completer.get_completions( + Document(text=text, cursor_position=position), complete_event + ) + ) + assert result == completions_to_set( + [ + Completion(text="MATERIALIZED VIEW", start_position=-2), + Completion(text="MAX", start_position=-2), + Completion(text="MAXEXTENTS", start_position=-2), + Completion(text="MAKE_DATE", start_position=-2), + Completion(text="MAKE_TIME", start_position=-2), + Completion(text="MAKE_TIMESTAMPTZ", start_position=-2), + Completion(text="MAKE_INTERVAL", start_position=-2), + Completion(text="MASKLEN", start_position=-2), + Completion(text="MAKE_TIMESTAMP", start_position=-2), + ] + ) + + +def test_column_name_completion(completer, complete_event): + text = "SELECT FROM users" + position = len("SELECT ") + result = completions_to_set( + completer.get_completions( + Document(text=text, cursor_position=position), complete_event + ) + ) + assert result == completions_to_set(map(Completion, completer.all_completions)) + + +def test_alter_well_known_keywords_completion(completer, complete_event): + text = "ALTER " + position = len(text) + result = completions_to_set( + completer.get_completions( + Document(text=text, cursor_position=position), + complete_event, + smart_completion=True, + ) + ) + assert result > completions_to_set( + [ + Completion(text="DATABASE", display_meta="keyword"), + Completion(text="TABLE", display_meta="keyword"), + Completion(text="SYSTEM", display_meta="keyword"), + ] + ) + assert ( + completions_to_set([Completion(text="CREATE", display_meta="keyword")]) + not in result + ) + + +def test_special_name_completion(completer, complete_event): + text = "\\" + position = len("\\") + result = completions_to_set( + completer.get_completions( + Document(text=text, cursor_position=position), complete_event + ) + ) + # Special commands will NOT be suggested during naive completion mode. + assert result == completions_to_set([]) + + +def test_datatype_name_completion(completer, complete_event): + text = "SELECT price::IN" + position = len("SELECT price::IN") + result = completions_to_set( + completer.get_completions( + Document(text=text, cursor_position=position), + complete_event, + smart_completion=True, + ) + ) + assert result == completions_to_set( + [ + Completion(text="INET", display_meta="datatype"), + Completion(text="INT", display_meta="datatype"), + Completion(text="INT2", display_meta="datatype"), + Completion(text="INT4", display_meta="datatype"), + Completion(text="INT8", display_meta="datatype"), + Completion(text="INTEGER", display_meta="datatype"), + Completion(text="INTERNAL", display_meta="datatype"), + Completion(text="INTERVAL", display_meta="datatype"), + ] + ) diff --git a/tests/test_pgexecute.py b/tests/test_pgexecute.py new file mode 100644 index 0000000..9273be9 --- /dev/null +++ b/tests/test_pgexecute.py @@ -0,0 +1,542 @@ +from textwrap import dedent + +import psycopg2 +import pytest +from mock import patch, MagicMock +from pgspecial.main import PGSpecial, NO_QUERY +from utils import run, dbtest, requires_json, requires_jsonb + +from pgcli.main import PGCli +from pgcli.packages.parseutils.meta import FunctionMetadata + + +def function_meta_data( + func_name, + schema_name="public", + arg_names=None, + arg_types=None, + arg_modes=None, + return_type=None, + is_aggregate=False, + is_window=False, + is_set_returning=False, + is_extension=False, + arg_defaults=None, +): + return FunctionMetadata( + schema_name, + func_name, + arg_names, + arg_types, + arg_modes, + return_type, + is_aggregate, + is_window, + is_set_returning, + is_extension, + arg_defaults, + ) + + +@dbtest +def test_conn(executor): + run(executor, """create table test(a text)""") + run(executor, """insert into test values('abc')""") + assert run(executor, """select * from test""", join=True) == dedent( + """\ + +-----+ + | a | + |-----| + | abc | + +-----+ + SELECT 1""" + ) + + +@dbtest +def test_copy(executor): + executor_copy = executor.copy() + run(executor_copy, """create table test(a text)""") + run(executor_copy, """insert into test values('abc')""") + assert run(executor_copy, """select * from test""", join=True) == dedent( + """\ + +-----+ + | a | + |-----| + | abc | + +-----+ + SELECT 1""" + ) + + +@dbtest +def test_bools_are_treated_as_strings(executor): + run(executor, """create table test(a boolean)""") + run(executor, """insert into test values(True)""") + assert run(executor, """select * from test""", join=True) == dedent( + """\ + +------+ + | a | + |------| + | True | + +------+ + SELECT 1""" + ) + + +@dbtest +def test_expanded_slash_G(executor, pgspecial): + # Tests whether we reset the expanded output after a \G. + run(executor, """create table test(a boolean)""") + run(executor, """insert into test values(True)""") + results = run(executor, """select * from test \G""", pgspecial=pgspecial) + assert pgspecial.expanded_output == False + + +@dbtest +def test_schemata_table_views_and_columns_query(executor): + run(executor, "create table a(x text, y text)") + run(executor, "create table b(z text)") + run(executor, "create view d as select 1 as e") + run(executor, "create schema schema1") + run(executor, "create table schema1.c (w text DEFAULT 'meow')") + run(executor, "create schema schema2") + + # schemata + # don't enforce all members of the schemas since they may include postgres + # temporary schemas + assert set(executor.schemata()) >= set( + ["public", "pg_catalog", "information_schema", "schema1", "schema2"] + ) + assert executor.search_path() == ["pg_catalog", "public"] + + # tables + assert set(executor.tables()) >= set( + [("public", "a"), ("public", "b"), ("schema1", "c")] + ) + + assert set(executor.table_columns()) >= set( + [ + ("public", "a", "x", "text", False, None), + ("public", "a", "y", "text", False, None), + ("public", "b", "z", "text", False, None), + ("schema1", "c", "w", "text", True, "'meow'::text"), + ] + ) + + # views + assert set(executor.views()) >= set([("public", "d")]) + + assert set(executor.view_columns()) >= set( + [("public", "d", "e", "integer", False, None)] + ) + + +@dbtest +def test_foreign_key_query(executor): + run(executor, "create schema schema1") + run(executor, "create schema schema2") + run(executor, "create table schema1.parent(parentid int PRIMARY KEY)") + run( + executor, + "create table schema2.child(childid int PRIMARY KEY, motherid int REFERENCES schema1.parent)", + ) + + assert set(executor.foreignkeys()) >= set( + [("schema1", "parent", "parentid", "schema2", "child", "motherid")] + ) + + +@dbtest +def test_functions_query(executor): + run( + executor, + """create function func1() returns int + language sql as $$select 1$$""", + ) + run(executor, "create schema schema1") + run( + executor, + """create function schema1.func2() returns int + language sql as $$select 2$$""", + ) + + run( + executor, + """create function func3() + returns table(x int, y int) language sql + as $$select 1, 2 from generate_series(1,5)$$;""", + ) + + run( + executor, + """create function func4(x int) returns setof int language sql + as $$select generate_series(1,5)$$;""", + ) + + funcs = set(executor.functions()) + assert funcs >= set( + [ + function_meta_data(func_name="func1", return_type="integer"), + function_meta_data( + func_name="func3", + arg_names=["x", "y"], + arg_types=["integer", "integer"], + arg_modes=["t", "t"], + return_type="record", + is_set_returning=True, + ), + function_meta_data( + schema_name="public", + func_name="func4", + arg_names=("x",), + arg_types=("integer",), + return_type="integer", + is_set_returning=True, + ), + function_meta_data( + schema_name="schema1", func_name="func2", return_type="integer" + ), + ] + ) + + +@dbtest +def test_datatypes_query(executor): + run(executor, "create type foo AS (a int, b text)") + + types = list(executor.datatypes()) + assert types == [("public", "foo")] + + +@dbtest +def test_database_list(executor): + databases = executor.databases() + assert "_test_db" in databases + + +@dbtest +def test_invalid_syntax(executor, exception_formatter): + result = run(executor, "invalid syntax!", exception_formatter=exception_formatter) + assert 'syntax error at or near "invalid"' in result[0] + + +@dbtest +def test_invalid_column_name(executor, exception_formatter): + result = run( + executor, "select invalid command", exception_formatter=exception_formatter + ) + assert 'column "invalid" does not exist' in result[0] + + +@pytest.fixture(params=[True, False]) +def expanded(request): + return request.param + + +@dbtest +def test_unicode_support_in_output(executor, expanded): + run(executor, "create table unicodechars(t text)") + run(executor, "insert into unicodechars (t) values ('é')") + + # See issue #24, this raises an exception without proper handling + assert "é" in run( + executor, "select * from unicodechars", join=True, expanded=expanded + ) + + +@dbtest +def test_not_is_special(executor, pgspecial): + """is_special is set to false for database queries.""" + query = "select 1" + result = list(executor.run(query, pgspecial=pgspecial)) + success, is_special = result[0][5:] + assert success == True + assert is_special == False + + +@dbtest +def test_execute_from_file_no_arg(executor, pgspecial): + """\i without a filename returns an error.""" + result = list(executor.run("\i", pgspecial=pgspecial)) + status, sql, success, is_special = result[0][3:] + assert "missing required argument" in status + assert success == False + assert is_special == True + + +@dbtest +@patch("pgcli.main.os") +def test_execute_from_file_io_error(os, executor, pgspecial): + """\i with an io_error returns an error.""" + # Inject an IOError. + os.path.expanduser.side_effect = IOError("test") + + # Check the result. + result = list(executor.run("\i test", pgspecial=pgspecial)) + status, sql, success, is_special = result[0][3:] + assert status == "test" + assert success == False + assert is_special == True + + +@dbtest +def test_multiple_queries_same_line(executor): + result = run(executor, "select 'foo'; select 'bar'") + assert len(result) == 12 # 2 * (output+status) * 3 lines + assert "foo" in result[3] + assert "bar" in result[9] + + +@dbtest +def test_multiple_queries_with_special_command_same_line(executor, pgspecial): + result = run(executor, "select 'foo'; \d", pgspecial=pgspecial) + assert len(result) == 11 # 2 * (output+status) * 3 lines + assert "foo" in result[3] + # This is a lame check. :( + assert "Schema" in result[7] + + +@dbtest +def test_multiple_queries_same_line_syntaxerror(executor, exception_formatter): + result = run( + executor, + "select 'fooé'; invalid syntax é", + exception_formatter=exception_formatter, + ) + assert "fooé" in result[3] + assert 'syntax error at or near "invalid"' in result[-1] + + +@pytest.fixture +def pgspecial(): + return PGCli().pgspecial + + +@dbtest +def test_special_command_help(executor, pgspecial): + result = run(executor, "\\?", pgspecial=pgspecial)[1].split("|") + assert "Command" in result[1] + assert "Description" in result[2] + + +@dbtest +def test_bytea_field_support_in_output(executor): + run(executor, "create table binarydata(c bytea)") + run(executor, "insert into binarydata (c) values (decode('DEADBEEF', 'hex'))") + + assert "\\xdeadbeef" in run(executor, "select * from binarydata", join=True) + + +@dbtest +def test_unicode_support_in_unknown_type(executor): + assert "日本語" in run(executor, "SELECT '日本語' AS japanese;", join=True) + + +@dbtest +def test_unicode_support_in_enum_type(executor): + run(executor, "CREATE TYPE mood AS ENUM ('sad', 'ok', 'happy', '日本語')") + run(executor, "CREATE TABLE person (name TEXT, current_mood mood)") + run(executor, "INSERT INTO person VALUES ('Moe', '日本語')") + assert "日本語" in run(executor, "SELECT * FROM person", join=True) + + +@requires_json +def test_json_renders_without_u_prefix(executor, expanded): + run(executor, "create table jsontest(d json)") + run(executor, """insert into jsontest (d) values ('{"name": "Éowyn"}')""") + result = run( + executor, "SELECT d FROM jsontest LIMIT 1", join=True, expanded=expanded + ) + + assert '{"name": "Éowyn"}' in result + + +@requires_jsonb +def test_jsonb_renders_without_u_prefix(executor, expanded): + run(executor, "create table jsonbtest(d jsonb)") + run(executor, """insert into jsonbtest (d) values ('{"name": "Éowyn"}')""") + result = run( + executor, "SELECT d FROM jsonbtest LIMIT 1", join=True, expanded=expanded + ) + + assert '{"name": "Éowyn"}' in result + + +@dbtest +def test_date_time_types(executor): + run(executor, "SET TIME ZONE UTC") + assert ( + run(executor, "SELECT (CAST('00:00:00' AS time))", join=True).split("\n")[3] + == "| 00:00:00 |" + ) + assert ( + run(executor, "SELECT (CAST('00:00:00+14:59' AS timetz))", join=True).split( + "\n" + )[3] + == "| 00:00:00+14:59 |" + ) + assert ( + run(executor, "SELECT (CAST('4713-01-01 BC' AS date))", join=True).split("\n")[ + 3 + ] + == "| 4713-01-01 BC |" + ) + assert ( + run( + executor, "SELECT (CAST('4713-01-01 00:00:00 BC' AS timestamp))", join=True + ).split("\n")[3] + == "| 4713-01-01 00:00:00 BC |" + ) + assert ( + run( + executor, + "SELECT (CAST('4713-01-01 00:00:00+00 BC' AS timestamptz))", + join=True, + ).split("\n")[3] + == "| 4713-01-01 00:00:00+00 BC |" + ) + assert ( + run( + executor, "SELECT (CAST('-123456789 days 12:23:56' AS interval))", join=True + ).split("\n")[3] + == "| -123456789 days, 12:23:56 |" + ) + + +@dbtest +@pytest.mark.parametrize("value", ["10000000", "10000000.0", "10000000000000"]) +def test_large_numbers_render_directly(executor, value): + run(executor, "create table numbertest(a numeric)") + run(executor, "insert into numbertest (a) values ({0})".format(value)) + assert value in run(executor, "select * from numbertest", join=True) + + +@dbtest +@pytest.mark.parametrize("command", ["di", "dv", "ds", "df", "dT"]) +@pytest.mark.parametrize("verbose", ["", "+"]) +@pytest.mark.parametrize("pattern", ["", "x", "*.*", "x.y", "x.*", "*.y"]) +def test_describe_special(executor, command, verbose, pattern, pgspecial): + # We don't have any tests for the output of any of the special commands, + # but we can at least make sure they run without error + sql = r"\{command}{verbose} {pattern}".format(**locals()) + list(executor.run(sql, pgspecial=pgspecial)) + + +@dbtest +@pytest.mark.parametrize("sql", ["invalid sql", "SELECT 1; select error;"]) +def test_raises_with_no_formatter(executor, sql): + with pytest.raises(psycopg2.ProgrammingError): + list(executor.run(sql)) + + +@dbtest +def test_on_error_resume(executor, exception_formatter): + sql = "select 1; error; select 1;" + result = list( + executor.run(sql, on_error_resume=True, exception_formatter=exception_formatter) + ) + assert len(result) == 3 + + +@dbtest +def test_on_error_stop(executor, exception_formatter): + sql = "select 1; error; select 1;" + result = list( + executor.run( + sql, on_error_resume=False, exception_formatter=exception_formatter + ) + ) + assert len(result) == 2 + + +# @dbtest +# def test_unicode_notices(executor): +# sql = "DO language plpgsql $$ BEGIN RAISE NOTICE '有人更改'; END $$;" +# result = list(executor.run(sql)) +# assert result[0][0] == u'NOTICE: 有人更改\n' + + +@dbtest +def test_nonexistent_function_definition(executor): + with pytest.raises(RuntimeError): + result = executor.view_definition("there_is_no_such_function") + + +@dbtest +def test_function_definition(executor): + run( + executor, + """ + CREATE OR REPLACE FUNCTION public.the_number_three() + RETURNS int + LANGUAGE sql + AS $function$ + select 3; + $function$ + """, + ) + result = executor.function_definition("the_number_three") + + +@dbtest +def test_view_definition(executor): + run(executor, "create table tbl1 (a text, b numeric)") + run(executor, "create view vw1 AS SELECT * FROM tbl1") + run(executor, "create materialized view mvw1 AS SELECT * FROM tbl1") + result = executor.view_definition("vw1") + assert "FROM tbl1" in result + # import pytest; pytest.set_trace() + result = executor.view_definition("mvw1") + assert "MATERIALIZED VIEW" in result + + +@dbtest +def test_nonexistent_view_definition(executor): + with pytest.raises(RuntimeError): + result = executor.view_definition("there_is_no_such_view") + with pytest.raises(RuntimeError): + result = executor.view_definition("mvw1") + + +@dbtest +def test_short_host(executor): + with patch.object(executor, "host", "localhost"): + assert executor.short_host == "localhost" + with patch.object(executor, "host", "localhost.example.org"): + assert executor.short_host == "localhost" + with patch.object( + executor, "host", "localhost1.example.org,localhost2.example.org" + ): + assert executor.short_host == "localhost1" + + +class BrokenConnection(object): + """Mock a connection that failed.""" + + def cursor(self): + raise psycopg2.InterfaceError("I'm broken!") + + +@dbtest +def test_exit_without_active_connection(executor): + quit_handler = MagicMock() + pgspecial = PGSpecial() + pgspecial.register( + quit_handler, + "\\q", + "\\q", + "Quit pgcli.", + arg_type=NO_QUERY, + case_sensitive=True, + aliases=(":q",), + ) + + with patch.object(executor, "conn", BrokenConnection()): + # we should be able to quit the app, even without active connection + run(executor, "\\q", pgspecial=pgspecial) + quit_handler.assert_called_once() + + # an exception should be raised when running a query without active connection + with pytest.raises(psycopg2.InterfaceError): + run(executor, "select 1", pgspecial=pgspecial) diff --git a/tests/test_pgspecial.py b/tests/test_pgspecial.py new file mode 100644 index 0000000..eaeaf12 --- /dev/null +++ b/tests/test_pgspecial.py @@ -0,0 +1,78 @@ +import pytest +from pgcli.packages.sqlcompletion import ( + suggest_type, + Special, + Database, + Schema, + Table, + View, + Function, + Datatype, +) + + +def test_slash_suggests_special(): + suggestions = suggest_type("\\", "\\") + assert set(suggestions) == set([Special()]) + + +def test_slash_d_suggests_special(): + suggestions = suggest_type("\\d", "\\d") + assert set(suggestions) == set([Special()]) + + +def test_dn_suggests_schemata(): + suggestions = suggest_type("\\dn ", "\\dn ") + assert suggestions == (Schema(),) + + suggestions = suggest_type("\\dn xxx", "\\dn xxx") + assert suggestions == (Schema(),) + + +def test_d_suggests_tables_views_and_schemas(): + suggestions = suggest_type("\d ", "\d ") + assert set(suggestions) == set([Schema(), Table(schema=None), View(schema=None)]) + + suggestions = suggest_type("\d xxx", "\d xxx") + assert set(suggestions) == set([Schema(), Table(schema=None), View(schema=None)]) + + +def test_d_dot_suggests_schema_qualified_tables_or_views(): + suggestions = suggest_type("\d myschema.", "\d myschema.") + assert set(suggestions) == set([Table(schema="myschema"), View(schema="myschema")]) + + suggestions = suggest_type("\d myschema.xxx", "\d myschema.xxx") + assert set(suggestions) == set([Table(schema="myschema"), View(schema="myschema")]) + + +def test_df_suggests_schema_or_function(): + suggestions = suggest_type("\\df xxx", "\\df xxx") + assert set(suggestions) == set([Function(schema=None, usage="special"), Schema()]) + + suggestions = suggest_type("\\df myschema.xxx", "\\df myschema.xxx") + assert suggestions == (Function(schema="myschema", usage="special"),) + + +def test_leading_whitespace_ok(): + cmd = "\\dn " + whitespace = " " + suggestions = suggest_type(whitespace + cmd, whitespace + cmd) + assert suggestions == suggest_type(cmd, cmd) + + +def test_dT_suggests_schema_or_datatypes(): + text = "\\dT " + suggestions = suggest_type(text, text) + assert set(suggestions) == set([Schema(), Datatype(schema=None)]) + + +def test_schema_qualified_dT_suggests_datatypes(): + text = "\\dT foo." + suggestions = suggest_type(text, text) + assert suggestions == (Datatype(schema="foo"),) + + +@pytest.mark.parametrize("command", ["\\c ", "\\connect "]) +def test_c_suggests_databases(command): + suggestions = suggest_type(command, command) + assert suggestions == (Database(),) diff --git a/tests/test_plan.wiki b/tests/test_plan.wiki new file mode 100644 index 0000000..6812f18 --- /dev/null +++ b/tests/test_plan.wiki @@ -0,0 +1,38 @@ += Gross Checks = + * [ ] Check connecting to a local database. + * [ ] Check connecting to a remote database. + * [ ] Check connecting to a database with a user/password. + * [ ] Check connecting to a non-existent database. + * [ ] Test changing the database. + + == PGExecute == + * [ ] Test successful execution given a cursor. + * [ ] Test unsuccessful execution with a syntax error. + * [ ] Test a series of executions with the same cursor without failure. + * [ ] Test a series of executions with the same cursor with failure. + * [ ] Test passing in a special command. + + == Naive Autocompletion == + * [ ] Input empty string, ask for completions - Everything. + * [ ] Input partial prefix, ask for completions - Stars with prefix. + * [ ] Input fully autocompleted string, ask for completions - Only full match + * [ ] Input non-existent prefix, ask for completions - nothing + * [ ] Input lowercase prefix - case insensitive completions + + == Smart Autocompletion == + * [ ] Input empty string and check if only keywords are returned. + * [ ] Input SELECT prefix and check if only columns are returned. + * [ ] Input SELECT blah - only keywords are returned. + * [ ] Input SELECT * FROM - Table names only + + == PGSpecial == + * [ ] Test \d + * [ ] Test \d tablename + * [ ] Test \d tablena* + * [ ] Test \d non-existent-tablename + * [ ] Test \d index + * [ ] Test \d sequence + * [ ] Test \d view + + == Exceptionals == + * [ ] Test the 'use' command to change db. diff --git a/tests/test_prioritization.py b/tests/test_prioritization.py new file mode 100644 index 0000000..f5b6700 --- /dev/null +++ b/tests/test_prioritization.py @@ -0,0 +1,20 @@ +from pgcli.packages.prioritization import PrevalenceCounter + + +def test_prevalence_counter(): + counter = PrevalenceCounter() + sql = """SELECT * FROM foo WHERE bar GROUP BY baz; + select * from foo; + SELECT * FROM foo WHERE bar GROUP + BY baz""" + counter.update(sql) + + keywords = ["SELECT", "FROM", "GROUP BY"] + expected = [3, 3, 2] + kw_counts = [counter.keyword_count(x) for x in keywords] + assert kw_counts == expected + assert counter.keyword_count("NOSUCHKEYWORD") == 0 + + names = ["foo", "bar", "baz"] + name_counts = [counter.name_count(x) for x in names] + assert name_counts == [3, 2, 2] diff --git a/tests/test_prompt_utils.py b/tests/test_prompt_utils.py new file mode 100644 index 0000000..c1f8a16 --- /dev/null +++ b/tests/test_prompt_utils.py @@ -0,0 +1,10 @@ +import click + +from pgcli.packages.prompt_utils import confirm_destructive_query + + +def test_confirm_destructive_query_notty(): + stdin = click.get_text_stream("stdin") + if not stdin.isatty(): + sql = "drop database foo;" + assert confirm_destructive_query(sql) is None diff --git a/tests/test_rowlimit.py b/tests/test_rowlimit.py new file mode 100644 index 0000000..e76ea04 --- /dev/null +++ b/tests/test_rowlimit.py @@ -0,0 +1,79 @@ +import pytest +from mock import Mock + +from pgcli.main import PGCli + + +# We need this fixtures beacause we need PGCli object to be created +# after test collection so it has config loaded from temp directory + + +@pytest.fixture(scope="module") +def default_pgcli_obj(): + return PGCli() + + +@pytest.fixture(scope="module") +def DEFAULT(default_pgcli_obj): + return default_pgcli_obj.row_limit + + +@pytest.fixture(scope="module") +def LIMIT(DEFAULT): + return DEFAULT + 1000 + + +@pytest.fixture(scope="module") +def over_default(DEFAULT): + over_default_cursor = Mock() + over_default_cursor.configure_mock(rowcount=DEFAULT + 10) + return over_default_cursor + + +@pytest.fixture(scope="module") +def over_limit(LIMIT): + over_limit_cursor = Mock() + over_limit_cursor.configure_mock(rowcount=LIMIT + 10) + return over_limit_cursor + + +@pytest.fixture(scope="module") +def low_count(): + low_count_cursor = Mock() + low_count_cursor.configure_mock(rowcount=1) + return low_count_cursor + + +def test_row_limit_with_LIMIT_clause(LIMIT, over_limit): + cli = PGCli(row_limit=LIMIT) + stmt = "SELECT * FROM students LIMIT 1000" + + result = cli._should_limit_output(stmt, over_limit) + assert result is False + + cli = PGCli(row_limit=0) + result = cli._should_limit_output(stmt, over_limit) + assert result is False + + +def test_row_limit_without_LIMIT_clause(LIMIT, over_limit): + cli = PGCli(row_limit=LIMIT) + stmt = "SELECT * FROM students" + + result = cli._should_limit_output(stmt, over_limit) + assert result is True + + cli = PGCli(row_limit=0) + result = cli._should_limit_output(stmt, over_limit) + assert result is False + + +def test_row_limit_on_non_select(over_limit): + cli = PGCli() + stmt = "UPDATE students SET name='Boby'" + result = cli._should_limit_output(stmt, over_limit) + assert result is False + + cli = PGCli(row_limit=0) + result = cli._should_limit_output(stmt, over_limit) + assert result is False diff --git a/tests/test_smart_completion_multiple_schemata.py b/tests/test_smart_completion_multiple_schemata.py new file mode 100644 index 0000000..805b727 --- /dev/null +++ b/tests/test_smart_completion_multiple_schemata.py @@ -0,0 +1,727 @@ +import itertools +from metadata import ( + MetaData, + alias, + name_join, + fk_join, + join, + schema, + table, + function, + wildcard_expansion, + column, + get_result, + result_set, + qual, + no_qual, + parametrize, +) +from utils import completions_to_set + +metadata = { + "tables": { + "public": { + "users": ["id", "email", "first_name", "last_name"], + "orders": ["id", "ordered_date", "status", "datestamp"], + "select": ["id", "localtime", "ABC"], + }, + "custom": { + "users": ["id", "phone_number"], + "Users": ["userid", "username"], + "products": ["id", "product_name", "price"], + "shipments": ["id", "address", "user_id"], + }, + "Custom": {"projects": ["projectid", "name"]}, + "blog": { + "entries": ["entryid", "entrytitle", "entrytext"], + "tags": ["tagid", "name"], + "entrytags": ["entryid", "tagid"], + "entacclog": ["entryid", "username", "datestamp"], + }, + }, + "functions": { + "public": [ + ["func1", [], [], [], "", False, False, False, False], + ["func2", [], [], [], "", False, False, False, False], + ], + "custom": [ + ["func3", [], [], [], "", False, False, False, False], + [ + "set_returning_func", + ["x"], + ["integer"], + ["o"], + "integer", + False, + False, + True, + False, + ], + ], + "Custom": [["func4", [], [], [], "", False, False, False, False]], + "blog": [ + [ + "extract_entry_symbols", + ["_entryid", "symbol"], + ["integer", "text"], + ["i", "o"], + "", + False, + False, + True, + False, + ], + [ + "enter_entry", + ["_title", "_text", "entryid"], + ["text", "text", "integer"], + ["i", "i", "o"], + "", + False, + False, + False, + False, + ], + ], + }, + "datatypes": {"public": ["typ1", "typ2"], "custom": ["typ3", "typ4"]}, + "foreignkeys": { + "custom": [("public", "users", "id", "custom", "shipments", "user_id")], + "blog": [ + ("blog", "entries", "entryid", "blog", "entacclog", "entryid"), + ("blog", "entries", "entryid", "blog", "entrytags", "entryid"), + ("blog", "tags", "tagid", "blog", "entrytags", "tagid"), + ], + }, + "defaults": { + "public": { + ("orders", "id"): "nextval('orders_id_seq'::regclass)", + ("orders", "datestamp"): "now()", + ("orders", "status"): "'PENDING'::text", + } + }, +} + +testdata = MetaData(metadata) +cased_schemas = [schema(x) for x in ("public", "blog", "CUSTOM", '"Custom"')] +casing = ( + "SELECT", + "Orders", + "User_Emails", + "CUSTOM", + "Func1", + "Entries", + "Tags", + "EntryTags", + "EntAccLog", + "EntryID", + "EntryTitle", + "EntryText", +) +completers = testdata.get_completers(casing) + + +@parametrize("completer", completers(filtr=True, casing=False, qualify=no_qual)) +@parametrize("table", ["users", '"users"']) +def test_suggested_column_names_from_shadowed_visible_table(completer, table): + result = get_result(completer, "SELECT FROM " + table, len("SELECT ")) + assert completions_to_set(result) == completions_to_set( + testdata.columns_functions_and_keywords("users") + ) + + +@parametrize("completer", completers(filtr=True, casing=False, qualify=no_qual)) +@parametrize( + "text", + [ + "SELECT from custom.users", + "WITH users as (SELECT 1 AS foo) SELECT from custom.users", + ], +) +def test_suggested_column_names_from_qualified_shadowed_table(completer, text): + result = get_result(completer, text, position=text.find(" ") + 1) + assert completions_to_set(result) == completions_to_set( + testdata.columns_functions_and_keywords("users", "custom") + ) + + +@parametrize("completer", completers(filtr=True, casing=False, qualify=no_qual)) +@parametrize("text", ["WITH users as (SELECT 1 AS foo) SELECT from users"]) +def test_suggested_column_names_from_cte(completer, text): + result = completions_to_set(get_result(completer, text, text.find(" ") + 1)) + assert result == completions_to_set( + [column("foo")] + testdata.functions_and_keywords() + ) + + +@parametrize("completer", completers(casing=False)) +@parametrize( + "text", + [ + "SELECT * FROM users JOIN custom.shipments ON ", + """SELECT * + FROM public.users + JOIN custom.shipments ON """, + ], +) +def test_suggested_join_conditions(completer, text): + result = get_result(completer, text) + assert completions_to_set(result) == completions_to_set( + [ + alias("users"), + alias("shipments"), + name_join("shipments.id = users.id"), + fk_join("shipments.user_id = users.id"), + ] + ) + + +@parametrize("completer", completers(filtr=True, casing=False, aliasing=False)) +@parametrize( + ("query", "tbl"), + itertools.product( + ( + "SELECT * FROM public.{0} RIGHT OUTER JOIN ", + """SELECT * + FROM {0} + JOIN """, + ), + ("users", '"users"', "Users"), + ), +) +def test_suggested_joins(completer, query, tbl): + result = get_result(completer, query.format(tbl)) + assert completions_to_set(result) == completions_to_set( + testdata.schemas_and_from_clause_items() + + [join("custom.shipments ON shipments.user_id = {0}.id".format(tbl))] + ) + + +@parametrize("completer", completers(filtr=True, casing=False, qualify=no_qual)) +def test_suggested_column_names_from_schema_qualifed_table(completer): + result = get_result(completer, "SELECT from custom.products", len("SELECT ")) + assert completions_to_set(result) == completions_to_set( + testdata.columns_functions_and_keywords("products", "custom") + ) + + +@parametrize( + "text", + [ + "INSERT INTO orders(", + "INSERT INTO orders (", + "INSERT INTO public.orders(", + "INSERT INTO public.orders (", + ], +) +@parametrize("completer", completers(filtr=True, casing=False)) +def test_suggested_columns_with_insert(completer, text): + assert completions_to_set(get_result(completer, text)) == completions_to_set( + testdata.columns("orders") + ) + + +@parametrize("completer", completers(filtr=True, casing=False, qualify=no_qual)) +def test_suggested_column_names_in_function(completer): + result = get_result( + completer, "SELECT MAX( from custom.products", len("SELECT MAX(") + ) + assert completions_to_set(result) == completions_to_set( + testdata.columns_functions_and_keywords("products", "custom") + ) + + +@parametrize("completer", completers(casing=False, aliasing=False)) +@parametrize( + "text", + ["SELECT * FROM Custom.", "SELECT * FROM custom.", 'SELECT * FROM "custom".'], +) +@parametrize("use_leading_double_quote", [False, True]) +def test_suggested_table_names_with_schema_dot( + completer, text, use_leading_double_quote +): + if use_leading_double_quote: + text += '"' + start_position = -1 + else: + start_position = 0 + + result = get_result(completer, text) + assert completions_to_set(result) == completions_to_set( + testdata.from_clause_items("custom", start_position) + ) + + +@parametrize("completer", completers(casing=False, aliasing=False)) +@parametrize("text", ['SELECT * FROM "Custom".']) +@parametrize("use_leading_double_quote", [False, True]) +def test_suggested_table_names_with_schema_dot2( + completer, text, use_leading_double_quote +): + if use_leading_double_quote: + text += '"' + start_position = -1 + else: + start_position = 0 + + result = get_result(completer, text) + assert completions_to_set(result) == completions_to_set( + testdata.from_clause_items("Custom", start_position) + ) + + +@parametrize("completer", completers(filtr=True, casing=False)) +def test_suggested_column_names_with_qualified_alias(completer): + result = get_result(completer, "SELECT p. from custom.products p", len("SELECT p.")) + assert completions_to_set(result) == completions_to_set( + testdata.columns("products", "custom") + ) + + +@parametrize("completer", completers(filtr=True, casing=False, qualify=no_qual)) +def test_suggested_multiple_column_names(completer): + result = get_result( + completer, "SELECT id, from custom.products", len("SELECT id, ") + ) + assert completions_to_set(result) == completions_to_set( + testdata.columns_functions_and_keywords("products", "custom") + ) + + +@parametrize("completer", completers(filtr=True, casing=False)) +def test_suggested_multiple_column_names_with_alias(completer): + result = get_result( + completer, "SELECT p.id, p. from custom.products p", len("SELECT u.id, u.") + ) + assert completions_to_set(result) == completions_to_set( + testdata.columns("products", "custom") + ) + + +@parametrize("completer", completers(filtr=True, casing=False)) +@parametrize( + "text", + [ + "SELECT x.id, y.product_name FROM custom.products x JOIN custom.products y ON ", + "SELECT x.id, y.product_name FROM custom.products x JOIN custom.products y ON JOIN public.orders z ON z.id > y.id", + ], +) +def test_suggestions_after_on(completer, text): + position = len( + "SELECT x.id, y.product_name FROM custom.products x JOIN custom.products y ON " + ) + result = get_result(completer, text, position) + assert completions_to_set(result) == completions_to_set( + [ + alias("x"), + alias("y"), + name_join("y.price = x.price"), + name_join("y.product_name = x.product_name"), + name_join("y.id = x.id"), + ] + ) + + +@parametrize("completer", completers()) +def test_suggested_aliases_after_on_right_side(completer): + text = "SELECT x.id, y.product_name FROM custom.products x JOIN custom.products y ON x.id = " + result = get_result(completer, text) + assert completions_to_set(result) == completions_to_set([alias("x"), alias("y")]) + + +@parametrize("completer", completers(filtr=True, casing=False, aliasing=False)) +def test_table_names_after_from(completer): + text = "SELECT * FROM " + result = get_result(completer, text) + assert completions_to_set(result) == completions_to_set( + testdata.schemas_and_from_clause_items() + ) + + +@parametrize("completer", completers(filtr=True, casing=False)) +def test_schema_qualified_function_name(completer): + text = "SELECT custom.func" + result = get_result(completer, text) + assert completions_to_set(result) == completions_to_set( + [ + function("func3()", -len("func")), + function("set_returning_func()", -len("func")), + ] + ) + + +@parametrize("completer", completers(filtr=True, casing=False)) +@parametrize( + "text", + [ + "SELECT 1::custom.", + "CREATE TABLE foo (bar custom.", + "CREATE FUNCTION foo (bar INT, baz custom.", + "ALTER TABLE foo ALTER COLUMN bar TYPE custom.", + ], +) +def test_schema_qualified_type_name(completer, text): + result = get_result(completer, text) + assert completions_to_set(result) == completions_to_set(testdata.types("custom")) + + +@parametrize("completer", completers(filtr=True, casing=False)) +def test_suggest_columns_from_aliased_set_returning_function(completer): + result = get_result( + completer, "select f. from custom.set_returning_func() f", len("select f.") + ) + assert completions_to_set(result) == completions_to_set( + testdata.columns("set_returning_func", "custom", "functions") + ) + + +@parametrize("completer", completers(filtr=True, casing=False, qualify=no_qual)) +@parametrize( + "text", + [ + "SELECT * FROM custom.set_returning_func()", + "SELECT * FROM Custom.set_returning_func()", + "SELECT * FROM Custom.Set_Returning_Func()", + ], +) +def test_wildcard_column_expansion_with_function(completer, text): + position = len("SELECT *") + + completions = get_result(completer, text, position) + + col_list = "x" + expected = [wildcard_expansion(col_list)] + + assert expected == completions + + +@parametrize("completer", completers(filtr=True, casing=False)) +def test_wildcard_column_expansion_with_alias_qualifier(completer): + text = "SELECT p.* FROM custom.products p" + position = len("SELECT p.*") + + completions = get_result(completer, text, position) + + col_list = "id, p.product_name, p.price" + expected = [wildcard_expansion(col_list)] + + assert expected == completions + + +@parametrize("completer", completers(filtr=True, casing=False)) +@parametrize( + "text", + [ + """ + SELECT count(1) FROM users; + CREATE FUNCTION foo(custom.products _products) returns custom.shipments + LANGUAGE SQL + AS $foo$ + SELECT 1 FROM custom.shipments; + INSERT INTO public.orders(*) values(-1, now(), 'preliminary'); + SELECT 2 FROM custom.users; + $foo$; + SELECT count(1) FROM custom.shipments; + """, + "INSERT INTO public.orders(*", + "INSERT INTO public.Orders(*", + "INSERT INTO public.orders (*", + "INSERT INTO public.Orders (*", + "INSERT INTO orders(*", + "INSERT INTO Orders(*", + "INSERT INTO orders (*", + "INSERT INTO Orders (*", + "INSERT INTO public.orders(*)", + "INSERT INTO public.Orders(*)", + "INSERT INTO public.orders (*)", + "INSERT INTO public.Orders (*)", + "INSERT INTO orders(*)", + "INSERT INTO Orders(*)", + "INSERT INTO orders (*)", + "INSERT INTO Orders (*)", + ], +) +def test_wildcard_column_expansion_with_insert(completer, text): + position = text.index("*") + 1 + completions = get_result(completer, text, position) + + expected = [wildcard_expansion("ordered_date, status")] + assert expected == completions + + +@parametrize("completer", completers(filtr=True, casing=False)) +def test_wildcard_column_expansion_with_table_qualifier(completer): + text = 'SELECT "select".* FROM public."select"' + position = len('SELECT "select".*') + + completions = get_result(completer, text, position) + + col_list = 'id, "select"."localtime", "select"."ABC"' + expected = [wildcard_expansion(col_list)] + + assert expected == completions + + +@parametrize("completer", completers(filtr=True, casing=False, qualify=qual)) +def test_wildcard_column_expansion_with_two_tables(completer): + text = 'SELECT * FROM public."select" JOIN custom.users ON true' + position = len("SELECT *") + + completions = get_result(completer, text, position) + + cols = ( + '"select".id, "select"."localtime", "select"."ABC", ' + "users.id, users.phone_number" + ) + expected = [wildcard_expansion(cols)] + assert completions == expected + + +@parametrize("completer", completers(filtr=True, casing=False)) +def test_wildcard_column_expansion_with_two_tables_and_parent(completer): + text = 'SELECT "select".* FROM public."select" JOIN custom.users u ON true' + position = len('SELECT "select".*') + + completions = get_result(completer, text, position) + + col_list = 'id, "select"."localtime", "select"."ABC"' + expected = [wildcard_expansion(col_list)] + + assert expected == completions + + +@parametrize("completer", completers(filtr=True, casing=False)) +@parametrize( + "text", + [ + "SELECT U. FROM custom.Users U", + "SELECT U. FROM custom.USERS U", + "SELECT U. FROM custom.users U", + 'SELECT U. FROM "custom".Users U', + 'SELECT U. FROM "custom".USERS U', + 'SELECT U. FROM "custom".users U', + ], +) +def test_suggest_columns_from_unquoted_table(completer, text): + position = len("SELECT U.") + result = get_result(completer, text, position) + assert completions_to_set(result) == completions_to_set( + testdata.columns("users", "custom") + ) + + +@parametrize("completer", completers(filtr=True, casing=False)) +@parametrize( + "text", ['SELECT U. FROM custom."Users" U', 'SELECT U. FROM "custom"."Users" U'] +) +def test_suggest_columns_from_quoted_table(completer, text): + position = len("SELECT U.") + result = get_result(completer, text, position) + assert completions_to_set(result) == completions_to_set( + testdata.columns("Users", "custom") + ) + + +texts = ["SELECT * FROM ", "SELECT * FROM public.Orders O CROSS JOIN "] + + +@parametrize("completer", completers(filtr=True, casing=False, aliasing=False)) +@parametrize("text", texts) +def test_schema_or_visible_table_completion(completer, text): + result = get_result(completer, text) + assert completions_to_set(result) == completions_to_set( + testdata.schemas_and_from_clause_items() + ) + + +@parametrize("completer", completers(aliasing=True, casing=False, filtr=True)) +@parametrize("text", texts) +def test_table_aliases(completer, text): + result = get_result(completer, text) + assert completions_to_set(result) == completions_to_set( + testdata.schemas() + + [ + table("users u"), + table("orders o" if text == "SELECT * FROM " else "orders o2"), + table('"select" s'), + function("func1() f"), + function("func2() f"), + ] + ) + + +@parametrize("completer", completers(aliasing=True, casing=True, filtr=True)) +@parametrize("text", texts) +def test_aliases_with_casing(completer, text): + result = get_result(completer, text) + assert completions_to_set(result) == completions_to_set( + cased_schemas + + [ + table("users u"), + table("Orders O" if text == "SELECT * FROM " else "Orders O2"), + table('"select" s'), + function("Func1() F"), + function("func2() f"), + ] + ) + + +@parametrize("completer", completers(aliasing=False, casing=True, filtr=True)) +@parametrize("text", texts) +def test_table_casing(completer, text): + result = get_result(completer, text) + assert completions_to_set(result) == completions_to_set( + cased_schemas + + [ + table("users"), + table("Orders"), + table('"select"'), + function("Func1()"), + function("func2()"), + ] + ) + + +@parametrize("completer", completers(aliasing=False, casing=True)) +def test_alias_search_without_aliases2(completer): + text = "SELECT * FROM blog.et" + result = get_result(completer, text) + assert result[0] == table("EntryTags", -2) + + +@parametrize("completer", completers(aliasing=False, casing=True)) +def test_alias_search_without_aliases1(completer): + text = "SELECT * FROM blog.e" + result = get_result(completer, text) + assert result[0] == table("Entries", -1) + + +@parametrize("completer", completers(aliasing=True, casing=True)) +def test_alias_search_with_aliases2(completer): + text = "SELECT * FROM blog.et" + result = get_result(completer, text) + assert result[0] == table("EntryTags ET", -2) + + +@parametrize("completer", completers(aliasing=True, casing=True)) +def test_alias_search_with_aliases1(completer): + text = "SELECT * FROM blog.e" + result = get_result(completer, text) + assert result[0] == table("Entries E", -1) + + +@parametrize("completer", completers(aliasing=True, casing=True)) +def test_join_alias_search_with_aliases1(completer): + text = "SELECT * FROM blog.Entries E JOIN blog.e" + result = get_result(completer, text) + assert result[:2] == [ + table("Entries E2", -1), + join("EntAccLog EAL ON EAL.EntryID = E.EntryID", -1), + ] + + +@parametrize("completer", completers(aliasing=False, casing=True)) +def test_join_alias_search_without_aliases1(completer): + text = "SELECT * FROM blog.Entries JOIN blog.e" + result = get_result(completer, text) + assert result[:2] == [ + table("Entries", -1), + join("EntAccLog ON EntAccLog.EntryID = Entries.EntryID", -1), + ] + + +@parametrize("completer", completers(aliasing=True, casing=True)) +def test_join_alias_search_with_aliases2(completer): + text = "SELECT * FROM blog.Entries E JOIN blog.et" + result = get_result(completer, text) + assert result[0] == join("EntryTags ET ON ET.EntryID = E.EntryID", -2) + + +@parametrize("completer", completers(aliasing=False, casing=True)) +def test_join_alias_search_without_aliases2(completer): + text = "SELECT * FROM blog.Entries JOIN blog.et" + result = get_result(completer, text) + assert result[0] == join("EntryTags ON EntryTags.EntryID = Entries.EntryID", -2) + + +@parametrize("completer", completers()) +def test_function_alias_search_without_aliases(completer): + text = "SELECT blog.ees" + result = get_result(completer, text) + first = result[0] + assert first.start_position == -3 + assert first.text == "extract_entry_symbols()" + assert first.display_text == "extract_entry_symbols(_entryid)" + + +@parametrize("completer", completers()) +def test_function_alias_search_with_aliases(completer): + text = "SELECT blog.ee" + result = get_result(completer, text) + first = result[0] + assert first.start_position == -2 + assert first.text == "enter_entry(_title := , _text := )" + assert first.display_text == "enter_entry(_title, _text)" + + +@parametrize("completer", completers(filtr=True, casing=True, qualify=no_qual)) +def test_column_alias_search(completer): + result = get_result(completer, "SELECT et FROM blog.Entries E", len("SELECT et")) + cols = ("EntryText", "EntryTitle", "EntryID") + assert result[:3] == [column(c, -2) for c in cols] + + +@parametrize("completer", completers(casing=True)) +def test_column_alias_search_qualified(completer): + result = get_result( + completer, "SELECT E.ei FROM blog.Entries E", len("SELECT E.ei") + ) + cols = ("EntryID", "EntryTitle") + assert result[:3] == [column(c, -2) for c in cols] + + +@parametrize("completer", completers(casing=False, filtr=False, aliasing=False)) +def test_schema_object_order(completer): + result = get_result(completer, "SELECT * FROM u") + assert result[:3] == [ + table(t, pos=-1) for t in ("users", 'custom."Users"', "custom.users") + ] + + +@parametrize("completer", completers(casing=False, filtr=False, aliasing=False)) +def test_all_schema_objects(completer): + text = "SELECT * FROM " + result = get_result(completer, text) + assert completions_to_set(result) >= completions_to_set( + [table(x) for x in ("orders", '"select"', "custom.shipments")] + + [function(x + "()") for x in ("func2",)] + ) + + +@parametrize("completer", completers(filtr=False, aliasing=False, casing=True)) +def test_all_schema_objects_with_casing(completer): + text = "SELECT * FROM " + result = get_result(completer, text) + assert completions_to_set(result) >= completions_to_set( + [table(x) for x in ("Orders", '"select"', "CUSTOM.shipments")] + + [function(x + "()") for x in ("func2",)] + ) + + +@parametrize("completer", completers(casing=False, filtr=False, aliasing=True)) +def test_all_schema_objects_with_aliases(completer): + text = "SELECT * FROM " + result = get_result(completer, text) + assert completions_to_set(result) >= completions_to_set( + [table(x) for x in ("orders o", '"select" s', "custom.shipments s")] + + [function(x) for x in ("func2() f",)] + ) + + +@parametrize("completer", completers(casing=False, filtr=False, aliasing=True)) +def test_set_schema(completer): + text = "SET SCHEMA " + result = get_result(completer, text) + assert completions_to_set(result) == completions_to_set( + [schema("'blog'"), schema("'Custom'"), schema("'custom'"), schema("'public'")] + ) diff --git a/tests/test_smart_completion_public_schema_only.py b/tests/test_smart_completion_public_schema_only.py new file mode 100644 index 0000000..b935709 --- /dev/null +++ b/tests/test_smart_completion_public_schema_only.py @@ -0,0 +1,1112 @@ +from metadata import ( + MetaData, + alias, + name_join, + fk_join, + join, + keyword, + schema, + table, + view, + function, + column, + wildcard_expansion, + get_result, + result_set, + qual, + no_qual, + parametrize, +) +from prompt_toolkit.completion import Completion +from utils import completions_to_set + + +metadata = { + "tables": { + "users": ["id", "parentid", "email", "first_name", "last_name"], + "Users": ["userid", "username"], + "orders": ["id", "ordered_date", "status", "email"], + "select": ["id", "insert", "ABC"], + }, + "views": {"user_emails": ["id", "email"], "functions": ["function"]}, + "functions": [ + ["custom_fun", [], [], [], "", False, False, False, False], + ["_custom_fun", [], [], [], "", False, False, False, False], + ["custom_func1", [], [], [], "", False, False, False, False], + ["custom_func2", [], [], [], "", False, False, False, False], + [ + "set_returning_func", + ["x", "y"], + ["integer", "integer"], + ["b", "b"], + "", + False, + False, + True, + False, + ], + ], + "datatypes": ["custom_type1", "custom_type2"], + "foreignkeys": [ + ("public", "users", "id", "public", "users", "parentid"), + ("public", "users", "id", "public", "Users", "userid"), + ], +} + +metadata = dict((k, {"public": v}) for k, v in metadata.items()) + +testdata = MetaData(metadata) + +cased_users_col_names = ["ID", "PARENTID", "Email", "First_Name", "last_name"] +cased_users2_col_names = ["UserID", "UserName"] +cased_func_names = [ + "Custom_Fun", + "_custom_fun", + "Custom_Func1", + "custom_func2", + "set_returning_func", +] +cased_tbls = ["Users", "Orders"] +cased_views = ["User_Emails", "Functions"] +casing = ( + ["SELECT", "PUBLIC"] + + cased_func_names + + cased_tbls + + cased_views + + cased_users_col_names + + cased_users2_col_names +) +# Lists for use in assertions +cased_funcs = [ + function(f) + for f in ("Custom_Fun()", "_custom_fun()", "Custom_Func1()", "custom_func2()") +] + [function("set_returning_func(x := , y := )", display="set_returning_func(x, y)")] +cased_tbls = [table(t) for t in (cased_tbls + ['"Users"', '"select"'])] +cased_rels = [view(t) for t in cased_views] + cased_funcs + cased_tbls +cased_users_cols = [column(c) for c in cased_users_col_names] +aliased_rels = ( + [table(t) for t in ("users u", '"Users" U', "orders o", '"select" s')] + + [view("user_emails ue"), view("functions f")] + + [ + function(f) + for f in ( + "_custom_fun() cf", + "custom_fun() cf", + "custom_func1() cf", + "custom_func2() cf", + ) + ] + + [ + function( + "set_returning_func(x := , y := ) srf", + display="set_returning_func(x, y) srf", + ) + ] +) +cased_aliased_rels = ( + [table(t) for t in ("Users U", '"Users" U', "Orders O", '"select" s')] + + [view("User_Emails UE"), view("Functions F")] + + [ + function(f) + for f in ( + "_custom_fun() cf", + "Custom_Fun() CF", + "Custom_Func1() CF", + "custom_func2() cf", + ) + ] + + [ + function( + "set_returning_func(x := , y := ) srf", + display="set_returning_func(x, y) srf", + ) + ] +) +completers = testdata.get_completers(casing) + + +# Just to make sure that this doesn't crash +@parametrize("completer", completers()) +def test_function_column_name(completer): + for l in range( + len("SELECT * FROM Functions WHERE function:"), + len("SELECT * FROM Functions WHERE function:text") + 1, + ): + assert [] == get_result( + completer, "SELECT * FROM Functions WHERE function:text"[:l] + ) + + +@parametrize("action", ["ALTER", "DROP", "CREATE", "CREATE OR REPLACE"]) +@parametrize("completer", completers()) +def test_drop_alter_function(completer, action): + assert get_result(completer, action + " FUNCTION set_ret") == [ + function("set_returning_func(x integer, y integer)", -len("set_ret")) + ] + + +@parametrize("completer", completers()) +def test_empty_string_completion(completer): + result = get_result(completer, "") + assert completions_to_set( + testdata.keywords() + testdata.specials() + ) == completions_to_set(result) + + +@parametrize("completer", completers()) +def test_select_keyword_completion(completer): + result = get_result(completer, "SEL") + assert completions_to_set(result) == completions_to_set([keyword("SELECT", -3)]) + + +@parametrize("completer", completers()) +def test_builtin_function_name_completion(completer): + result = get_result(completer, "SELECT MA") + assert completions_to_set(result) == completions_to_set( + [ + function("MAKE_DATE", -2), + function("MAKE_INTERVAL", -2), + function("MAKE_TIME", -2), + function("MAKE_TIMESTAMP", -2), + function("MAKE_TIMESTAMPTZ", -2), + function("MASKLEN", -2), + function("MAX", -2), + keyword("MAXEXTENTS", -2), + keyword("MATERIALIZED VIEW", -2), + ] + ) + + +@parametrize("completer", completers()) +def test_builtin_function_matches_only_at_start(completer): + text = "SELECT IN" + + result = [c.text for c in get_result(completer, text)] + + assert "MIN" not in result + + +@parametrize("completer", completers(casing=False, aliasing=False)) +def test_user_function_name_completion(completer): + result = get_result(completer, "SELECT cu") + assert completions_to_set(result) == completions_to_set( + [ + function("custom_fun()", -2), + function("_custom_fun()", -2), + function("custom_func1()", -2), + function("custom_func2()", -2), + function("CURRENT_DATE", -2), + function("CURRENT_TIMESTAMP", -2), + function("CUME_DIST", -2), + function("CURRENT_TIME", -2), + keyword("CURRENT", -2), + ] + ) + + +@parametrize("completer", completers(casing=False, aliasing=False)) +def test_user_function_name_completion_matches_anywhere(completer): + result = get_result(completer, "SELECT om") + assert completions_to_set(result) == completions_to_set( + [ + function("custom_fun()", -2), + function("_custom_fun()", -2), + function("custom_func1()", -2), + function("custom_func2()", -2), + ] + ) + + +@parametrize("completer", completers(casing=True)) +def test_list_functions_for_special(completer): + result = get_result(completer, r"\df ") + assert completions_to_set(result) == completions_to_set( + [schema("PUBLIC")] + [function(f) for f in cased_func_names] + ) + + +@parametrize("completer", completers(casing=False, qualify=no_qual)) +def test_suggested_column_names_from_visible_table(completer): + result = get_result(completer, "SELECT from users", len("SELECT ")) + assert completions_to_set(result) == completions_to_set( + testdata.columns_functions_and_keywords("users") + ) + + +@parametrize("completer", completers(casing=True, qualify=no_qual)) +def test_suggested_cased_column_names(completer): + result = get_result(completer, "SELECT from users", len("SELECT ")) + assert completions_to_set(result) == completions_to_set( + cased_funcs + + cased_users_cols + + testdata.builtin_functions() + + testdata.keywords() + ) + + +@parametrize("completer", completers(casing=False, qualify=no_qual)) +@parametrize("text", ["SELECT from users", "INSERT INTO Orders SELECT from users"]) +def test_suggested_auto_qualified_column_names(text, completer): + position = text.index(" ") + 1 + cols = [column(c.lower()) for c in cased_users_col_names] + result = get_result(completer, text, position) + assert completions_to_set(result) == completions_to_set( + cols + testdata.functions_and_keywords() + ) + + +@parametrize("completer", completers(casing=False, qualify=qual)) +@parametrize( + "text", + [ + 'SELECT from users U NATURAL JOIN "Users"', + 'INSERT INTO Orders SELECT from users U NATURAL JOIN "Users"', + ], +) +def test_suggested_auto_qualified_column_names_two_tables(text, completer): + position = text.index(" ") + 1 + cols = [column("U." + c.lower()) for c in cased_users_col_names] + cols += [column('"Users".' + c.lower()) for c in cased_users2_col_names] + result = get_result(completer, text, position) + assert completions_to_set(result) == completions_to_set( + cols + testdata.functions_and_keywords() + ) + + +@parametrize("completer", completers(casing=True, qualify=["always"])) +@parametrize("text", ["UPDATE users SET ", "INSERT INTO users("]) +def test_no_column_qualification(text, completer): + cols = [column(c) for c in cased_users_col_names] + result = get_result(completer, text) + assert completions_to_set(result) == completions_to_set(cols) + + +@parametrize("completer", completers(casing=True, qualify=["always"])) +def test_suggested_cased_always_qualified_column_names(completer): + text = "SELECT from users" + position = len("SELECT ") + cols = [column("users." + c) for c in cased_users_col_names] + result = get_result(completer, text, position) + assert completions_to_set(result) == completions_to_set( + cased_funcs + cols + testdata.builtin_functions() + testdata.keywords() + ) + + +@parametrize("completer", completers(casing=False, qualify=no_qual)) +def test_suggested_column_names_in_function(completer): + result = get_result(completer, "SELECT MAX( from users", len("SELECT MAX(")) + assert completions_to_set(result) == completions_to_set( + (testdata.columns_functions_and_keywords("users")) + ) + + +@parametrize("completer", completers(casing=False)) +def test_suggested_column_names_with_table_dot(completer): + result = get_result(completer, "SELECT users. from users", len("SELECT users.")) + assert completions_to_set(result) == completions_to_set(testdata.columns("users")) + + +@parametrize("completer", completers(casing=False)) +def test_suggested_column_names_with_alias(completer): + result = get_result(completer, "SELECT u. from users u", len("SELECT u.")) + assert completions_to_set(result) == completions_to_set(testdata.columns("users")) + + +@parametrize("completer", completers(casing=False, qualify=no_qual)) +def test_suggested_multiple_column_names(completer): + result = get_result(completer, "SELECT id, from users u", len("SELECT id, ")) + assert completions_to_set(result) == completions_to_set( + (testdata.columns_functions_and_keywords("users")) + ) + + +@parametrize("completer", completers(casing=False)) +def test_suggested_multiple_column_names_with_alias(completer): + result = get_result( + completer, "SELECT u.id, u. from users u", len("SELECT u.id, u.") + ) + assert completions_to_set(result) == completions_to_set(testdata.columns("users")) + + +@parametrize("completer", completers(casing=True)) +def test_suggested_cased_column_names_with_alias(completer): + result = get_result( + completer, "SELECT u.id, u. from users u", len("SELECT u.id, u.") + ) + assert completions_to_set(result) == completions_to_set(cased_users_cols) + + +@parametrize("completer", completers(casing=False)) +def test_suggested_multiple_column_names_with_dot(completer): + result = get_result( + completer, + "SELECT users.id, users. from users u", + len("SELECT users.id, users."), + ) + assert completions_to_set(result) == completions_to_set(testdata.columns("users")) + + +@parametrize("completer", completers(casing=False)) +def test_suggest_columns_after_three_way_join(completer): + text = """SELECT * FROM users u1 + INNER JOIN users u2 ON u1.id = u2.id + INNER JOIN users u3 ON u2.id = u3.""" + result = get_result(completer, text) + assert column("id") in result + + +join_condition_texts = [ + 'INSERT INTO orders SELECT * FROM users U JOIN "Users" U2 ON ', + """INSERT INTO public.orders(orderid) + SELECT * FROM users U JOIN "Users" U2 ON """, + 'SELECT * FROM users U JOIN "Users" U2 ON ', + 'SELECT * FROM users U INNER join "Users" U2 ON ', + 'SELECT * FROM USERS U right JOIN "Users" U2 ON ', + 'SELECT * FROM users U LEFT JOIN "Users" U2 ON ', + 'SELECT * FROM Users U FULL JOIN "Users" U2 ON ', + 'SELECT * FROM users U right outer join "Users" U2 ON ', + 'SELECT * FROM Users U LEFT OUTER JOIN "Users" U2 ON ', + 'SELECT * FROM users U FULL OUTER JOIN "Users" U2 ON ', + """SELECT * + FROM users U + FULL OUTER JOIN "Users" U2 ON + """, +] + + +@parametrize("completer", completers(casing=False)) +@parametrize("text", join_condition_texts) +def test_suggested_join_conditions(completer, text): + result = get_result(completer, text) + assert completions_to_set(result) == completions_to_set( + [alias("U"), alias("U2"), fk_join("U2.userid = U.id")] + ) + + +@parametrize("completer", completers(casing=True)) +@parametrize("text", join_condition_texts) +def test_cased_join_conditions(completer, text): + result = get_result(completer, text) + assert completions_to_set(result) == completions_to_set( + [alias("U"), alias("U2"), fk_join("U2.UserID = U.ID")] + ) + + +@parametrize("completer", completers(casing=False)) +@parametrize( + "text", + [ + """SELECT * + FROM users + CROSS JOIN "Users" + NATURAL JOIN users u + JOIN "Users" u2 ON + """ + ], +) +def test_suggested_join_conditions_with_same_table_twice(completer, text): + result = get_result(completer, text) + assert result == [ + fk_join("u2.userid = u.id"), + fk_join("u2.userid = users.id"), + name_join('u2.userid = "Users".userid'), + name_join('u2.username = "Users".username'), + alias("u"), + alias("u2"), + alias("users"), + alias('"Users"'), + ] + + +@parametrize("completer", completers()) +@parametrize("text", ["SELECT * FROM users JOIN users u2 on foo."]) +def test_suggested_join_conditions_with_invalid_qualifier(completer, text): + result = get_result(completer, text) + assert result == [] + + +@parametrize("completer", completers(casing=False)) +@parametrize( + ("text", "ref"), + [ + ("SELECT * FROM users JOIN NonTable on ", "NonTable"), + ("SELECT * FROM users JOIN nontable nt on ", "nt"), + ], +) +def test_suggested_join_conditions_with_invalid_table(completer, text, ref): + result = get_result(completer, text) + assert completions_to_set(result) == completions_to_set( + [alias("users"), alias(ref)] + ) + + +@parametrize("completer", completers(casing=False, aliasing=False)) +@parametrize( + "text", + [ + 'SELECT * FROM "Users" u JOIN u', + 'SELECT * FROM "Users" u JOIN uid', + 'SELECT * FROM "Users" u JOIN userid', + 'SELECT * FROM "Users" u JOIN id', + ], +) +def test_suggested_joins_fuzzy(completer, text): + result = get_result(completer, text) + last_word = text.split()[-1] + expected = join("users ON users.id = u.userid", -len(last_word)) + assert expected in result + + +join_texts = [ + "SELECT * FROM Users JOIN ", + """INSERT INTO "Users" + SELECT * + FROM Users + INNER JOIN """, + """INSERT INTO public."Users"(username) + SELECT * + FROM Users + INNER JOIN """, + """SELECT * + FROM Users + INNER JOIN """, +] + + +@parametrize("completer", completers(casing=False, aliasing=False)) +@parametrize("text", join_texts) +def test_suggested_joins(completer, text): + result = get_result(completer, text) + assert completions_to_set(result) == completions_to_set( + testdata.schemas_and_from_clause_items() + + [ + join('"Users" ON "Users".userid = Users.id'), + join("users users2 ON users2.id = Users.parentid"), + join("users users2 ON users2.parentid = Users.id"), + ] + ) + + +@parametrize("completer", completers(casing=True, aliasing=False)) +@parametrize("text", join_texts) +def test_cased_joins(completer, text): + result = get_result(completer, text) + assert completions_to_set(result) == completions_to_set( + [schema("PUBLIC")] + + cased_rels + + [ + join('"Users" ON "Users".UserID = Users.ID'), + join("Users Users2 ON Users2.ID = Users.PARENTID"), + join("Users Users2 ON Users2.PARENTID = Users.ID"), + ] + ) + + +@parametrize("completer", completers(casing=False, aliasing=True)) +@parametrize("text", join_texts) +def test_aliased_joins(completer, text): + result = get_result(completer, text) + assert completions_to_set(result) == completions_to_set( + testdata.schemas() + + aliased_rels + + [ + join('"Users" U ON U.userid = Users.id'), + join("users u ON u.id = Users.parentid"), + join("users u ON u.parentid = Users.id"), + ] + ) + + +@parametrize("completer", completers(casing=False, aliasing=False)) +@parametrize( + "text", + [ + 'SELECT * FROM public."Users" JOIN ', + 'SELECT * FROM public."Users" RIGHT OUTER JOIN ', + """SELECT * + FROM public."Users" + LEFT JOIN """, + ], +) +def test_suggested_joins_quoted_schema_qualified_table(completer, text): + result = get_result(completer, text) + assert completions_to_set(result) == completions_to_set( + testdata.schemas_and_from_clause_items() + + [join('public.users ON users.id = "Users".userid')] + ) + + +@parametrize("completer", completers(casing=False)) +@parametrize( + "text", + [ + "SELECT u.name, o.id FROM users u JOIN orders o ON ", + "SELECT u.name, o.id FROM users u JOIN orders o ON JOIN orders o2 ON", + ], +) +def test_suggested_aliases_after_on(completer, text): + position = len("SELECT u.name, o.id FROM users u JOIN orders o ON ") + result = get_result(completer, text, position) + assert completions_to_set(result) == completions_to_set( + [ + alias("u"), + name_join("o.id = u.id"), + name_join("o.email = u.email"), + alias("o"), + ] + ) + + +@parametrize("completer", completers()) +@parametrize( + "text", + [ + "SELECT u.name, o.id FROM users u JOIN orders o ON o.user_id = ", + "SELECT u.name, o.id FROM users u JOIN orders o ON o.user_id = JOIN orders o2 ON", + ], +) +def test_suggested_aliases_after_on_right_side(completer, text): + position = len("SELECT u.name, o.id FROM users u JOIN orders o ON o.user_id = ") + result = get_result(completer, text, position) + assert completions_to_set(result) == completions_to_set([alias("u"), alias("o")]) + + +@parametrize("completer", completers(casing=False)) +@parametrize( + "text", + [ + "SELECT users.name, orders.id FROM users JOIN orders ON ", + "SELECT users.name, orders.id FROM users JOIN orders ON JOIN orders orders2 ON", + ], +) +def test_suggested_tables_after_on(completer, text): + position = len("SELECT users.name, orders.id FROM users JOIN orders ON ") + result = get_result(completer, text, position) + assert completions_to_set(result) == completions_to_set( + [ + name_join("orders.id = users.id"), + name_join("orders.email = users.email"), + alias("users"), + alias("orders"), + ] + ) + + +@parametrize("completer", completers(casing=False)) +@parametrize( + "text", + [ + "SELECT users.name, orders.id FROM users JOIN orders ON orders.user_id = JOIN orders orders2 ON", + "SELECT users.name, orders.id FROM users JOIN orders ON orders.user_id = ", + ], +) +def test_suggested_tables_after_on_right_side(completer, text): + position = len( + "SELECT users.name, orders.id FROM users JOIN orders ON orders.user_id = " + ) + result = get_result(completer, text, position) + assert completions_to_set(result) == completions_to_set( + [alias("users"), alias("orders")] + ) + + +@parametrize("completer", completers(casing=False)) +@parametrize( + "text", + [ + "SELECT * FROM users INNER JOIN orders USING (", + "SELECT * FROM users INNER JOIN orders USING(", + ], +) +def test_join_using_suggests_common_columns(completer, text): + result = get_result(completer, text) + assert completions_to_set(result) == completions_to_set( + [column("id"), column("email")] + ) + + +@parametrize("completer", completers(casing=False)) +@parametrize( + "text", + [ + "SELECT * FROM users u1 JOIN users u2 USING (email) JOIN user_emails ue USING()", + "SELECT * FROM users u1 JOIN users u2 USING(email) JOIN user_emails ue USING ()", + "SELECT * FROM users u1 JOIN user_emails ue USING () JOIN users u2 ue USING(first_name, last_name)", + "SELECT * FROM users u1 JOIN user_emails ue USING() JOIN users u2 ue USING (first_name, last_name)", + ], +) +def test_join_using_suggests_from_last_table(completer, text): + position = text.index("()") + 1 + result = get_result(completer, text, position) + assert completions_to_set(result) == completions_to_set( + [column("id"), column("email")] + ) + + +@parametrize("completer", completers(casing=False)) +@parametrize( + "text", + [ + "SELECT * FROM users INNER JOIN orders USING (id,", + "SELECT * FROM users INNER JOIN orders USING(id,", + ], +) +def test_join_using_suggests_columns_after_first_column(completer, text): + result = get_result(completer, text) + assert completions_to_set(result) == completions_to_set( + [column("id"), column("email")] + ) + + +@parametrize("completer", completers(casing=False, aliasing=False)) +@parametrize( + "text", + [ + "SELECT * FROM ", + "SELECT * FROM users CROSS JOIN ", + "SELECT * FROM users natural join ", + ], +) +def test_table_names_after_from(completer, text): + result = get_result(completer, text) + assert completions_to_set(result) == completions_to_set( + testdata.schemas_and_from_clause_items() + ) + assert [c.text for c in result] == [ + "public", + "orders", + '"select"', + "users", + '"Users"', + "functions", + "user_emails", + "_custom_fun()", + "custom_fun()", + "custom_func1()", + "custom_func2()", + "set_returning_func(x := , y := )", + ] + + +@parametrize("completer", completers(casing=False, qualify=no_qual)) +def test_auto_escaped_col_names(completer): + result = get_result(completer, 'SELECT from "select"', len("SELECT ")) + assert completions_to_set(result) == completions_to_set( + testdata.columns_functions_and_keywords("select") + ) + + +@parametrize("completer", completers(aliasing=False)) +def test_allow_leading_double_quote_in_last_word(completer): + result = get_result(completer, 'SELECT * from "sele') + + expected = table('"select"', -5) + + assert expected in result + + +@parametrize("completer", completers(casing=False)) +@parametrize( + "text", + [ + "SELECT 1::", + "CREATE TABLE foo (bar ", + "CREATE FUNCTION foo (bar INT, baz ", + "ALTER TABLE foo ALTER COLUMN bar TYPE ", + ], +) +def test_suggest_datatype(text, completer): + result = get_result(completer, text) + assert completions_to_set(result) == completions_to_set( + testdata.schemas() + testdata.types() + testdata.builtin_datatypes() + ) + + +@parametrize("completer", completers(casing=False)) +def test_suggest_columns_from_escaped_table_alias(completer): + result = get_result(completer, 'select * from "select" s where s.') + assert completions_to_set(result) == completions_to_set(testdata.columns("select")) + + +@parametrize("completer", completers(casing=False, qualify=no_qual)) +def test_suggest_columns_from_set_returning_function(completer): + result = get_result(completer, "select from set_returning_func()", len("select ")) + assert completions_to_set(result) == completions_to_set( + testdata.columns_functions_and_keywords("set_returning_func", typ="functions") + ) + + +@parametrize("completer", completers(casing=False)) +def test_suggest_columns_from_aliased_set_returning_function(completer): + result = get_result( + completer, "select f. from set_returning_func() f", len("select f.") + ) + assert completions_to_set(result) == completions_to_set( + testdata.columns("set_returning_func", typ="functions") + ) + + +@parametrize("completer", completers(casing=False)) +def test_join_functions_using_suggests_common_columns(completer): + text = """SELECT * FROM set_returning_func() f1 + INNER JOIN set_returning_func() f2 USING (""" + result = get_result(completer, text) + assert completions_to_set(result) == completions_to_set( + testdata.columns("set_returning_func", typ="functions") + ) + + +@parametrize("completer", completers(casing=False)) +def test_join_functions_on_suggests_columns_and_join_conditions(completer): + text = """SELECT * FROM set_returning_func() f1 + INNER JOIN set_returning_func() f2 ON f1.""" + result = get_result(completer, text) + assert completions_to_set(result) == completions_to_set( + [name_join("y = f2.y"), name_join("x = f2.x")] + + testdata.columns("set_returning_func", typ="functions") + ) + + +@parametrize("completer", completers()) +def test_learn_keywords(completer): + history = "CREATE VIEW v AS SELECT 1" + completer.extend_query_history(history) + + # Now that we've used `VIEW` once, it should be suggested ahead of other + # keywords starting with v. + text = "create v" + completions = get_result(completer, text) + assert completions[0].text == "VIEW" + + +@parametrize("completer", completers(casing=False, aliasing=False)) +def test_learn_table_names(completer): + history = "SELECT * FROM users; SELECT * FROM orders; SELECT * FROM users" + completer.extend_query_history(history) + + text = "SELECT * FROM " + completions = get_result(completer, text) + + # `users` should be higher priority than `orders` (used more often) + users = table("users") + orders = table("orders") + + assert completions.index(users) < completions.index(orders) + + +@parametrize("completer", completers(casing=False, qualify=no_qual)) +def test_columns_before_keywords(completer): + text = "SELECT * FROM orders WHERE s" + completions = get_result(completer, text) + + col = column("status", -1) + kw = keyword("SELECT", -1) + + assert completions.index(col) < completions.index(kw) + + +@parametrize("completer", completers(casing=False, qualify=no_qual)) +@parametrize( + "text", + [ + "SELECT * FROM users", + "INSERT INTO users SELECT * FROM users u", + """INSERT INTO users(id, parentid, email, first_name, last_name) + SELECT * + FROM users u""", + ], +) +def test_wildcard_column_expansion(completer, text): + position = text.find("*") + 1 + + completions = get_result(completer, text, position) + + col_list = "id, parentid, email, first_name, last_name" + expected = [wildcard_expansion(col_list)] + + assert expected == completions + + +@parametrize("completer", completers(casing=False)) +@parametrize( + "text", + [ + "SELECT u.* FROM users u", + "INSERT INTO public.users SELECT u.* FROM users u", + """INSERT INTO users(id, parentid, email, first_name, last_name) + SELECT u.* + FROM users u""", + ], +) +def test_wildcard_column_expansion_with_alias(completer, text): + position = text.find("*") + 1 + + completions = get_result(completer, text, position) + + col_list = "id, u.parentid, u.email, u.first_name, u.last_name" + expected = [wildcard_expansion(col_list)] + + assert expected == completions + + +@parametrize("completer", completers(casing=False)) +@parametrize( + "text,expected", + [ + ( + "SELECT users.* FROM users", + "id, users.parentid, users.email, users.first_name, users.last_name", + ), + ( + "SELECT Users.* FROM Users", + "id, Users.parentid, Users.email, Users.first_name, Users.last_name", + ), + ], +) +def test_wildcard_column_expansion_with_table_qualifier(completer, text, expected): + position = len("SELECT users.*") + + completions = get_result(completer, text, position) + + expected = [wildcard_expansion(expected)] + + assert expected == completions + + +@parametrize("completer", completers(casing=False, qualify=qual)) +def test_wildcard_column_expansion_with_two_tables(completer): + text = 'SELECT * FROM "select" JOIN users u ON true' + position = len("SELECT *") + + completions = get_result(completer, text, position) + + cols = ( + '"select".id, "select".insert, "select"."ABC", ' + "u.id, u.parentid, u.email, u.first_name, u.last_name" + ) + expected = [wildcard_expansion(cols)] + assert completions == expected + + +@parametrize("completer", completers(casing=False)) +def test_wildcard_column_expansion_with_two_tables_and_parent(completer): + text = 'SELECT "select".* FROM "select" JOIN users u ON true' + position = len('SELECT "select".*') + + completions = get_result(completer, text, position) + + col_list = 'id, "select".insert, "select"."ABC"' + expected = [wildcard_expansion(col_list)] + + assert expected == completions + + +@parametrize("completer", completers(casing=False)) +@parametrize( + "text", + ["SELECT U. FROM Users U", "SELECT U. FROM USERS U", "SELECT U. FROM users U"], +) +def test_suggest_columns_from_unquoted_table(completer, text): + position = len("SELECT U.") + result = get_result(completer, text, position) + assert completions_to_set(result) == completions_to_set(testdata.columns("users")) + + +@parametrize("completer", completers(casing=False)) +def test_suggest_columns_from_quoted_table(completer): + result = get_result(completer, 'SELECT U. FROM "Users" U', len("SELECT U.")) + assert completions_to_set(result) == completions_to_set(testdata.columns("Users")) + + +@parametrize("completer", completers(casing=False, aliasing=False)) +@parametrize("text", ["SELECT * FROM ", "SELECT * FROM Orders o CROSS JOIN "]) +def test_schema_or_visible_table_completion(completer, text): + result = get_result(completer, text) + assert completions_to_set(result) == completions_to_set( + testdata.schemas_and_from_clause_items() + ) + + +@parametrize("completer", completers(casing=False, aliasing=True)) +@parametrize("text", ["SELECT * FROM "]) +def test_table_aliases(completer, text): + result = get_result(completer, text) + assert completions_to_set(result) == completions_to_set( + testdata.schemas() + aliased_rels + ) + + +@parametrize("completer", completers(casing=False, aliasing=True)) +@parametrize("text", ["SELECT * FROM Orders o CROSS JOIN "]) +def test_duplicate_table_aliases(completer, text): + result = get_result(completer, text) + assert completions_to_set(result) == completions_to_set( + testdata.schemas() + + [ + table("orders o2"), + table("users u"), + table('"Users" U'), + table('"select" s'), + view("user_emails ue"), + view("functions f"), + function("_custom_fun() cf"), + function("custom_fun() cf"), + function("custom_func1() cf"), + function("custom_func2() cf"), + function( + "set_returning_func(x := , y := ) srf", + display="set_returning_func(x, y) srf", + ), + ] + ) + + +@parametrize("completer", completers(casing=True, aliasing=True)) +@parametrize("text", ["SELECT * FROM Orders o CROSS JOIN "]) +def test_duplicate_aliases_with_casing(completer, text): + result = get_result(completer, text) + assert completions_to_set(result) == completions_to_set( + [ + schema("PUBLIC"), + table("Orders O2"), + table("Users U"), + table('"Users" U'), + table('"select" s'), + view("User_Emails UE"), + view("Functions F"), + function("_custom_fun() cf"), + function("Custom_Fun() CF"), + function("Custom_Func1() CF"), + function("custom_func2() cf"), + function( + "set_returning_func(x := , y := ) srf", + display="set_returning_func(x, y) srf", + ), + ] + ) + + +@parametrize("completer", completers(casing=True, aliasing=True)) +@parametrize("text", ["SELECT * FROM "]) +def test_aliases_with_casing(completer, text): + result = get_result(completer, text) + assert completions_to_set(result) == completions_to_set( + [schema("PUBLIC")] + cased_aliased_rels + ) + + +@parametrize("completer", completers(casing=True, aliasing=False)) +@parametrize("text", ["SELECT * FROM "]) +def test_table_casing(completer, text): + result = get_result(completer, text) + assert completions_to_set(result) == completions_to_set( + [schema("PUBLIC")] + cased_rels + ) + + +@parametrize("completer", completers(casing=False)) +@parametrize( + "text", + [ + "INSERT INTO users ()", + "INSERT INTO users()", + "INSERT INTO users () SELECT * FROM orders;", + "INSERT INTO users() SELECT * FROM users u cross join orders o", + ], +) +def test_insert(completer, text): + position = text.find("(") + 1 + result = get_result(completer, text, position) + assert completions_to_set(result) == completions_to_set(testdata.columns("users")) + + +@parametrize("completer", completers(casing=False, aliasing=False)) +def test_suggest_cte_names(completer): + text = """ + WITH cte1 AS (SELECT a, b, c FROM foo), + cte2 AS (SELECT d, e, f FROM bar) + SELECT * FROM + """ + result = get_result(completer, text) + expected = completions_to_set( + [ + Completion("cte1", 0, display_meta="table"), + Completion("cte2", 0, display_meta="table"), + ] + ) + assert expected <= completions_to_set(result) + + +@parametrize("completer", completers(casing=False, qualify=no_qual)) +def test_suggest_columns_from_cte(completer): + result = get_result( + completer, + "WITH cte AS (SELECT foo, bar FROM baz) SELECT FROM cte", + len("WITH cte AS (SELECT foo, bar FROM baz) SELECT "), + ) + expected = [ + Completion("foo", 0, display_meta="column"), + Completion("bar", 0, display_meta="column"), + ] + testdata.functions_and_keywords() + + assert completions_to_set(expected) == completions_to_set(result) + + +@parametrize("completer", completers(casing=False, qualify=no_qual)) +@parametrize( + "text", + [ + "WITH cte AS (SELECT foo FROM bar) SELECT * FROM cte WHERE cte.", + "WITH cte AS (SELECT foo FROM bar) SELECT * FROM cte c WHERE c.", + ], +) +def test_cte_qualified_columns(completer, text): + result = get_result(completer, text) + expected = [Completion("foo", 0, display_meta="column")] + assert completions_to_set(expected) == completions_to_set(result) + + +@parametrize( + "keyword_casing,expected,texts", + [ + ("upper", "SELECT", ("", "s", "S", "Sel")), + ("lower", "select", ("", "s", "S", "Sel")), + ("auto", "SELECT", ("", "S", "SEL", "seL")), + ("auto", "select", ("s", "sel", "SEl")), + ], +) +def test_keyword_casing_upper(keyword_casing, expected, texts): + for text in texts: + completer = testdata.get_completer({"keyword_casing": keyword_casing}) + completions = get_result(completer, text) + assert expected in [cpl.text for cpl in completions] + + +@parametrize("completer", completers()) +def test_keyword_after_alter(completer): + text = "ALTER TABLE users ALTER " + expected = Completion("COLUMN", start_position=0, display_meta="keyword") + completions = get_result(completer, text) + assert expected in completions + + +@parametrize("completer", completers()) +def test_set_schema(completer): + text = "SET SCHEMA " + result = get_result(completer, text) + expected = completions_to_set([schema("'public'")]) + assert completions_to_set(result) == expected + + +@parametrize("completer", completers()) +def test_special_name_completion(completer): + result = get_result(completer, "\\t") + assert completions_to_set(result) == completions_to_set( + [ + Completion( + text="\\timing", + start_position=-2, + display_meta="Toggle timing of commands.", + ) + ] + ) diff --git a/tests/test_sqlcompletion.py b/tests/test_sqlcompletion.py new file mode 100644 index 0000000..3cbad0a --- /dev/null +++ b/tests/test_sqlcompletion.py @@ -0,0 +1,993 @@ +from pgcli.packages.sqlcompletion import ( + suggest_type, + Special, + Database, + Schema, + Table, + Column, + View, + Keyword, + FromClauseItem, + Function, + Datatype, + Alias, + JoinCondition, + Join, +) +from pgcli.packages.parseutils.tables import TableReference +import pytest + + +def cols_etc( + table, schema=None, alias=None, is_function=False, parent=None, last_keyword=None +): + """Returns the expected select-clause suggestions for a single-table + select.""" + return set( + [ + Column( + table_refs=(TableReference(schema, table, alias, is_function),), + qualifiable=True, + ), + Function(schema=parent), + Keyword(last_keyword), + ] + ) + + +def test_select_suggests_cols_with_visible_table_scope(): + suggestions = suggest_type("SELECT FROM tabl", "SELECT ") + assert set(suggestions) == cols_etc("tabl", last_keyword="SELECT") + + +def test_select_suggests_cols_with_qualified_table_scope(): + suggestions = suggest_type("SELECT FROM sch.tabl", "SELECT ") + assert set(suggestions) == cols_etc("tabl", "sch", last_keyword="SELECT") + + +def test_cte_does_not_crash(): + sql = "WITH CTE AS (SELECT F.* FROM Foo F WHERE F.Bar > 23) SELECT C.* FROM CTE C WHERE C.FooID BETWEEN 123 AND 234;" + for i in range(len(sql)): + suggestions = suggest_type(sql[: i + 1], sql[: i + 1]) + + +@pytest.mark.parametrize("expression", ['SELECT * FROM "tabl" WHERE ']) +def test_where_suggests_columns_functions_quoted_table(expression): + expected = cols_etc("tabl", alias='"tabl"', last_keyword="WHERE") + suggestions = suggest_type(expression, expression) + assert expected == set(suggestions) + + +@pytest.mark.parametrize( + "expression", + [ + "INSERT INTO OtherTabl(ID, Name) SELECT * FROM tabl WHERE ", + "INSERT INTO OtherTabl SELECT * FROM tabl WHERE ", + "SELECT * FROM tabl WHERE ", + "SELECT * FROM tabl WHERE (", + "SELECT * FROM tabl WHERE foo = ", + "SELECT * FROM tabl WHERE bar OR ", + "SELECT * FROM tabl WHERE foo = 1 AND ", + "SELECT * FROM tabl WHERE (bar > 10 AND ", + "SELECT * FROM tabl WHERE (bar AND (baz OR (qux AND (", + "SELECT * FROM tabl WHERE 10 < ", + "SELECT * FROM tabl WHERE foo BETWEEN ", + "SELECT * FROM tabl WHERE foo BETWEEN foo AND ", + ], +) +def test_where_suggests_columns_functions(expression): + suggestions = suggest_type(expression, expression) + assert set(suggestions) == cols_etc("tabl", last_keyword="WHERE") + + +@pytest.mark.parametrize( + "expression", + ["SELECT * FROM tabl WHERE foo IN (", "SELECT * FROM tabl WHERE foo IN (bar, "], +) +def test_where_in_suggests_columns(expression): + suggestions = suggest_type(expression, expression) + assert set(suggestions) == cols_etc("tabl", last_keyword="WHERE") + + +@pytest.mark.parametrize("expression", ["SELECT 1 AS ", "SELECT 1 FROM tabl AS "]) +def test_after_as(expression): + suggestions = suggest_type(expression, expression) + assert set(suggestions) == set() + + +def test_where_equals_any_suggests_columns_or_keywords(): + text = "SELECT * FROM tabl WHERE foo = ANY(" + suggestions = suggest_type(text, text) + assert set(suggestions) == cols_etc("tabl", last_keyword="WHERE") + + +def test_lparen_suggests_cols_and_funcs(): + suggestion = suggest_type("SELECT MAX( FROM tbl", "SELECT MAX(") + assert set(suggestion) == set( + [ + Column(table_refs=((None, "tbl", None, False),), qualifiable=True), + Function(schema=None), + Keyword("("), + ] + ) + + +def test_select_suggests_cols_and_funcs(): + suggestions = suggest_type("SELECT ", "SELECT ") + assert set(suggestions) == set( + [ + Column(table_refs=(), qualifiable=True), + Function(schema=None), + Keyword("SELECT"), + ] + ) + + +@pytest.mark.parametrize( + "expression", ["INSERT INTO ", "COPY ", "UPDATE ", "DESCRIBE "] +) +def test_suggests_tables_views_and_schemas(expression): + suggestions = suggest_type(expression, expression) + assert set(suggestions) == set([Table(schema=None), View(schema=None), Schema()]) + + +@pytest.mark.parametrize("expression", ["SELECT * FROM "]) +def test_suggest_tables_views_schemas_and_functions(expression): + suggestions = suggest_type(expression, expression) + assert set(suggestions) == set([FromClauseItem(schema=None), Schema()]) + + +@pytest.mark.parametrize( + "expression", + [ + "SELECT * FROM foo JOIN bar on bar.barid = foo.barid JOIN ", + "SELECT * FROM foo JOIN bar USING (barid) JOIN ", + ], +) +def test_suggest_after_join_with_two_tables(expression): + suggestions = suggest_type(expression, expression) + tables = tuple([(None, "foo", None, False), (None, "bar", None, False)]) + assert set(suggestions) == set( + [FromClauseItem(schema=None, table_refs=tables), Join(tables, None), Schema()] + ) + + +@pytest.mark.parametrize( + "expression", ["SELECT * FROM foo JOIN ", "SELECT * FROM foo JOIN bar"] +) +def test_suggest_after_join_with_one_table(expression): + suggestions = suggest_type(expression, expression) + tables = ((None, "foo", None, False),) + assert set(suggestions) == set( + [ + FromClauseItem(schema=None, table_refs=tables), + Join(((None, "foo", None, False),), None), + Schema(), + ] + ) + + +@pytest.mark.parametrize( + "expression", ["INSERT INTO sch.", "COPY sch.", "DESCRIBE sch."] +) +def test_suggest_qualified_tables_and_views(expression): + suggestions = suggest_type(expression, expression) + assert set(suggestions) == set([Table(schema="sch"), View(schema="sch")]) + + +@pytest.mark.parametrize("expression", ["UPDATE sch."]) +def test_suggest_qualified_aliasable_tables_and_views(expression): + suggestions = suggest_type(expression, expression) + assert set(suggestions) == set([Table(schema="sch"), View(schema="sch")]) + + +@pytest.mark.parametrize( + "expression", + [ + "SELECT * FROM sch.", + 'SELECT * FROM sch."', + 'SELECT * FROM sch."foo', + 'SELECT * FROM "sch".', + 'SELECT * FROM "sch"."', + ], +) +def test_suggest_qualified_tables_views_and_functions(expression): + suggestions = suggest_type(expression, expression) + assert set(suggestions) == set([FromClauseItem(schema="sch")]) + + +@pytest.mark.parametrize("expression", ["SELECT * FROM foo JOIN sch."]) +def test_suggest_qualified_tables_views_functions_and_joins(expression): + suggestions = suggest_type(expression, expression) + tbls = tuple([(None, "foo", None, False)]) + assert set(suggestions) == set( + [FromClauseItem(schema="sch", table_refs=tbls), Join(tbls, "sch")] + ) + + +def test_truncate_suggests_tables_and_schemas(): + suggestions = suggest_type("TRUNCATE ", "TRUNCATE ") + assert set(suggestions) == set([Table(schema=None), Schema()]) + + +def test_truncate_suggests_qualified_tables(): + suggestions = suggest_type("TRUNCATE sch.", "TRUNCATE sch.") + assert set(suggestions) == set([Table(schema="sch")]) + + +@pytest.mark.parametrize( + "text", ["SELECT DISTINCT ", "INSERT INTO foo SELECT DISTINCT "] +) +def test_distinct_suggests_cols(text): + suggestions = suggest_type(text, text) + assert set(suggestions) == set( + [ + Column(table_refs=(), local_tables=(), qualifiable=True), + Function(schema=None), + Keyword("DISTINCT"), + ] + ) + + +@pytest.mark.parametrize( + "text, text_before, last_keyword", + [ + ("SELECT DISTINCT FROM tbl x JOIN tbl1 y", "SELECT DISTINCT", "SELECT"), + ( + "SELECT * FROM tbl x JOIN tbl1 y ORDER BY ", + "SELECT * FROM tbl x JOIN tbl1 y ORDER BY ", + "ORDER BY", + ), + ], +) +def test_distinct_and_order_by_suggestions_with_aliases( + text, text_before, last_keyword +): + suggestions = suggest_type(text, text_before) + assert set(suggestions) == set( + [ + Column( + table_refs=( + TableReference(None, "tbl", "x", False), + TableReference(None, "tbl1", "y", False), + ), + local_tables=(), + qualifiable=True, + ), + Function(schema=None), + Keyword(last_keyword), + ] + ) + + +@pytest.mark.parametrize( + "text, text_before", + [ + ("SELECT DISTINCT x. FROM tbl x JOIN tbl1 y", "SELECT DISTINCT x."), + ( + "SELECT * FROM tbl x JOIN tbl1 y ORDER BY x.", + "SELECT * FROM tbl x JOIN tbl1 y ORDER BY x.", + ), + ], +) +def test_distinct_and_order_by_suggestions_with_alias_given(text, text_before): + suggestions = suggest_type(text, text_before) + assert set(suggestions) == set( + [ + Column( + table_refs=(TableReference(None, "tbl", "x", False),), + local_tables=(), + qualifiable=False, + ), + Table(schema="x"), + View(schema="x"), + Function(schema="x"), + ] + ) + + +def test_function_arguments_with_alias_given(): + suggestions = suggest_type("SELECT avg(x. FROM tbl x, tbl2 y", "SELECT avg(x.") + + assert set(suggestions) == set( + [ + Column( + table_refs=(TableReference(None, "tbl", "x", False),), + local_tables=(), + qualifiable=False, + ), + Table(schema="x"), + View(schema="x"), + Function(schema="x"), + ] + ) + + +def test_col_comma_suggests_cols(): + suggestions = suggest_type("SELECT a, b, FROM tbl", "SELECT a, b,") + assert set(suggestions) == set( + [ + Column(table_refs=((None, "tbl", None, False),), qualifiable=True), + Function(schema=None), + Keyword("SELECT"), + ] + ) + + +def test_table_comma_suggests_tables_and_schemas(): + suggestions = suggest_type("SELECT a, b FROM tbl1, ", "SELECT a, b FROM tbl1, ") + assert set(suggestions) == set([FromClauseItem(schema=None), Schema()]) + + +def test_into_suggests_tables_and_schemas(): + suggestion = suggest_type("INSERT INTO ", "INSERT INTO ") + assert set(suggestion) == set([Table(schema=None), View(schema=None), Schema()]) + + +@pytest.mark.parametrize( + "text", ["INSERT INTO abc (", "INSERT INTO abc () SELECT * FROM hij;"] +) +def test_insert_into_lparen_suggests_cols(text): + suggestions = suggest_type(text, "INSERT INTO abc (") + assert suggestions == ( + Column(table_refs=((None, "abc", None, False),), context="insert"), + ) + + +def test_insert_into_lparen_partial_text_suggests_cols(): + suggestions = suggest_type("INSERT INTO abc (i", "INSERT INTO abc (i") + assert suggestions == ( + Column(table_refs=((None, "abc", None, False),), context="insert"), + ) + + +def test_insert_into_lparen_comma_suggests_cols(): + suggestions = suggest_type("INSERT INTO abc (id,", "INSERT INTO abc (id,") + assert suggestions == ( + Column(table_refs=((None, "abc", None, False),), context="insert"), + ) + + +def test_partially_typed_col_name_suggests_col_names(): + suggestions = suggest_type( + "SELECT * FROM tabl WHERE col_n", "SELECT * FROM tabl WHERE col_n" + ) + assert set(suggestions) == cols_etc("tabl", last_keyword="WHERE") + + +def test_dot_suggests_cols_of_a_table_or_schema_qualified_table(): + suggestions = suggest_type("SELECT tabl. FROM tabl", "SELECT tabl.") + assert set(suggestions) == set( + [ + Column(table_refs=((None, "tabl", None, False),)), + Table(schema="tabl"), + View(schema="tabl"), + Function(schema="tabl"), + ] + ) + + +@pytest.mark.parametrize( + "sql", + [ + "SELECT t1. FROM tabl1 t1", + "SELECT t1. FROM tabl1 t1, tabl2 t2", + 'SELECT t1. FROM "tabl1" t1', + 'SELECT t1. FROM "tabl1" t1, "tabl2" t2', + ], +) +def test_dot_suggests_cols_of_an_alias(sql): + suggestions = suggest_type(sql, "SELECT t1.") + assert set(suggestions) == set( + [ + Table(schema="t1"), + View(schema="t1"), + Column(table_refs=((None, "tabl1", "t1", False),)), + Function(schema="t1"), + ] + ) + + +@pytest.mark.parametrize( + "sql", + [ + "SELECT * FROM tabl1 t1 WHERE t1.", + "SELECT * FROM tabl1 t1, tabl2 t2 WHERE t1.", + 'SELECT * FROM "tabl1" t1 WHERE t1.', + 'SELECT * FROM "tabl1" t1, tabl2 t2 WHERE t1.', + ], +) +def test_dot_suggests_cols_of_an_alias_where(sql): + suggestions = suggest_type(sql, sql) + assert set(suggestions) == set( + [ + Table(schema="t1"), + View(schema="t1"), + Column(table_refs=((None, "tabl1", "t1", False),)), + Function(schema="t1"), + ] + ) + + +def test_dot_col_comma_suggests_cols_or_schema_qualified_table(): + suggestions = suggest_type( + "SELECT t1.a, t2. FROM tabl1 t1, tabl2 t2", "SELECT t1.a, t2." + ) + assert set(suggestions) == set( + [ + Column(table_refs=((None, "tabl2", "t2", False),)), + Table(schema="t2"), + View(schema="t2"), + Function(schema="t2"), + ] + ) + + +@pytest.mark.parametrize( + "expression", + [ + "SELECT * FROM (", + "SELECT * FROM foo WHERE EXISTS (", + "SELECT * FROM foo WHERE bar AND NOT EXISTS (", + ], +) +def test_sub_select_suggests_keyword(expression): + suggestion = suggest_type(expression, expression) + assert suggestion == (Keyword(),) + + +@pytest.mark.parametrize( + "expression", + [ + "SELECT * FROM (S", + "SELECT * FROM foo WHERE EXISTS (S", + "SELECT * FROM foo WHERE bar AND NOT EXISTS (S", + ], +) +def test_sub_select_partial_text_suggests_keyword(expression): + suggestion = suggest_type(expression, expression) + assert suggestion == (Keyword(),) + + +def test_outer_table_reference_in_exists_subquery_suggests_columns(): + q = "SELECT * FROM foo f WHERE EXISTS (SELECT 1 FROM bar WHERE f." + suggestions = suggest_type(q, q) + assert set(suggestions) == set( + [ + Column(table_refs=((None, "foo", "f", False),)), + Table(schema="f"), + View(schema="f"), + Function(schema="f"), + ] + ) + + +@pytest.mark.parametrize("expression", ["SELECT * FROM (SELECT * FROM "]) +def test_sub_select_table_name_completion(expression): + suggestion = suggest_type(expression, expression) + assert set(suggestion) == set([FromClauseItem(schema=None), Schema()]) + + +@pytest.mark.parametrize( + "expression", + [ + "SELECT * FROM foo WHERE EXISTS (SELECT * FROM ", + "SELECT * FROM foo WHERE bar AND NOT EXISTS (SELECT * FROM ", + ], +) +def test_sub_select_table_name_completion_with_outer_table(expression): + suggestion = suggest_type(expression, expression) + tbls = tuple([(None, "foo", None, False)]) + assert set(suggestion) == set( + [FromClauseItem(schema=None, table_refs=tbls), Schema()] + ) + + +def test_sub_select_col_name_completion(): + suggestions = suggest_type( + "SELECT * FROM (SELECT FROM abc", "SELECT * FROM (SELECT " + ) + assert set(suggestions) == set( + [ + Column(table_refs=((None, "abc", None, False),), qualifiable=True), + Function(schema=None), + Keyword("SELECT"), + ] + ) + + +@pytest.mark.xfail +def test_sub_select_multiple_col_name_completion(): + suggestions = suggest_type( + "SELECT * FROM (SELECT a, FROM abc", "SELECT * FROM (SELECT a, " + ) + assert set(suggestions) == cols_etc("abc") + + +def test_sub_select_dot_col_name_completion(): + suggestions = suggest_type( + "SELECT * FROM (SELECT t. FROM tabl t", "SELECT * FROM (SELECT t." + ) + assert set(suggestions) == set( + [ + Column(table_refs=((None, "tabl", "t", False),)), + Table(schema="t"), + View(schema="t"), + Function(schema="t"), + ] + ) + + +@pytest.mark.parametrize("join_type", ("", "INNER", "LEFT", "RIGHT OUTER")) +@pytest.mark.parametrize("tbl_alias", ("", "foo")) +def test_join_suggests_tables_and_schemas(tbl_alias, join_type): + text = "SELECT * FROM abc {0} {1} JOIN ".format(tbl_alias, join_type) + suggestion = suggest_type(text, text) + tbls = tuple([(None, "abc", tbl_alias or None, False)]) + assert set(suggestion) == set( + [FromClauseItem(schema=None, table_refs=tbls), Schema(), Join(tbls, None)] + ) + + +def test_left_join_with_comma(): + text = "select * from foo f left join bar b," + suggestions = suggest_type(text, text) + # tbls should also include (None, 'bar', 'b', False) + # but there's a bug with commas + tbls = tuple([(None, "foo", "f", False)]) + assert set(suggestions) == set( + [FromClauseItem(schema=None, table_refs=tbls), Schema()] + ) + + +@pytest.mark.parametrize( + "sql", + [ + "SELECT * FROM abc a JOIN def d ON a.", + "SELECT * FROM abc a JOIN def d ON a.id = d.id AND a.", + ], +) +def test_join_alias_dot_suggests_cols1(sql): + suggestions = suggest_type(sql, sql) + tables = ((None, "abc", "a", False), (None, "def", "d", False)) + assert set(suggestions) == set( + [ + Column(table_refs=((None, "abc", "a", False),)), + Table(schema="a"), + View(schema="a"), + Function(schema="a"), + JoinCondition(table_refs=tables, parent=(None, "abc", "a", False)), + ] + ) + + +@pytest.mark.parametrize( + "sql", + [ + "SELECT * FROM abc a JOIN def d ON a.id = d.", + "SELECT * FROM abc a JOIN def d ON a.id = d.id AND a.id2 = d.", + ], +) +def test_join_alias_dot_suggests_cols2(sql): + suggestion = suggest_type(sql, sql) + assert set(suggestion) == set( + [ + Column(table_refs=((None, "def", "d", False),)), + Table(schema="d"), + View(schema="d"), + Function(schema="d"), + ] + ) + + +@pytest.mark.parametrize( + "sql", + [ + "select a.x, b.y from abc a join bcd b on ", + """select a.x, b.y +from abc a +join bcd b on +""", + """select a.x, b.y +from abc a +join bcd b +on """, + "select a.x, b.y from abc a join bcd b on a.id = b.id OR ", + ], +) +def test_on_suggests_aliases_and_join_conditions(sql): + suggestions = suggest_type(sql, sql) + tables = ((None, "abc", "a", False), (None, "bcd", "b", False)) + assert set(suggestions) == set( + (JoinCondition(table_refs=tables, parent=None), Alias(aliases=("a", "b"))) + ) + + +@pytest.mark.parametrize( + "sql", + [ + "select abc.x, bcd.y from abc join bcd on abc.id = bcd.id AND ", + "select abc.x, bcd.y from abc join bcd on ", + ], +) +def test_on_suggests_tables_and_join_conditions(sql): + suggestions = suggest_type(sql, sql) + tables = ((None, "abc", None, False), (None, "bcd", None, False)) + assert set(suggestions) == set( + (JoinCondition(table_refs=tables, parent=None), Alias(aliases=("abc", "bcd"))) + ) + + +@pytest.mark.parametrize( + "sql", + [ + "select a.x, b.y from abc a join bcd b on a.id = ", + "select a.x, b.y from abc a join bcd b on a.id = b.id AND a.id2 = ", + ], +) +def test_on_suggests_aliases_right_side(sql): + suggestions = suggest_type(sql, sql) + assert suggestions == (Alias(aliases=("a", "b")),) + + +@pytest.mark.parametrize( + "sql", + [ + "select abc.x, bcd.y from abc join bcd on abc.id = bcd.id and ", + "select abc.x, bcd.y from abc join bcd on ", + ], +) +def test_on_suggests_tables_and_join_conditions_right_side(sql): + suggestions = suggest_type(sql, sql) + tables = ((None, "abc", None, False), (None, "bcd", None, False)) + assert set(suggestions) == set( + (JoinCondition(table_refs=tables, parent=None), Alias(aliases=("abc", "bcd"))) + ) + + +@pytest.mark.parametrize( + "text", + ( + "select * from abc inner join def using (", + "select * from abc inner join def using (col1, ", + "insert into hij select * from abc inner join def using (", + """insert into hij(x, y, z) + select * from abc inner join def using (col1, """, + """insert into hij (a,b,c) + select * from abc inner join def using (col1, """, + ), +) +def test_join_using_suggests_common_columns(text): + tables = ((None, "abc", None, False), (None, "def", None, False)) + assert set(suggest_type(text, text)) == set( + [Column(table_refs=tables, require_last_table=True)] + ) + + +def test_suggest_columns_after_multiple_joins(): + sql = """select * from t1 + inner join t2 ON + t1.id = t2.t1_id + inner join t3 ON + t2.id = t3.""" + suggestions = suggest_type(sql, sql) + assert Column(table_refs=((None, "t3", None, False),)) in set(suggestions) + + +def test_2_statements_2nd_current(): + suggestions = suggest_type( + "select * from a; select * from ", "select * from a; select * from " + ) + assert set(suggestions) == set([FromClauseItem(schema=None), Schema()]) + + suggestions = suggest_type( + "select * from a; select from b", "select * from a; select " + ) + assert set(suggestions) == set( + [ + Column(table_refs=((None, "b", None, False),), qualifiable=True), + Function(schema=None), + Keyword("SELECT"), + ] + ) + + # Should work even if first statement is invalid + suggestions = suggest_type( + "select * from; select * from ", "select * from; select * from " + ) + assert set(suggestions) == set([FromClauseItem(schema=None), Schema()]) + + +def test_2_statements_1st_current(): + suggestions = suggest_type("select * from ; select * from b", "select * from ") + assert set(suggestions) == set([FromClauseItem(schema=None), Schema()]) + + suggestions = suggest_type("select from a; select * from b", "select ") + assert set(suggestions) == cols_etc("a", last_keyword="SELECT") + + +def test_3_statements_2nd_current(): + suggestions = suggest_type( + "select * from a; select * from ; select * from c", + "select * from a; select * from ", + ) + assert set(suggestions) == set([FromClauseItem(schema=None), Schema()]) + + suggestions = suggest_type( + "select * from a; select from b; select * from c", "select * from a; select " + ) + assert set(suggestions) == cols_etc("b", last_keyword="SELECT") + + +@pytest.mark.parametrize( + "text", + [ + """ +CREATE OR REPLACE FUNCTION func() RETURNS setof int AS $$ +SELECT FROM foo; +SELECT 2 FROM bar; +$$ language sql; + """, + """create function func2(int, varchar) +RETURNS text +language sql AS +$func$ +SELECT 2 FROM bar; +SELECT FROM foo; +$func$ + """, + """ +CREATE OR REPLACE FUNCTION func() RETURNS setof int AS $func$ +SELECT 3 FROM foo; +SELECT 2 FROM bar; +$$ language sql; +create function func2(int, varchar) +RETURNS text +language sql AS +$func$ +SELECT 2 FROM bar; +SELECT FROM foo; +$func$ + """, + """ +SELECT * FROM baz; +CREATE OR REPLACE FUNCTION func() RETURNS setof int AS $func$ +SELECT FROM foo; +SELECT 2 FROM bar; +$$ language sql; +create function func2(int, varchar) +RETURNS text +language sql AS +$func$ +SELECT 3 FROM bar; +SELECT FROM foo; +$func$ +SELECT * FROM qux; + """, + ], +) +def test_statements_in_function_body(text): + suggestions = suggest_type(text, text[: text.find(" ") + 1]) + assert set(suggestions) == set( + [ + Column(table_refs=((None, "foo", None, False),), qualifiable=True), + Function(schema=None), + Keyword("SELECT"), + ] + ) + + +functions = [ + """ +CREATE OR REPLACE FUNCTION func() RETURNS setof int AS $$ +SELECT 1 FROM foo; +SELECT 2 FROM bar; +$$ language sql; + """, + """ +create function func2(int, varchar) +RETURNS text +language sql AS +' +SELECT 2 FROM bar; +SELECT 1 FROM foo; +'; + """, +] + + +@pytest.mark.parametrize("text", functions) +def test_statements_with_cursor_after_function_body(text): + suggestions = suggest_type(text, text[: text.find("; ") + 1]) + assert set(suggestions) == set([Keyword(), Special()]) + + +@pytest.mark.parametrize("text", functions) +def test_statements_with_cursor_before_function_body(text): + suggestions = suggest_type(text, "") + assert set(suggestions) == set([Keyword(), Special()]) + + +def test_create_db_with_template(): + suggestions = suggest_type( + "create database foo with template ", "create database foo with template " + ) + + assert set(suggestions) == set((Database(),)) + + +@pytest.mark.parametrize("initial_text", ("", " ", "\t \t", "\n")) +def test_specials_included_for_initial_completion(initial_text): + suggestions = suggest_type(initial_text, initial_text) + + assert set(suggestions) == set([Keyword(), Special()]) + + +def test_drop_schema_qualified_table_suggests_only_tables(): + text = "DROP TABLE schema_name.table_name" + suggestions = suggest_type(text, text) + assert suggestions == (Table(schema="schema_name"),) + + +@pytest.mark.parametrize("text", (",", " ,", "sel ,")) +def test_handle_pre_completion_comma_gracefully(text): + suggestions = suggest_type(text, text) + + assert iter(suggestions) + + +def test_drop_schema_suggests_schemas(): + sql = "DROP SCHEMA " + assert suggest_type(sql, sql) == (Schema(),) + + +@pytest.mark.parametrize("text", ["SELECT x::", "SELECT x::y", "SELECT (x + y)::"]) +def test_cast_operator_suggests_types(text): + assert set(suggest_type(text, text)) == set( + [Datatype(schema=None), Table(schema=None), Schema()] + ) + + +@pytest.mark.parametrize( + "text", ["SELECT foo::bar.", "SELECT foo::bar.baz", "SELECT (x + y)::bar."] +) +def test_cast_operator_suggests_schema_qualified_types(text): + assert set(suggest_type(text, text)) == set( + [Datatype(schema="bar"), Table(schema="bar")] + ) + + +def test_alter_column_type_suggests_types(): + q = "ALTER TABLE foo ALTER COLUMN bar TYPE " + assert set(suggest_type(q, q)) == set( + [Datatype(schema=None), Table(schema=None), Schema()] + ) + + +@pytest.mark.parametrize( + "text", + [ + "CREATE TABLE foo (bar ", + "CREATE TABLE foo (bar DOU", + "CREATE TABLE foo (bar INT, baz ", + "CREATE TABLE foo (bar INT, baz TEXT, qux ", + "CREATE FUNCTION foo (bar ", + "CREATE FUNCTION foo (bar INT, baz ", + "SELECT * FROM foo() AS bar (baz ", + "SELECT * FROM foo() AS bar (baz INT, qux ", + # make sure this doesnt trigger special completion + "CREATE TABLE foo (dt d", + ], +) +def test_identifier_suggests_types_in_parentheses(text): + assert set(suggest_type(text, text)) == set( + [Datatype(schema=None), Table(schema=None), Schema()] + ) + + +@pytest.mark.parametrize( + "text", + [ + "SELECT foo ", + "SELECT foo FROM bar ", + "SELECT foo AS bar ", + "SELECT foo bar ", + "SELECT * FROM foo AS bar ", + "SELECT * FROM foo bar ", + "SELECT foo FROM (SELECT bar ", + ], +) +def test_alias_suggests_keywords(text): + suggestions = suggest_type(text, text) + assert suggestions == (Keyword(),) + + +def test_invalid_sql(): + # issue 317 + text = "selt *" + suggestions = suggest_type(text, text) + assert suggestions == (Keyword(),) + + +@pytest.mark.parametrize( + "text", + ["SELECT * FROM foo where created > now() - ", "select * from foo where bar "], +) +def test_suggest_where_keyword(text): + # https://github.com/dbcli/mycli/issues/135 + suggestions = suggest_type(text, text) + assert set(suggestions) == cols_etc("foo", last_keyword="WHERE") + + +@pytest.mark.parametrize( + "text, before, expected", + [ + ( + "\\ns abc SELECT ", + "SELECT ", + [ + Column(table_refs=(), qualifiable=True), + Function(schema=None), + Keyword("SELECT"), + ], + ), + ("\\ns abc SELECT foo ", "SELECT foo ", (Keyword(),)), + ( + "\\ns abc SELECT t1. FROM tabl1 t1", + "SELECT t1.", + [ + Table(schema="t1"), + View(schema="t1"), + Column(table_refs=((None, "tabl1", "t1", False),)), + Function(schema="t1"), + ], + ), + ], +) +def test_named_query_completion(text, before, expected): + suggestions = suggest_type(text, before) + assert set(expected) == set(suggestions) + + +def test_select_suggests_fields_from_function(): + suggestions = suggest_type("SELECT FROM func()", "SELECT ") + assert set(suggestions) == cols_etc("func", is_function=True, last_keyword="SELECT") + + +@pytest.mark.parametrize("sql", ["("]) +def test_leading_parenthesis(sql): + # No assertion for now; just make sure it doesn't crash + suggest_type(sql, sql) + + +@pytest.mark.parametrize("sql", ['select * from "', 'select * from "foo']) +def test_ignore_leading_double_quotes(sql): + suggestions = suggest_type(sql, sql) + assert FromClauseItem(schema=None) in set(suggestions) + + +@pytest.mark.parametrize( + "sql", + [ + "ALTER TABLE foo ALTER COLUMN ", + "ALTER TABLE foo ALTER COLUMN bar", + "ALTER TABLE foo DROP COLUMN ", + "ALTER TABLE foo DROP COLUMN bar", + ], +) +def test_column_keyword_suggests_columns(sql): + suggestions = suggest_type(sql, sql) + assert set(suggestions) == set([Column(table_refs=((None, "foo", None, False),))]) + + +def test_handle_unrecognized_kw_generously(): + sql = "SELECT * FROM sessions WHERE session = 1 AND " + suggestions = suggest_type(sql, sql) + expected = Column(table_refs=((None, "sessions", None, False),), qualifiable=True) + + assert expected in set(suggestions) + + +@pytest.mark.parametrize("sql", ["ALTER ", "ALTER TABLE foo ALTER "]) +def test_keyword_after_alter(sql): + assert Keyword("ALTER") in set(suggest_type(sql, sql)) diff --git a/tests/utils.py b/tests/utils.py new file mode 100644 index 0000000..2427c30 --- /dev/null +++ b/tests/utils.py @@ -0,0 +1,95 @@ +import pytest +import psycopg2 +import psycopg2.extras +from pgcli.main import format_output, OutputSettings +from pgcli.pgexecute import register_json_typecasters +from os import getenv + +POSTGRES_USER = getenv("PGUSER", "postgres") +POSTGRES_HOST = getenv("PGHOST", "localhost") +POSTGRES_PORT = getenv("PGPORT", 5432) +POSTGRES_PASSWORD = getenv("PGPASSWORD", "") + + +def db_connection(dbname=None): + conn = psycopg2.connect( + user=POSTGRES_USER, + host=POSTGRES_HOST, + password=POSTGRES_PASSWORD, + port=POSTGRES_PORT, + database=dbname, + ) + conn.autocommit = True + return conn + + +try: + conn = db_connection() + CAN_CONNECT_TO_DB = True + SERVER_VERSION = conn.server_version + json_types = register_json_typecasters(conn, lambda x: x) + JSON_AVAILABLE = "json" in json_types + JSONB_AVAILABLE = "jsonb" in json_types +except: + CAN_CONNECT_TO_DB = JSON_AVAILABLE = JSONB_AVAILABLE = False + SERVER_VERSION = 0 + + +dbtest = pytest.mark.skipif( + not CAN_CONNECT_TO_DB, + reason="Need a postgres instance at localhost accessible by user 'postgres'", +) + + +requires_json = pytest.mark.skipif( + not JSON_AVAILABLE, reason="Postgres server unavailable or json type not defined" +) + + +requires_jsonb = pytest.mark.skipif( + not JSONB_AVAILABLE, reason="Postgres server unavailable or jsonb type not defined" +) + + +def create_db(dbname): + with db_connection().cursor() as cur: + try: + cur.execute("""CREATE DATABASE _test_db""") + except: + pass + + +def drop_tables(conn): + with conn.cursor() as cur: + cur.execute( + """ + DROP SCHEMA public CASCADE; + CREATE SCHEMA public; + DROP SCHEMA IF EXISTS schema1 CASCADE; + DROP SCHEMA IF EXISTS schema2 CASCADE""" + ) + + +def run( + executor, sql, join=False, expanded=False, pgspecial=None, exception_formatter=None +): + " Return string output for the sql to be run " + + results = executor.run(sql, pgspecial, exception_formatter) + formatted = [] + settings = OutputSettings( + table_format="psql", dcmlfmt="d", floatfmt="g", expanded=expanded + ) + for title, rows, headers, status, sql, success, is_special in results: + formatted.extend(format_output(title, rows, headers, status, settings)) + if join: + formatted = "\n".join(formatted) + + return formatted + + +def completions_to_set(completions): + return set( + (completion.display_text, completion.display_meta_text) + for completion in completions + ) diff --git a/tox.ini b/tox.ini new file mode 100644 index 0000000..c2d4239 --- /dev/null +++ b/tox.ini @@ -0,0 +1,13 @@ +[tox] +envlist = py36, py37, py38, py39 +[testenv] +deps = pytest>=2.7.0,<=3.0.7 + mock>=1.0.1 + behave>=1.2.4 + pexpect==3.3 +commands = py.test + behave tests/features +passenv = PGHOST + PGPORT + PGUSER + PGPASSWORD